From 891a8ab033a0aabd0ba09d486329520515068556 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:37:20 +0800 Subject: [PATCH 001/111] test: add organized regression suites and fix discovered bugs --- .github/workflows/codeql.yml | 2 +- .github/workflows/regression.yml | 154 + .gitignore | 8 + CONTRIBUTING.md | 6 +- .../Configuration/UserConfigurationManager.cs | 11 +- .../Controllers/JellyfinEnhancedController.cs | 3 + .../JellyfinEnhanced.csproj | 9 +- .../ScheduledTasks/ArrTagsSyncTask.cs | 2 +- .../JellyfinToSeerrWatchlistSyncTask.cs | 7 +- .../JellyseerrWatchlistSyncTask.cs | 4 + .../Services/MaintenanceModeService.cs | 109 +- .../Services/PosterTags/PosterTagSettings.cs | 2 +- .../PosterTags/Resolution/LanguageNames.cs | 17 +- .../PosterTags/Resolution/ResolutionData.g.cs | 2 +- .../Services/ScriptInjectionStartupFilter.cs | 2 +- .../SpoilerGuard/SpoilerBlurImageFilter.cs | 12 + .../Services/TmdbResponseCache.cs | 11 +- .../Services/UsageEventCounterService.cs | 4 +- .../Services/WatchProvidersBatch.cs | 6 +- .../Services/WatchlistMonitor.cs | 19 +- .../Services/WikidataAwardsService.cs | 2 + .../js/core/api-client.js | 3 + .../js/core/dom-observer.js | 32 +- .../js/core/tag-renderer-base.js | 14 +- .../js/enhanced/bookmarks/bookmarks.js | 94 +- .../js/enhanced/events.js | 7 +- .../hiddencontent/hidden-content-data.js | 2 + .../hiddencontent/hidden-content-filter.js | 23 +- .../features-details-media-info.js | 19 +- .../itemdetails/features-release-dates.js | 8 +- .../js/enhanced/player/pausescreen.js | 49 +- .../settingspanel/ui-panel-settings.js | 2 +- .../js/enhanced/spoilerguard/image-refresh.js | 6 +- .../js/enhanced/spoilerguard/state.js | 42 +- .../moreinfo/more-info-modal-init.js | 39 +- .../js/jellyseerr/ui/ui-buttons.js | 26 +- .../js/others/letterboxd-links.js | 5 + .../packages.jf10.lock.json | 348 + .../packages.jf12.lock.json | 305 + package-lock.json | 1251 +++ package.json | 21 + tests/README.md | 58 + tests/backend/Api/ApiAssetTests.cs | 139 + tests/backend/Api/ApiBrandingTests.cs | 97 + tests/backend/Api/ApiCdnRefreshTests.cs | 83 + tests/backend/Api/ApiConfigurationTests.cs | 101 + tests/backend/Api/ApiMiddlewareTests.cs | 158 + tests/backend/Api/ApiRoutingTests.cs | 112 + tests/backend/Api/ApiSeerrTests.cs | 241 + tests/backend/Core/CoreAwardsTests.cs | 73 + tests/backend/Core/CoreConfigurationTests.cs | 72 + tests/backend/Core/CoreMaintenanceTests.cs | 286 + tests/backend/Core/CorePersistenceTests.cs | 131 + .../Core/CoreServerConfigurationTests.cs | 63 + tests/backend/Core/CoreSharedStoresTests.cs | 112 + tests/backend/Core/CoreUsageTests.cs | 91 + tests/backend/Core/CoreWhatsNewTests.cs | 62 + .../Integrations/IntegrationAnalyticsTests.cs | 147 + .../Integrations/IntegrationAwardsTests.cs | 86 + .../Integrations/IntegrationCalendarTests.cs | 167 + .../IntegrationCompanyDiscoverTests.cs | 58 + .../Integrations/IntegrationHttpTests.cs | 146 + .../Integrations/IntegrationItemStatsTests.cs | 73 + .../Integrations/IntegrationLifecycleTests.cs | 24 + .../Integrations/IntegrationMdblistTests.cs | 95 + .../Integrations/IntegrationTmdbTests.cs | 152 + .../IntegrationWatchProvidersTests.cs | 65 + tests/backend/JE.Tests.csproj | 31 + .../PosterTags/HistoryNativeClientTests.cs | 48 + .../PerfRound2TagCacheResponseTests.cs | 97 + .../PosterTags/PerfRound2TagCacheTests.cs | 77 + .../PosterTags/PosterBlurFilterTests.cs | 181 + tests/backend/PosterTags/PosterBlurTests.cs | 62 + .../PosterTags/PosterEpisodeSelectorTests.cs | 63 + .../PosterTags/PosterRegressionTests.cs | 215 + .../PosterTags/PosterSettingsProviderTests.cs | 50 + tests/backend/PosterTags/PosterTokenTests.cs | 79 + .../backend/Privacy/PrivacyAutoEnableTests.cs | 107 + tests/backend/Privacy/PrivacyCacheTests.cs | 144 + .../Privacy/PrivacyFieldFilterTests.cs | 142 + .../Privacy/PrivacyHiddenContentTests.cs | 132 + tests/backend/Privacy/PrivacyIdentityTests.cs | 176 + tests/backend/Privacy/PrivacyPolicyTests.cs | 117 + .../Privacy/PrivacySpoilerStripTests.cs | 97 + .../Privacy/ReviewActivityVisibilityTests.cs | 99 + tests/backend/Privacy/UrlPolicyTests.cs | 56 + tests/backend/README.md | 16 + .../ScheduledTasks/TaskAutoRequestTests.cs | 160 + .../ScheduledTasks/TaskMetadataTests.cs | 188 + .../ScheduledTasks/TaskRegressionTests.cs | 217 + .../ScheduledTasks/TaskSeerrSyncTests.cs | 148 + tests/backend/Support/ApiPluginFixture.cs | 35 + tests/backend/Support/CoreFixture.cs | 33 + .../backend/Support/IntegrationEnvironment.cs | 20 + tests/backend/Support/IntegrationTransport.cs | 17 + tests/backend/packages.jf10.lock.json | 504 + tests/backend/packages.jf12.lock.json | 453 + tests/browser/README.md | 33 + tests/browser/fixture.html | 18 + tests/browser/flows.spec.cjs | 159 + .../request-modal-chromium-linux.png | Bin 0 -> 127980 bytes .../request-modal-firefox-linux.png | Bin 0 -> 95180 bytes .../request-modal-mobile-chromium-linux.png | Bin 0 -> 85531 bytes tests/browser/fonts/LICENSE | 101 + tests/browser/fonts/LiberationSans-Bold.ttf | Bin 0 -> 414568 bytes .../browser/fonts/LiberationSans-Regular.ttf | Bin 0 -> 410820 bytes tests/browser/playwright.config.cjs | 16 + tests/browser/server.cjs | 15 + tests/docs/README.md | 75 + tests/docs/coverage-matrix.md | 138 + tests/docs/history-audit.md | 40 + tests/docs/history-early.md | 48 + tests/docs/history-middle.md | 328 + tests/docs/history-recent-triage.json | 2294 +++++ tests/docs/history-recent.md | 45 + tests/docs/maintenance-recovery.md | 9 + tests/docs/mutations.md | 25 + tests/docs/perf-round2.md | 84 + tests/docs/production-inventory.json | 8814 +++++++++++++++++ tests/docs/progress.md | 62 + tests/docs/validation.md | 96 + tests/frontend/README.md | 44 + .../bootstrap/bootstrap-header.test.mjs | 84 + .../bootstrap/bootstrap-native-tabs.test.mjs | 117 + .../bootstrap-settings-panel.test.mjs | 60 + .../bootstrap/bootstrap-shortcuts.test.mjs | 63 + tests/frontend/bootstrap/bootstrap.test.mjs | 192 + tests/frontend/core/core-letterboxd.test.mjs | 26 + .../core/core-media-language.test.mjs | 15 + .../frontend/core/core-tag-pipeline.test.mjs | 27 + .../frontend/core/core-tag-renderer.test.mjs | 51 + tests/frontend/core/core-theme.test.mjs | 19 + .../frontend/core/core-translations.test.mjs | 34 + tests/frontend/core/core.test.mjs | 224 + .../features/features-autoskip.test.mjs | 46 + .../features/features-bookmarks.test.mjs | 117 + .../features/features-hidden.test.mjs | 61 + .../features/features-media-info.test.mjs | 37 + .../frontend/features/features-pause.test.mjs | 36 + .../features/features-playback.test.mjs | 18 + .../features/features-random.test.mjs | 24 + .../features/features-release-dates.test.mjs | 28 + .../features/features-requests.test.mjs | 30 + .../features/features-spoiler-ui.test.mjs | 64 + .../features/features-spoiler.test.mjs | 114 + .../features/features-subtitles.test.mjs | 48 + tests/frontend/helpers/coverage.mjs | 46 + tests/frontend/helpers/discovery.mjs | 19 + tests/frontend/helpers/harness.mjs | 71 + tests/frontend/pages/pages-activity.test.mjs | 51 + tests/frontend/pages/pages-awards.test.mjs | 24 + tests/frontend/pages/pages-calendar.test.mjs | 163 + tests/frontend/pages/pages-downloads.test.mjs | 45 + tests/frontend/pages/pages-moreinfo.test.mjs | 74 + .../pages/pages-recommendations.test.mjs | 48 + tests/frontend/pages/pages-reviews.test.mjs | 121 + tests/frontend/pages/pages-scroll.test.mjs | 33 + .../regressions/history-early-events.test.mjs | 41 + .../regressions/history-middle.test.mjs | 42 + .../history-recent-regressions.test.mjs | 82 + .../perf-round2-discovery.test.mjs | 51 + .../requests/request-submission.test.mjs | 172 + tests/frontend/run.mjs | 7 + tests/history/backend-mutations.json | 57 + tests/history/early-mutations.json | 18 + tests/history/early-triage.json | 3012 ++++++ tests/history/middle-mutations.json | 61 + tests/history/recent-mutations.json | 66 + tests/host/README.md | 32 + tests/host/browser.mjs | 60 + tests/host/run.py | 287 + tests/inventory/generate.py | 92 + tests/poster/README.md | 11 + .../poster/parity}/.gitignore | 0 .../PosterTagParity/PosterTagParity.csproj | 2 +- .../poster/parity}/PosterTagParity/Program.cs | 4 +- tests/poster/parity/README.md | 7 + .../poster/parity}/casing-expected.mjs | 0 .../poster/parity}/diff.mjs | 0 .../poster/parity}/fetch-inputs.mjs | 0 .../poster/parity}/gen-resolution-data.mjs | 8 +- .../poster/parity}/names-expected.mjs | 0 tests/poster/parity/offline-inputs.mjs | 13 + .../poster/parity}/profiles.mjs | 0 .../poster/parity}/run.mjs | 12 +- .../poster/parity}/web-expected.mjs | 4 +- .../poster/pipeline}/.gitignore | 0 .../PosterTagsPipelineHarness.csproj | 5 +- .../poster/pipeline}/Program.cs | 37 +- .../poster/pipeline}/README.txt | 6 +- .../poster/rendering}/Directory.Build.props | 0 .../rendering}/PosterTagsHarness.csproj | 2 +- .../poster/rendering}/Program.cs | 0 tests/run.py | 117 + tests/runner/clean.py | 63 + tests/runner/coverage.py | 39 + tests/runner/mutations.py | 149 + tests/runner/poster.py | 41 + tests/runner/tests/test_regression.py | 98 + 199 files changed, 28543 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/regression.yml create mode 100644 Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json create mode 100644 Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tests/README.md create mode 100644 tests/backend/Api/ApiAssetTests.cs create mode 100644 tests/backend/Api/ApiBrandingTests.cs create mode 100644 tests/backend/Api/ApiCdnRefreshTests.cs create mode 100644 tests/backend/Api/ApiConfigurationTests.cs create mode 100644 tests/backend/Api/ApiMiddlewareTests.cs create mode 100644 tests/backend/Api/ApiRoutingTests.cs create mode 100644 tests/backend/Api/ApiSeerrTests.cs create mode 100644 tests/backend/Core/CoreAwardsTests.cs create mode 100644 tests/backend/Core/CoreConfigurationTests.cs create mode 100644 tests/backend/Core/CoreMaintenanceTests.cs create mode 100644 tests/backend/Core/CorePersistenceTests.cs create mode 100644 tests/backend/Core/CoreServerConfigurationTests.cs create mode 100644 tests/backend/Core/CoreSharedStoresTests.cs create mode 100644 tests/backend/Core/CoreUsageTests.cs create mode 100644 tests/backend/Core/CoreWhatsNewTests.cs create mode 100644 tests/backend/Integrations/IntegrationAnalyticsTests.cs create mode 100644 tests/backend/Integrations/IntegrationAwardsTests.cs create mode 100644 tests/backend/Integrations/IntegrationCalendarTests.cs create mode 100644 tests/backend/Integrations/IntegrationCompanyDiscoverTests.cs create mode 100644 tests/backend/Integrations/IntegrationHttpTests.cs create mode 100644 tests/backend/Integrations/IntegrationItemStatsTests.cs create mode 100644 tests/backend/Integrations/IntegrationLifecycleTests.cs create mode 100644 tests/backend/Integrations/IntegrationMdblistTests.cs create mode 100644 tests/backend/Integrations/IntegrationTmdbTests.cs create mode 100644 tests/backend/Integrations/IntegrationWatchProvidersTests.cs create mode 100644 tests/backend/JE.Tests.csproj create mode 100644 tests/backend/PosterTags/HistoryNativeClientTests.cs create mode 100644 tests/backend/PosterTags/PerfRound2TagCacheResponseTests.cs create mode 100644 tests/backend/PosterTags/PerfRound2TagCacheTests.cs create mode 100644 tests/backend/PosterTags/PosterBlurFilterTests.cs create mode 100644 tests/backend/PosterTags/PosterBlurTests.cs create mode 100644 tests/backend/PosterTags/PosterEpisodeSelectorTests.cs create mode 100644 tests/backend/PosterTags/PosterRegressionTests.cs create mode 100644 tests/backend/PosterTags/PosterSettingsProviderTests.cs create mode 100644 tests/backend/PosterTags/PosterTokenTests.cs create mode 100644 tests/backend/Privacy/PrivacyAutoEnableTests.cs create mode 100644 tests/backend/Privacy/PrivacyCacheTests.cs create mode 100644 tests/backend/Privacy/PrivacyFieldFilterTests.cs create mode 100644 tests/backend/Privacy/PrivacyHiddenContentTests.cs create mode 100644 tests/backend/Privacy/PrivacyIdentityTests.cs create mode 100644 tests/backend/Privacy/PrivacyPolicyTests.cs create mode 100644 tests/backend/Privacy/PrivacySpoilerStripTests.cs create mode 100644 tests/backend/Privacy/ReviewActivityVisibilityTests.cs create mode 100644 tests/backend/Privacy/UrlPolicyTests.cs create mode 100644 tests/backend/README.md create mode 100644 tests/backend/ScheduledTasks/TaskAutoRequestTests.cs create mode 100644 tests/backend/ScheduledTasks/TaskMetadataTests.cs create mode 100644 tests/backend/ScheduledTasks/TaskRegressionTests.cs create mode 100644 tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs create mode 100644 tests/backend/Support/ApiPluginFixture.cs create mode 100644 tests/backend/Support/CoreFixture.cs create mode 100644 tests/backend/Support/IntegrationEnvironment.cs create mode 100644 tests/backend/Support/IntegrationTransport.cs create mode 100644 tests/backend/packages.jf10.lock.json create mode 100644 tests/backend/packages.jf12.lock.json create mode 100644 tests/browser/README.md create mode 100644 tests/browser/fixture.html create mode 100644 tests/browser/flows.spec.cjs create mode 100644 tests/browser/flows.spec.cjs-snapshots/request-modal-chromium-linux.png create mode 100644 tests/browser/flows.spec.cjs-snapshots/request-modal-firefox-linux.png create mode 100644 tests/browser/flows.spec.cjs-snapshots/request-modal-mobile-chromium-linux.png create mode 100644 tests/browser/fonts/LICENSE create mode 100644 tests/browser/fonts/LiberationSans-Bold.ttf create mode 100644 tests/browser/fonts/LiberationSans-Regular.ttf create mode 100644 tests/browser/playwright.config.cjs create mode 100644 tests/browser/server.cjs create mode 100644 tests/docs/README.md create mode 100644 tests/docs/coverage-matrix.md create mode 100644 tests/docs/history-audit.md create mode 100644 tests/docs/history-early.md create mode 100644 tests/docs/history-middle.md create mode 100644 tests/docs/history-recent-triage.json create mode 100644 tests/docs/history-recent.md create mode 100644 tests/docs/maintenance-recovery.md create mode 100644 tests/docs/mutations.md create mode 100644 tests/docs/perf-round2.md create mode 100644 tests/docs/production-inventory.json create mode 100644 tests/docs/progress.md create mode 100644 tests/docs/validation.md create mode 100644 tests/frontend/README.md create mode 100644 tests/frontend/bootstrap/bootstrap-header.test.mjs create mode 100644 tests/frontend/bootstrap/bootstrap-native-tabs.test.mjs create mode 100644 tests/frontend/bootstrap/bootstrap-settings-panel.test.mjs create mode 100644 tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs create mode 100644 tests/frontend/bootstrap/bootstrap.test.mjs create mode 100644 tests/frontend/core/core-letterboxd.test.mjs create mode 100644 tests/frontend/core/core-media-language.test.mjs create mode 100644 tests/frontend/core/core-tag-pipeline.test.mjs create mode 100644 tests/frontend/core/core-tag-renderer.test.mjs create mode 100644 tests/frontend/core/core-theme.test.mjs create mode 100644 tests/frontend/core/core-translations.test.mjs create mode 100644 tests/frontend/core/core.test.mjs create mode 100644 tests/frontend/features/features-autoskip.test.mjs create mode 100644 tests/frontend/features/features-bookmarks.test.mjs create mode 100644 tests/frontend/features/features-hidden.test.mjs create mode 100644 tests/frontend/features/features-media-info.test.mjs create mode 100644 tests/frontend/features/features-pause.test.mjs create mode 100644 tests/frontend/features/features-playback.test.mjs create mode 100644 tests/frontend/features/features-random.test.mjs create mode 100644 tests/frontend/features/features-release-dates.test.mjs create mode 100644 tests/frontend/features/features-requests.test.mjs create mode 100644 tests/frontend/features/features-spoiler-ui.test.mjs create mode 100644 tests/frontend/features/features-spoiler.test.mjs create mode 100644 tests/frontend/features/features-subtitles.test.mjs create mode 100644 tests/frontend/helpers/coverage.mjs create mode 100644 tests/frontend/helpers/discovery.mjs create mode 100644 tests/frontend/helpers/harness.mjs create mode 100644 tests/frontend/pages/pages-activity.test.mjs create mode 100644 tests/frontend/pages/pages-awards.test.mjs create mode 100644 tests/frontend/pages/pages-calendar.test.mjs create mode 100644 tests/frontend/pages/pages-downloads.test.mjs create mode 100644 tests/frontend/pages/pages-moreinfo.test.mjs create mode 100644 tests/frontend/pages/pages-recommendations.test.mjs create mode 100644 tests/frontend/pages/pages-reviews.test.mjs create mode 100644 tests/frontend/pages/pages-scroll.test.mjs create mode 100644 tests/frontend/regressions/history-early-events.test.mjs create mode 100644 tests/frontend/regressions/history-middle.test.mjs create mode 100644 tests/frontend/regressions/history-recent-regressions.test.mjs create mode 100644 tests/frontend/regressions/perf-round2-discovery.test.mjs create mode 100644 tests/frontend/requests/request-submission.test.mjs create mode 100644 tests/frontend/run.mjs create mode 100644 tests/history/backend-mutations.json create mode 100644 tests/history/early-mutations.json create mode 100644 tests/history/early-triage.json create mode 100644 tests/history/middle-mutations.json create mode 100644 tests/history/recent-mutations.json create mode 100644 tests/host/README.md create mode 100644 tests/host/browser.mjs create mode 100644 tests/host/run.py create mode 100644 tests/inventory/generate.py create mode 100644 tests/poster/README.md rename {scripts/poster-tags-parity => tests/poster/parity}/.gitignore (100%) rename {scripts/poster-tags-parity => tests/poster/parity}/PosterTagParity/PosterTagParity.csproj (84%) rename {scripts/poster-tags-parity => tests/poster/parity}/PosterTagParity/Program.cs (99%) create mode 100644 tests/poster/parity/README.md rename {scripts/poster-tags-parity => tests/poster/parity}/casing-expected.mjs (100%) rename {scripts/poster-tags-parity => tests/poster/parity}/diff.mjs (100%) rename {scripts/poster-tags-parity => tests/poster/parity}/fetch-inputs.mjs (100%) rename {scripts/poster-tags-parity => tests/poster/parity}/gen-resolution-data.mjs (97%) rename {scripts/poster-tags-parity => tests/poster/parity}/names-expected.mjs (100%) create mode 100644 tests/poster/parity/offline-inputs.mjs rename {scripts/poster-tags-parity => tests/poster/parity}/profiles.mjs (100%) rename {scripts/poster-tags-parity => tests/poster/parity}/run.mjs (80%) rename {scripts/poster-tags-parity => tests/poster/parity}/web-expected.mjs (99%) rename {scripts/poster-tags-pipeline-harness => tests/poster/pipeline}/.gitignore (100%) rename {scripts/poster-tags-pipeline-harness => tests/poster/pipeline}/PosterTagsPipelineHarness.csproj (80%) rename {scripts/poster-tags-pipeline-harness => tests/poster/pipeline}/Program.cs (95%) rename {scripts/poster-tags-pipeline-harness => tests/poster/pipeline}/README.txt (77%) rename {scripts/poster-tags-harness => tests/poster/rendering}/Directory.Build.props (100%) rename {scripts/poster-tags-harness => tests/poster/rendering}/PosterTagsHarness.csproj (94%) rename {scripts/poster-tags-harness => tests/poster/rendering}/Program.cs (100%) create mode 100644 tests/run.py create mode 100644 tests/runner/clean.py create mode 100644 tests/runner/coverage.py create mode 100644 tests/runner/mutations.py create mode 100644 tests/runner/poster.py create mode 100644 tests/runner/tests/test_regression.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e2f0c212c..223c3747c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - if: matrix.build-mode == 'manual' name: Build diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 000000000..8afc3cfe0 --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,154 @@ +name: Regression tests + +on: + pull_request: + push: + branches: [main, master] + schedule: + - cron: '23 3 * * 2' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: regression-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + backend: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + target: [jf10, jf12] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: | + 9.0.x + 10.0.x + - run: python3 tests/inventory/generate.py --check + - run: python3 tests/run.py backend --target ${{ matrix.target }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: backend-${{ matrix.target }} + path: | + artifacts/regression/*.log + artifacts/regression/summary.json + artifacts/regression/backend/ + retention-days: 14 + + frontend: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22.14.0' + cache: npm + - run: npm ci + - run: python3 -m unittest discover -s tests/runner/tests -v + - run: python3 tests/run.py frontend + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: frontend-coverage + path: | + artifacts/regression/*.log + artifacts/regression/summary.json + artifacts/frontend-coverage/ + coverage/ + retention-days: 14 + + browser: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22.14.0' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium firefox + - run: python3 tests/run.py browser + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: browser-results + path: | + artifacts/regression/*.log + artifacts/regression/summary.json + artifacts/browser/ + playwright-report/ + test-results/ + retention-days: 14 + + host: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + target: [jf10, jf12] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: '10.0.x' + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22.14.0' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: python3 tests/run.py host --target ${{ matrix.target }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: host-${{ matrix.target }} + path: | + artifacts/regression/*.log + artifacts/regression/summary.json + artifacts/regression/host/ + retention-days: 14 + + extended: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + dotnet-version: | + 9.0.x + 10.0.x + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + # Matches the ICU version used to generate poster language tables. + node-version: '26.2.0' + cache: npm + - run: npm ci + - run: python3 tests/run.py poster --artifacts artifacts/regression/poster-run + - run: python3 tests/run.py mutation --artifacts artifacts/regression/mutation-run + if: always() + - run: python3 tests/run.py history --artifacts artifacts/regression/history-run + if: always() + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: extended-results + path: | + artifacts/regression/*.log + artifacts/regression/summary.json + artifacts/regression/poster-run/ + artifacts/regression/mutation-run/ + artifacts/regression/history-run/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index b2c02650b..2fe7a57a5 100644 --- a/.gitignore +++ b/.gitignore @@ -326,3 +326,11 @@ scripts/deploy* # Local test output (poster tag harnesses and captures) /.engineering-artifacts/ + +# Regression infrastructure manifests are versioned; generated reports are not. +!/package.json +!/package-lock.json +/artifacts/ +/coverage/ +/playwright-report/ +/test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 739f36936..4801cdd54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,7 +159,7 @@ Adding a client module: ## ✅ CI Checks -Every PR runs a few automated checks (GitHub Actions, `.github/workflows/`). There are no automated tests to run locally - these are all static checks: +Pull requests run automated backend regressions for both Jellyfin targets, JavaScript behavioral tests with coverage, and browser tests, alongside the static checks below. See [Regression testing](tests/README.md) for setup, commands, reports, and current coverage gaps. | Check | What it does | Reproduce locally | |---|---|---| @@ -172,7 +172,9 @@ Two more workflows exist but aren't part of the PR gate: **Check Unused Translat ## 🧪 Testing -Before submitting a PR, ensure you've tested: +After installing the prerequisites in [Regression testing](tests/README.md), run `python3 tests/run.py fast` for local feedback or `python3 tests/run.py all` for the complete suite. The full run includes disposable real Jellyfin hosts; it never uses your personal server. Automated coverage is recorded in the [coverage matrix](tests/docs/coverage-matrix.md). + +For behavior outside automated coverage, also verify: - [ ] Feature works as expected - [ ] No console errors diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs b/Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs index 22f00d67d..5147ff41e 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs @@ -608,14 +608,17 @@ private AllReviewsStore ReadStoreUnlocked(bool throwOnCorruption = false) try { - var parsed = JsonConvert.DeserializeObject(json); - if (parsed == null) + // Missing/null collections and null rows are corruption, not an + // empty store. Do not let a subsequent write erase evidence. + var parsed = new AllReviewsStore { Reviews = null! }; + JsonConvert.PopulateObject(json, parsed, new JsonSerializerSettings { CheckAdditionalContent = true }); + if (parsed.Reviews == null || parsed.Reviews.Values.Any(review => review == null)) { if (throwOnCorruption) { - _logger.Error("reviews.json deserialized to null; refusing to write over it."); + _logger.Error("reviews.json contains an invalid review store; refusing to write over it."); BackupCorruptFileUnlocked(filePath); - throw new InvalidDataException("reviews.json deserialized to null."); + throw new InvalidDataException("reviews.json contains an invalid review store."); } return new AllReviewsStore(); } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs index dc3544605..c7f8f2407 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs @@ -4654,6 +4654,9 @@ private bool TryResolveBrandingFilePath(string requestedFileName, out string nor return false; } + // The allowlist is case-insensitive, but the middleware and status endpoint + // use fixed lowercase names. Normalize accepted aliases on case-sensitive hosts. + normalizedFileName = normalizedFileName.ToLowerInvariant(); var fullBrandingDir = Path.GetFullPath(brandingDir); var candidateFilePath = Path.GetFullPath(Path.Combine(fullBrandingDir, normalizedFileName)); var candidateDirectory = Path.GetDirectoryName(candidateFilePath); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj b/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj index 0ca6db507..37e4d03d7 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj +++ b/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj @@ -9,6 +9,8 @@ BrandingAssetStartupFilter) uses only standard ASP.NET, so it works on both runtimes. --> jf12 + true + $(MSBuildProjectDirectory)/packages.$(JellyfinTarget).lock.json enable enable @@ -64,9 +66,10 @@ - + - + + @@ -96,4 +99,4 @@ - \ No newline at end of file + diff --git a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs index 27662a776..b83b89c86 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs @@ -275,7 +275,7 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can } // Update item if modified - if (modified) + if (modified && !(item.Tags ?? Array.Empty()).SequenceEqual(existingTags, StringComparer.Ordinal)) { item.Tags = existingTags.ToArray(); await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs index a2a9d02a1..0189c7608 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs @@ -181,13 +181,14 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can { cancellationToken.ThrowIfCancellationRequested(); - if (!item.ProviderIds.TryGetValue("Tmdb", out var tmdbIdStr) || string.IsNullOrEmpty(tmdbIdStr)) + if (!item.ProviderIds.TryGetValue("Tmdb", out var tmdbIdStr) + || !int.TryParse(tmdbIdStr, out var tmdbId) || tmdbId <= 0) { itemsSkipped++; continue; } - var key = $"{mediaType}:{tmdbIdStr}"; + var key = $"{mediaType}:{tmdbId}"; if (seerrWatchlistKeys.Contains(key)) { @@ -197,7 +198,7 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can var result = await AddToSeerrWatchlist( httpClient, jellyseerrUrl, jellyseerrUserId, config.JellyseerrApiKey, - int.Parse(tmdbIdStr), mediaType, item.Name ?? ""); + tmdbId, mediaType, item.Name ?? ""); if (result == 1) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs index 58cbb9e7d..a04a5f4ee 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs @@ -230,6 +230,10 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can _logger.Info($"[Seerr→Jellyfin Watchlist Sync] User {jellyfinUser.Username}: Added {itemsAdded} items to watchlist, {itemsPending} items added to pending watchlist, {alreadyProcessedItems.Count} already processed, {alreadyInWatchlistItems.Count} already in watchlist, {notInLibraryItems.Count} not in library"); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { _logger.Error($"[Seerr→Jellyfin Watchlist Sync] Error processing user {jellyfinUser.Username}: {ex.Message}"); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs index 5b6bbefc6..a02192fdd 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs @@ -85,16 +85,20 @@ public MaintenanceState GetStatus() private static bool IsExpired(MaintenanceState state) => state.IsActive && state.EndsAt.HasValue && DateTime.UtcNow >= state.EndsAt.Value; + private static bool HasPendingRestores(MaintenanceState state) + => state.AccountDisabledUserIds.Count > 0 || state.RemoteDisabledUserIds.Count > 0; + /// /// Same expiry check as , but awaits the disable so the caller /// (the schedule tick) sees the settled state instead of racing the background restore. /// public async Task ExpireIfDueAsync() { - if (IsExpired(LoadState())) + var current = LoadState(); + if (IsExpired(current) || (!current.IsActive && HasPendingRestores(current))) { // Re-checked under the gate: an admin may have re-enabled with a new end time meanwhile. - await DisableCoreAsync(IsExpired, "Timed window reached its end", false).ConfigureAwait(false); + await DisableCoreAsync(state => IsExpired(state) || !state.IsActive, "Timed window reached its end or restoration pending", false).ConfigureAwait(false); } return LoadState(); } @@ -147,6 +151,12 @@ private async Task EnableLockedAsync(string message, string? notificationMessage action ??= "disable_accounts"; var currentState = LoadState(); + if (!currentState.IsActive && HasPendingRestores(currentState)) + { + await RestoreUsersAsync(currentState).ConfigureAwait(false); + if (HasPendingRestores(currentState)) + throw new InvalidOperationException("Previous maintenance policy restoration is still pending; retry before opening a new window."); + } if (currentState.IsActive) { bool sameAction = currentState.Action == action; @@ -194,7 +204,14 @@ private async Task EnableLockedAsync(string message, string? notificationMessage // Undo whatever this instance previously applied, then fall through to re-apply // fresh against the new action/target below. _logger.Info("[Maintenance] Action/targets changed while active - reconciling."); + // Until restoration completes this is a pending transition, not + // a fully applied active window. A retry of the old selection + // must reapply it rather than take the same-target shortcut. + currentState.IsActive = false; + SaveState(currentState); await RestoreUsersAsync(currentState).ConfigureAwait(false); + if (HasPendingRestores(currentState)) + throw new InvalidOperationException("Maintenance policy restoration failed; previous targets are retained for retry."); } bool doAccounts = action == "disable_accounts" || action == "both"; @@ -222,6 +239,11 @@ private async Task EnableLockedAsync(string message, string? notificationMessage var accountDisabled = new List(); var remoteDisabled = new List(); + // Refuse policy mutations if the restoration journal is already + // unwritable. Host policy commits and this file cannot share a + // transaction, but an existing filesystem failure is detectable. + SaveState(currentState); + foreach (var user in targetUsers) { try @@ -230,24 +252,30 @@ private async Task EnableLockedAsync(string message, string? notificationMessage if (dto.Policy == null) continue; bool changed = false; + bool accountChanged = false; + bool remoteChanged = false; if (doAccounts && !dto.Policy.IsDisabled) { dto.Policy.IsDisabled = true; - accountDisabled.Add(user.Id.ToString()); + accountChanged = true; changed = true; } if (doRemote && dto.Policy.EnableRemoteAccess) { dto.Policy.EnableRemoteAccess = false; - remoteDisabled.Add(user.Id.ToString()); + remoteChanged = true; changed = true; } if (changed) { await _userManager.UpdatePolicyAsync(user.Id, dto.Policy).ConfigureAwait(false); + // Only restore changes the policy store actually accepted. + // A failed update must not grant access on a later disable. + if (accountChanged) accountDisabled.Add(user.Id.ToString()); + if (remoteChanged) remoteDisabled.Add(user.Id.ToString()); _logger.Info($"[Maintenance] Updated user '{user.Username}'" + $"{(doAccounts && accountDisabled.Contains(user.Id.ToString()) ? " (account disabled)" : "")}" + $"{(doRemote && remoteDisabled.Contains(user.Id.ToString()) ? " (remote disabled)" : "")}"); @@ -314,16 +342,18 @@ private async Task DisableCoreAsync(Func shouldDisable, var state = LoadState(); if (!state.IsActive) { + if (HasPendingRestores(state)) + await RestoreUsersAsync(state).ConfigureAwait(false); _logger.Info("[Maintenance] Already inactive - skipping disable."); return; } if (!shouldDisable(state)) return; if (reason != null) _logger.Info($"[Maintenance] {reason} - disabling."); - SaveState(new MaintenanceState - { - IsActive = false, - SkippedScheduledWindowEnd = explicitEnd && state.Source == "schedule" ? state.EndsAt : null - }); + // Persist restoration intent before touching policies. Failed users + // remain on this inactive state so the next tick/restart retries. + state.IsActive = false; + state.SkippedScheduledWindowEnd = explicitEnd && state.Source == "schedule" ? state.EndsAt : null; + SaveState(state); // A timed manual window that ran out must also clear the admin toggle, or the config // page would still show it checked and the next save would re-enable it. Cleared before @@ -493,25 +523,38 @@ private async Task RestoreUsersAsync(MaintenanceState state) foreach (var idStr in allIds) { - if (!Guid.TryParse(idStr, out var userId)) continue; + if (!Guid.TryParse(idStr, out var userId)) + { + state.AccountDisabledUserIds.Remove(idStr); + state.RemoteDisabledUserIds.Remove(idStr); + SaveState(state); + continue; + } try { var user = _userManager.GetUserById(userId); - if (user == null) continue; - - var dto = _userManager.GetUserDto(user, string.Empty); - if (dto.Policy == null) continue; + if (user != null) + { + var dto = _userManager.GetUserDto(user, string.Empty); + if (dto.Policy == null) continue; - if (accountSet.Contains(idStr)) dto.Policy.IsDisabled = false; - if (remoteSet.Contains(idStr)) dto.Policy.EnableRemoteAccess = true; + if (accountSet.Contains(idStr)) dto.Policy.IsDisabled = false; + if (remoteSet.Contains(idStr)) dto.Policy.EnableRemoteAccess = true; - await _userManager.UpdatePolicyAsync(userId, dto.Policy).ConfigureAwait(false); - _logger.Info($"[Maintenance] Restored user '{user.Username}'"); + await _userManager.UpdatePolicyAsync(userId, dto.Policy).ConfigureAwait(false); + _logger.Info($"[Maintenance] Restored user '{user.Username}'"); + } } catch (Exception ex) { _logger.Error($"[Maintenance] Failed to restore user {idStr}: {ex.Message}"); + continue; } + // Persistence failures must abort the transition, not be swallowed + // as policy failures and allow a new window to replace old intent. + state.AccountDisabledUserIds.Remove(idStr); + state.RemoteDisabledUserIds.Remove(idStr); + SaveState(state); } } @@ -526,7 +569,7 @@ private static bool AffectedUserSetsEqual(List? a, List? b) private MaintenanceState LoadState() { var cached = _cached; - if (cached != null) return cached; + if (cached != null) return CopyState(cached); try { if (File.Exists(_stateFilePath)) @@ -540,20 +583,42 @@ private MaintenanceState LoadState() _logger.Error($"[Maintenance] Failed to load state: {ex.Message}"); } cached ??= new MaintenanceState(); + cached.AccountDisabledUserIds = (cached.AccountDisabledUserIds ?? new()).Distinct().ToList(); + cached.RemoteDisabledUserIds = (cached.RemoteDisabledUserIds ?? new()).Distinct().ToList(); _cached = cached; - return cached; + return CopyState(cached); } + // Callers stage mutations privately; a failed disk checkpoint must not + // silently change the authoritative cached state. + private static MaintenanceState CopyState(MaintenanceState state) => new() + { + IsActive = state.IsActive, Message = state.Message, NotificationMessage = state.NotificationMessage, + Action = state.Action, Source = state.Source, StartedAt = state.StartedAt, EndsAt = state.EndsAt, + RequestedDurationMinutes = state.RequestedDurationMinutes, + AccountDisabledUserIds = new(state.AccountDisabledUserIds), + RemoteDisabledUserIds = new(state.RemoteDisabledUserIds), + RequestedAffectedUserIds = state.RequestedAffectedUserIds == null ? null : new(state.RequestedAffectedUserIds), + SkippedScheduledWindowEnd = state.SkippedScheduledWindowEnd + }; + private void SaveState(MaintenanceState state) { - _cached = state; + var temporaryPath = _stateFilePath + ".tmp." + Guid.NewGuid().ToString("N"); try { - File.WriteAllText(_stateFilePath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.WriteAllText(temporaryPath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.Move(temporaryPath, _stateFilePath, overwrite: true); + _cached = CopyState(state); } catch (Exception ex) { _logger.Error($"[Maintenance] Failed to save state: {ex.Message}"); + throw; + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs index bb27e27e2..9d42ec9c8 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs @@ -38,7 +38,7 @@ public sealed record PosterTagSettings /// Version of the resolution rules, part of . Bump it when a change to /// the resolver changes the tags produced for the same settings and data. /// - public const string ResolverVersion = "2"; + public const string ResolverVersion = "3"; private static readonly JsonSerializerSettings LenientRead = new() { NullValueHandling = NullValueHandling.Ignore }; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs index 04dff8d51..d8a0087a3 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs @@ -119,15 +119,15 @@ internal static class LanguageNames /// /// Variant canonicalization: language+variant aliases (zh-hakka -> hak, art-lojban -> jbo), /// then the language-independent ones (heploc -> alalc97, lojban dropped; baku1926 becomes the - /// Baku script only when it is the tag's sole subtag). + /// Baku script when no script or region is present after language alias expansion). /// private static List? ApplyVariantAliases(string originalLanguage, List variants, ref string language, ref string? script, ref string? region) { - var soleSubtag = variants.Count == 1 && script is null && region is null; + var canUseVariantScript = script is null && region is null; var result = new List(variants.Count); foreach (var variant in variants) { - if (VariantAliases.TryGetValue(originalLanguage + "-" + variant, out var pairAlias)) + if (variant != "baku1926" && VariantAliases.TryGetValue(originalLanguage + "-" + variant, out var pairAlias)) { // Intl writes a kept posix variant as the -u-va-posix extension (prs-posix -> fa-AF-u-va-posix). if (EndsWithAsciiIgnoreCase(pairAlias, PosixExtension)) @@ -147,8 +147,15 @@ internal static class LanguageNames } else if (replacement.Length == 4 && char.IsAsciiLetterUpper(replacement[0])) { - if (soleSubtag) script = replacement; - else result.Add(variant); + if (canUseVariantScript) + { + // ICU consumes the complete variant sequence when promoting Baku + // to a script. An alias-provided script/region prevents promotion. + script = replacement; + return null; + } + + result.Add(variant); } else if (replacement.Length > 0) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs index 2fc332d62..e61b62314 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs @@ -1,5 +1,5 @@ // -// Generated by scripts/poster-tags-parity/gen-resolution-data.mjs. Do not edit by hand. +// Generated by tests/poster/parity/gen-resolution-data.mjs. Do not edit by hand. // Source: Node 26.2.0, ICU 78.3, CLDR 48.0, Unicode 17.0. // diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs index b101bbd31..99528249e 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs @@ -171,7 +171,7 @@ private static bool IsIndexRequest(string? path) return path.EndsWith("/web/index.html", StringComparison.OrdinalIgnoreCase) || path.EndsWith("/web/", StringComparison.OrdinalIgnoreCase) - || path.Equals("/web", StringComparison.OrdinalIgnoreCase); + || path.EndsWith("/web", StringComparison.OrdinalIgnoreCase); } } } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs index 46412fd9f..9a0a1e012 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs @@ -628,6 +628,7 @@ private async Task RunImageFilterAsync( var executed = await next().ConfigureAwait(false); if (executed.Canceled || executed.Exception != null) return; + var originalResult = executed.Result; try { if (spoilerMode == "hide") @@ -665,6 +666,17 @@ private async Task RunImageFilterAsync( } } } + finally + { + // MVC normally disposes FileStreamResult streams while executing the + // result. Replacing that result transfers ownership to this filter; + // otherwise every protected stream response leaks its file handle. + if (!ReferenceEquals(originalResult, executed.Result) && originalResult is FileStreamResult streamResult) + { + try { await streamResult.FileStream.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger.Warning($"Spoiler Guard source stream cleanup failed: {ex.Message}"); } + } + } } // When an item that SHOULD be considered for blur (user has the diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs index 7ea8d2192..3b2bf7d33 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs @@ -707,7 +707,7 @@ private void LoadFromDisk() data = JsonSerializer.Deserialize(stream); } - if (data == null || data.V != DiskSchemaVersion) + if (data == null || data.V != DiskSchemaVersion || data.Entries == null) { return; } @@ -720,6 +720,15 @@ private void LoadFromDisk() // that order and lets the size cap drop the oldest. foreach (var entry in data.Entries) { + // Cache files are disposable input: ignore corrupt records rather + // than preventing plugin startup or losing later valid entries. + if (entry == null || entry.C == null + || entry.E < DateTimeOffset.MinValue.ToUnixTimeMilliseconds() + || entry.E > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) + { + continue; + } + var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(entry.E); if (expiresAt <= now || string.IsNullOrEmpty(entry.K) || _entries.ContainsKey(entry.K)) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs index 7485f0daf..f5a97ef62 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs @@ -83,7 +83,7 @@ public void Increment(string key) // category off, nothing is counted or written to disk at all. var config = JellyfinEnhanced.Instance?.Configuration; if (config == null || !config.AnalyticsEnabled || !config.AnalyticsShareUsageCounts) return; - if (!IsValidKey(key)) return; + if (!IsValidKey(key) || !IsKnownKey(key)) return; _counters.AddOrUpdate(key, 1, (_, v) => v + 1); ScheduleDebouncedSave(); } @@ -140,7 +140,7 @@ private void LoadFromDisk() { using var stream = File.OpenRead(path); var data = JsonSerializer.Deserialize(stream); - if (data == null || data.SchemaVersion != CurrentSchemaVersion) return; + if (data == null || data.SchemaVersion != CurrentSchemaVersion || data.Counters == null) return; if (!string.IsNullOrEmpty(data.PeriodStart)) _periodStart = data.PeriodStart; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs index 04a36b204..ea5b1d36c 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs @@ -24,10 +24,10 @@ public static class WatchProvidersBatch private const int MaxItemsLength = 2048; private const int MaxProviderNameLength = 200; - private static readonly Regex ItemPattern = new(@"^(movie|tv):([0-9]{1,9})$", RegexOptions.CultureInvariant); - private static readonly Regex RegionPattern = new(@"^[A-Z]{2}$", RegexOptions.CultureInvariant); + private static readonly Regex ItemPattern = new(@"^(movie|tv):([0-9]{1,9})\z", RegexOptions.CultureInvariant); + private static readonly Regex RegionPattern = new(@"^[A-Z]{2}\z", RegexOptions.CultureInvariant); // Same shape the client accepts for TMDB image paths (e.g. "/abc.jpg"). - private static readonly Regex LogoPathPattern = new(@"^/[A-Za-z0-9_\-.]+\.(jpg|jpeg|png|webp|avif)$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private static readonly Regex LogoPathPattern = new(@"^/[A-Za-z0-9_\-.]+\.(jpg|jpeg|png|webp|avif)\z", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); /// One requested title. /// "movie" or "tv". diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs index d8e0c0de4..f22fd5d27 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs @@ -28,6 +28,8 @@ public class WatchlistMonitor : IDisposable private readonly Logger _logger; private readonly Dictionary Items, DateTime CachedAt)> _requestsCache = new(); private readonly object _requestsCacheLock = new(); + private readonly object _subscriptionLock = new(); + private bool _subscribed; private readonly ConcurrentDictionary?>> _requestsInFlight = new(); public WatchlistMonitor( @@ -74,8 +76,13 @@ public void Initialize() } // _logger.Info("[Watchlist] Initializing library event monitoring"); - _libraryManager.ItemAdded += OnItemAdded; - _libraryManager.ItemUpdated += OnItemUpdated; + lock (_subscriptionLock) + { + if (_subscribed) return; + _libraryManager.ItemAdded += OnItemAdded; + _libraryManager.ItemUpdated += OnItemUpdated; + _subscribed = true; + } _logger.Info("[Watchlist] Successfully subscribed to library ItemAdded and ItemUpdated events"); } @@ -406,8 +413,12 @@ private async Task ProcessItemForWatchlist(ItemChangeEventArgs e, string eventTy public void Dispose() { _logger.Info("[Watchlist] Unsubscribing from library events"); - _libraryManager.ItemAdded -= OnItemAdded; - _libraryManager.ItemUpdated -= OnItemUpdated; + lock (_subscriptionLock) + { + _libraryManager.ItemAdded -= OnItemAdded; + _libraryManager.ItemUpdated -= OnItemUpdated; + _subscribed = false; + } GC.SuppressFinalize(this); } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs index 68b6e87a5..c512f7c18 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs @@ -380,6 +380,8 @@ private void LoadFromDisk() foreach (var kvp in data.Items) { + if (kvp.Value == null || kvp.Value.FetchedAtUnixMs < DateTimeOffset.MinValue.ToUnixTimeMilliseconds() + || kvp.Value.FetchedAtUnixMs > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) continue; _cache[kvp.Key] = kvp.Value; } _logger.Info($"[Awards] Loaded {_cache.Count} entries from disk"); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js b/Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js index 05fd1cb2d..a49676ac9 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js @@ -558,6 +558,9 @@ * @param {*} data */ function setCache(key, data) { + // Refresh an existing key without evicting an unrelated entry. Deleting + // first also moves the refreshed response to the newest LRU position. + responseCache.delete(key); // Evict oldest entries if at capacity if (responseCache.size >= CONFIG.cache.maxEntries) { const oldestKey = responseCache.keys().next().value; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js b/Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js index 467b3636f..ca0622ff5 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js @@ -88,7 +88,8 @@ const batch = pendingMutations; pendingMutations = []; if (batch.length === 0) return; - for (const [id, sub] of bodySubscribers) { + for (const [id, sub] of Array.from(bodySubscribers)) { + if (bodySubscribers.get(id) !== sub) continue; if (sub.priority > 0) continue; try { sub.callback(batch); @@ -113,10 +114,11 @@ } if (!hasStructuralChange) return; - // NOTE: Callbacks may call unsubscribe()/disconnect(), deleting from this Map - // during iteration. ES spec guarantees Map iteration handles concurrent deletion. + // Callbacks can subscribe, replace, or unsubscribe during dispatch. Snapshot + // this batch so resorting the live Map cannot revisit callbacks. // Priority subscribers (sorted first) stay synchronous so they can act before paint. - for (const [id, sub] of bodySubscribers) { + for (const [id, sub] of Array.from(bodySubscribers)) { + if (bodySubscribers.get(id) !== sub) continue; if (sub.priority <= 0) break; try { sub.callback(mutations); @@ -176,14 +178,15 @@ if (bodySubscribers.has(id)) { console.warn(`🪼 Jellyfin Enhanced: Replacing body observer subscriber: ${id}`); } - bodySubscribers.set(id, { callback, priority }); - if (priority !== 0) { - resortBodySubscribers(); - } + const subscription = { callback, priority }; + bodySubscribers.set(id, subscription); + // Replacing a high-priority entry with priority zero must also move it + // behind the remaining priority subscribers: dispatch stops at the first zero. + resortBodySubscribers(); ensureBodyObserver(); // console.log(`🪼 Jellyfin Enhanced: Body subscriber registered: ${id} (priority: ${priority}, total: ${bodySubscribers.size})`); const cleanup = () => { - if (!bodySubscribers.has(id)) return; + if (bodySubscribers.get(id) !== subscription) return; bodySubscribers.delete(id); // console.log(`🪼 Jellyfin Enhanced: Body subscriber removed: ${id} (remaining: ${bodySubscribers.size})`); stopBodyObserverIfEmpty(); @@ -217,6 +220,12 @@ * @returns {MutationObserver|{ disconnect: Function, unsubscribe: Function }} Observer handle */ function createObserver(id, callback, target, config) { + // Dispose the previous observer even when its replacement changes from a + // dedicated observer to the shared body path (or vice versa). + if (activeObservers.has(id)) { + activeObservers.get(id).disconnect(); + activeObservers.delete(id); + } // Route body observers to the shared multiplexed observer const isBodyTarget = target === document.body || target === document.documentElement || target === document; const isSubtreeWatch = config && config.childList && config.subtree; @@ -238,11 +247,6 @@ // For non-body targets or complex configs (attributes, characterData), // create a dedicated observer as before - if (activeObservers.has(id)) { - const existing = activeObservers.get(id); - existing.disconnect(); - console.warn(`🪼 Jellyfin Enhanced: Replacing existing observer: ${id}`); - } const observer = new MutationObserver(callback); observer.observe(target, config); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js b/Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js index ae16a8059..74b6d7a56 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js @@ -371,9 +371,17 @@ state.cacheTtl = (JE.pluginConfig?.TagsCacheTtlDays || 30) * 24 * 60 * 60 * 1000; if (!spec.cache) return; enforceCacheOwnership(); - state.cache = state.localStorageEnabled - ? (JSON.parse(localStorage.getItem(spec.cache.key) || '{}') || {}) - : {}; + state.cache = {}; + if (state.localStorageEnabled) { + try { + const cached = JSON.parse(localStorage.getItem(spec.cache.key) || '{}'); + // Persisted data is an item-id dictionary. Corrupt or obsolete + // payloads must not prevent the renderer from registering. + if (cached && typeof cached === 'object' && !Array.isArray(cached)) state.cache = cached; + } catch (e) { + console.warn(`${logPrefix} Ignoring unreadable tag cache`, e); + } + } if (spec.cache.hotBucket) { const Hot = (JE._hotCache = JE._hotCache || { ttl: state.cacheTtl }); Hot[spec.cache.hotBucket] = Hot[spec.cache.hotBucket] || new Map(); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js index c2714d93a..30255a49f 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js @@ -59,18 +59,26 @@ } } - const itemDetailsCache = { itemId: null, data: null, pending: null }; + let itemDetailsCache = null; /** * Fetch full item details including TMDB/TVDB IDs (cached per item for a few seconds) */ async function fetchItemDetails(itemId) { - if (itemDetailsCache.itemId === itemId && itemDetailsCache.data) { - return itemDetailsCache.data; + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + if (!itemDetailsCache || itemDetailsCache.owner !== owner + || itemDetailsCache.epoch !== epoch || itemDetailsCache.itemId !== itemId) { + itemDetailsCache = { owner, epoch, itemId, data: null, pending: null }; + } + // Keep each in-flight lookup attached to its original owner and item. + const cache = itemDetailsCache; + if (cache.itemId === itemId && cache.data) { + return cache.data; } - if (itemDetailsCache.pending && itemDetailsCache.itemId === itemId) { - return itemDetailsCache.pending; + if (cache.pending && cache.itemId === itemId) { + return cache.pending; } const fetchPromise = (async () => { @@ -87,6 +95,7 @@ dataType: 'json' }); + if (JE.userConfig !== owner || (JE.session && !JE.session.isCurrent(epoch))) return null; const item = result?.Items?.[0]; if (!item) return null; @@ -141,18 +150,18 @@ episodeNumber }; - itemDetailsCache.data = details; + cache.data = details; return details; } catch (e) { console.warn(`${logPrefix} Error fetching item details:`, e); return null; } finally { - itemDetailsCache.pending = null; + cache.pending = null; } })(); - itemDetailsCache.itemId = itemId; - itemDetailsCache.pending = fetchPromise; + cache.itemId = itemId; + cache.pending = fetchPromise; return fetchPromise; } @@ -215,6 +224,10 @@ * Add a new bookmark */ async function addBookmark(timestamp, label = '') { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); const itemData = getCurrentItemData(); if (!itemData) { JE.toast(JE.t('toast_bookmark_no_item'), 3000); @@ -223,6 +236,7 @@ // Fetch full details const details = await fetchItemDetails(itemData.itemId); + if (!isCurrentOwner()) return null; if (!details) { JE.toast(JE.t('toast_bookmark_fetch_failed'), 3000); return null; @@ -247,23 +261,24 @@ }; // Initialize bookmark structure if needed - if (!JE.userConfig.bookmark) { - JE.userConfig.bookmark = { bookmarks: {} }; + if (!owner.bookmark) { + owner.bookmark = { bookmarks: {} }; } - if (!JE.userConfig.bookmark.bookmarks) { - JE.userConfig.bookmark.bookmarks = {}; + if (!owner.bookmark.bookmarks) { + owner.bookmark.bookmarks = {}; } - JE.userConfig.bookmark.bookmarks[bookmarkId] = bookmark; + const bookmarks = owner.bookmark.bookmarks; + bookmarks[bookmarkId] = bookmark; try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark); console.log(`${logPrefix} Bookmark added:`, bookmarkId, bookmark); - emitBookmarksUpdated('add'); + if (isCurrentOwner()) emitBookmarksUpdated('add'); return { id: bookmarkId, ...bookmark }; } catch (e) { console.error(`${logPrefix} Failed to save bookmark:`, e); - delete JE.userConfig.bookmark.bookmarks[bookmarkId]; + delete bookmarks[bookmarkId]; throw e; } } @@ -272,20 +287,29 @@ * Update an existing bookmark */ async function updateBookmark(bookmarkId, updates) { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); if (!JE.userConfig?.bookmark?.bookmarks?.[bookmarkId]) { console.warn(`${logPrefix} Bookmark not found:`, bookmarkId); return false; } - const bookmark = JE.userConfig.bookmark.bookmarks[bookmarkId]; - Object.assign(bookmark, updates, { updatedAt: new Date().toISOString() }); + const bookmarks = owner.bookmark.bookmarks; + const original = bookmarks[bookmarkId]; + const bookmark = { ...original, ...updates, updatedAt: new Date().toISOString() }; + bookmarks[bookmarkId] = bookmark; try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark); console.log(`${logPrefix} Bookmark updated:`, bookmarkId); - emitBookmarksUpdated('update'); + if (isCurrentOwner()) emitBookmarksUpdated('update'); return true; } catch (e) { + if (bookmarks[bookmarkId] === bookmark) { + bookmarks[bookmarkId] = original; + } console.error(`${logPrefix} Failed to update bookmark:`, e); return false; } @@ -295,19 +319,28 @@ * Delete a bookmark */ async function deleteBookmark(bookmarkId) { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); if (!JE.userConfig?.bookmark?.bookmarks?.[bookmarkId]) { console.warn(`${logPrefix} Bookmark not found:`, bookmarkId); return false; } - delete JE.userConfig.bookmark.bookmarks[bookmarkId]; + const bookmarks = owner.bookmark.bookmarks; + const original = bookmarks[bookmarkId]; + delete bookmarks[bookmarkId]; try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark); console.log(`${logPrefix} Bookmark deleted:`, bookmarkId); - emitBookmarksUpdated('delete'); + if (isCurrentOwner()) emitBookmarksUpdated('delete'); return true; } catch (e) { + if (!Object.prototype.hasOwnProperty.call(bookmarks, bookmarkId)) { + bookmarks[bookmarkId] = original; + } console.error(`${logPrefix} Failed to delete bookmark:`, e); return false; } @@ -318,6 +351,11 @@ * Creates duplicates with new item ID, keeps old ones */ async function syncBookmarks(oldBookmarks, newItemDetails, timeOffset = 0) { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); + const bookmarks = owner.bookmark.bookmarks; const synced = []; const now = new Date().toISOString(); @@ -340,19 +378,19 @@ episodeNumber: newItemDetails.episodeNumber ?? null }; - JE.userConfig.bookmark.bookmarks[newBookmarkId] = newBookmark; + bookmarks[newBookmarkId] = newBookmark; synced.push({ id: newBookmarkId, ...newBookmark }); } try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark); console.log(`${logPrefix} Synced ${synced.length} bookmarks to new item ID`); - emitBookmarksUpdated('sync'); + if (isCurrentOwner()) emitBookmarksUpdated('sync'); return synced; } catch (e) { console.error(`${logPrefix} Failed to sync bookmarks:`, e); // Rollback - synced.forEach(bm => delete JE.userConfig.bookmark.bookmarks[bm.id]); + synced.forEach(bm => delete bookmarks[bm.id]); throw e; } } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js index 1165014a4..f1f17bbd5 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js @@ -29,10 +29,11 @@ if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; const key = e.key; - const combo = (e.shiftKey ? 'Shift+' : '') + - (e.metaKey ? 'Meta+' : '') + + // Match the modifier order persisted by the shortcut editor. + const combo = (e.metaKey ? 'Meta+' : '') + (e.ctrlKey ? 'Ctrl+' : '') + (e.altKey ? 'Alt+' : '') + + (e.shiftKey ? 'Shift+' : '') + (key.match(/^[a-zA-Z]$/) ? key.toUpperCase() : key); const video = document.querySelector('video'); @@ -399,4 +400,4 @@ }); }; -})(window.JellyfinEnhanced); \ No newline at end of file +})(window.JellyfinEnhanced); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js index 9f763807b..8a9b54441 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js @@ -146,6 +146,7 @@ // Re-fetch from server and replace local cache. Don't call immediately after a server-direct write from THIS tab — use markScopedHidden(). async function refresh() { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; try { const userId = ApiClient.getCurrentUserId(); if (!userId) return false; @@ -154,6 +155,7 @@ url: ApiClient.getUrl(`/JellyfinEnhanced/user-settings/${userId}/hidden-content.json?_=${Date.now()}`), dataType: 'json' }); + if (JE.session && !JE.session.isCurrent(requestEpoch)) return false; const camelCased = (typeof JE.toCamelCase === 'function') ? JE.toCamelCase(fresh) : fresh; JE.userConfig = JE.userConfig || {}; JE.userConfig.hiddenContent = camelCased || { items: {}, settings: {} }; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js index cbdba8546..7b63c7b2c 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js @@ -72,6 +72,7 @@ if (parentSeriesRequestMap.has(itemId)) { return parentSeriesRequestMap.get(itemId); } + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const request = (async () => { try { const userId = ApiClient.getCurrentUserId(); @@ -80,15 +81,17 @@ url: ApiClient.getUrl(`/Users/${userId}/Items/${itemId}`, { Fields: 'SeriesId' }), dataType: 'json' }); - const seriesId = item?.SeriesId || null; + if (JE.session && !JE.session.isCurrent(requestEpoch)) return null; + const seriesId = normalizeId(item?.SeriesId) || null; parentSeriesCache.set(itemId, seriesId); return seriesId; } catch (e) { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return null; console.warn('🪼 Jellyfin Enhanced: Failed to fetch parent series for', itemId, e); parentSeriesCache.set(itemId, null); return null; } finally { - parentSeriesRequestMap.delete(itemId); + if (!JE.session || JE.session.isCurrent(requestEpoch)) parentSeriesRequestMap.delete(itemId); } })(); parentSeriesRequestMap.set(itemId, request); @@ -196,7 +199,9 @@ if (!getSettings().enabled || !shouldFilterSurface(getCurrentNativeSurface())) return; if (hiddenIdSet.size === 0) return; + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; getParentSeriesId(itemId).then((seriesId) => { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; if (!seriesId) return; if (!card.isConnected) return; if (!getSettings().enabled || !shouldFilterSurface(getCurrentNativeSurface())) return; @@ -224,6 +229,10 @@ if (!getSettings().enabled || !shouldFilterSurface(getCurrentNativeSurface())) return; if (hiddenIdSet.size === 0) return; + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; + const canApply = () => (!JE.session || JE.session.isCurrent(requestEpoch)) + && getSettings().enabled && shouldFilterSurface(getCurrentNativeSurface()); + // Separate cached from uncached const cached = []; const uncached = []; @@ -239,6 +248,7 @@ // Process cached entries immediately if (cached.length > 0) { requestAnimationFrame(() => { + if (!canApply()) return; for (let i = 0; i < cached.length; i++) { const { card, seriesId } = cached[i]; if (!card.isConnected || !seriesId) continue; @@ -268,12 +278,15 @@ dataType: 'json' }); + if (!canApply()) return; const itemsById = new Map(); const responseItems = result?.Items || []; for (let i = 0; i < responseItems.length; i++) { const item = responseItems[i]; - itemsById.set(item.Id, item.SeriesId || null); - parentSeriesCache.set(item.Id, item.SeriesId || null); + const itemId = normalizeId(item.Id); + const seriesId = normalizeId(item.SeriesId) || null; + itemsById.set(itemId, seriesId); + parentSeriesCache.set(itemId, seriesId); } // Also cache items that weren't in the response (deleted, etc.) @@ -285,6 +298,7 @@ // Batch apply hiding requestAnimationFrame(() => { + if (!canApply()) return; for (let i = 0; i < chunk.length; i++) { const { card, itemId } = chunk[i]; if (!card.isConnected) continue; @@ -297,6 +311,7 @@ } }); } catch (e) { + if (!canApply()) return; console.warn('🪼 Jellyfin Enhanced: Batch parent series check failed', e); // Fall back to individual lookups for this chunk for (let i = 0; i < chunk.length; i++) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js index 2ee9be8d0..3f934ed8f 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js @@ -320,6 +320,8 @@ * @param {HTMLElement} container The DOM element to append the info to. */ async function displayItemSize(itemId, container, mediaSourceId = null) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrent = () => !JE.session || JE.session.isCurrent(requestEpoch); const cacheKey = `${itemId}|${mediaSourceId || ''}`; const existing = container.querySelector('.mediaInfoItem-fileSize'); if (existing) { @@ -369,6 +371,7 @@ try { const itemResult = await statsPromise; await nextFrame(); + if (!isCurrent()) return; const totalSize = itemResult?.size ?? 0; if (totalSize > 0) { @@ -380,6 +383,7 @@ fileSizeCache.set(cacheKey, { size: null, unavailable: true, ts: now }); } } catch (error) { + if (!isCurrent()) return; console.error('🪼 Jellyfin Enhanced: Error fetching item size for ID %s:', itemId, error); // Keep placeholder with dash to prevent repeated calls renderUnavailable(); @@ -546,6 +550,8 @@ * @param {HTMLElement} container The DOM element to append the info to. */ async function displayAudioLanguages(itemId, container, mediaSourceId = null) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrent = () => !JE.session || JE.session.isCurrent(requestEpoch); const cacheKey = `${itemId}|${mediaSourceId || ''}`; // show itemMiscInfo if hidden like on season pages if (container.classList.contains('hide')) { @@ -698,11 +704,11 @@ // The lookups start right away (they cost the main thread nothing // while in flight); the chip is built in the next animation frame so // its small DOM write lands with that frame's own layout pass. - const renderUnavailableInFrame = () => nextFrame().then(() => { if (placeholder.isConnected) renderUnavailable(); }); + const renderUnavailableInFrame = () => nextFrame().then(() => { if (isCurrent() && placeholder.isConnected) renderUnavailable(); }); const renderLanguagesInFrame = async (languages) => { const tagMap = await loadLanguageTagMap(itemId, languages); await nextFrame(); - if (placeholder.isConnected) renderLanguages(languages, tagMap); + if (isCurrent() && placeholder.isConnected) renderLanguages(languages, tagMap); }; const performFetch = async () => { // Check cache first @@ -716,7 +722,7 @@ } // Render from cache const tagMap = await loadLanguageTagMap(itemId, cached.languages); - if (placeholder.isConnected) renderLanguages(cached.languages, tagMap); + if (isCurrent() && placeholder.isConnected) renderLanguages(cached.languages, tagMap); return; } @@ -737,6 +743,7 @@ ? await JE.helpers.getItemCached(itemId, { userId }) : await ApiClient.getItem(userId, itemId)); + if (!isCurrent()) return; let sourceItem = item; // For Series/Season, fetch the first episode to get language info. @@ -748,11 +755,13 @@ item.Id, item.FirstEpisode?.Id ); + if (!isCurrent()) return; if (episode) { sourceItem = episode; } else { // No episodes found await renderUnavailableInFrame(); + if (!isCurrent()) return; audioLanguageCache.set(cacheKey, { languages: [], unavailable: true, ts: Date.now() }); return; } @@ -794,14 +803,18 @@ if (uniqueLanguages.length > 0) { await renderLanguagesInFrame(uniqueLanguages); // Cache the successful result + if (!isCurrent()) return; audioLanguageCache.set(cacheKey, { languages: uniqueLanguages, unavailable: false, ts: Date.now() }); } else { await renderUnavailableInFrame(); + if (!isCurrent()) return; audioLanguageCache.set(cacheKey, { languages: [], unavailable: true, ts: Date.now() }); } } catch (error) { + if (!isCurrent()) return; console.error('🪼 Jellyfin Enhanced: Error fetching audio languages for %s:', itemId, error); await renderUnavailableInFrame(); + if (!isCurrent()) return; audioLanguageCache.set(cacheKey, { languages: [], unavailable: true, ts: Date.now() }); } }; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js index 3c3caaeb5..3f500f0e0 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js @@ -225,7 +225,13 @@ chip.style.gap = '0.3em'; chip.style.whiteSpace = 'nowrap'; chip.style.margin = '0 1em 0 0 !important'; - chip.innerHTML = `${info.icon}${formatReleaseDate(info.date)}`; + const icon = document.createElement('span'); + icon.className = 'je-release-date-icon'; + icon.style.fontSize = 'inherit'; + icon.textContent = info.icon; + const date = document.createElement('span'); + date.textContent = formatReleaseDate(info.date); + chip.replaceChildren(icon, date); } // Each date is its own flex sibling (like every other stat chip in this diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js index 218358ba1..6dba72ba9 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js @@ -9,12 +9,17 @@ JE.initializePauseScreen = function() { // Only run if the feature is enabled in the user's settings if (!JE.currentSettings.pauseScreenEnabled) { + JE.pauseScreenInstance?.destroy(); console.log('🪼 Jellyfin Enhanced: Custom Pause Screen is disabled.'); return; } + if (JE.pauseScreenInstance?.overlay?.isConnected) return; + JE.pauseScreenInstance?.destroy(); class JellyfinPauseScreen { constructor() { // State + this.generation = 0; + this.destroyed = false; this.currentVideo = null; this.currentItemId = null; this.userId = null; @@ -66,6 +71,8 @@ // re-read is deferred a tick because the reset fires before the host // finishes writing the new credentials to localStorage. JE.session?.onUserChange('pause-screen', () => { + if (this.destroyed) return; + this.clearState(); for (const url of this.imgBlobCache.values()) URL.revokeObjectURL(url); this.imgBlobCache.clear(); this.imgProbeCache.clear(); @@ -73,6 +80,7 @@ this.userId = null; this.token = null; setTimeout(() => { + if (this.destroyed) return; const fresh = this.getCredentials(); this.userId = fresh?.userId || null; this.token = fresh?.token || null; @@ -477,7 +485,7 @@ setupKeyboardAccessibility() { // Space/Enter resumes when overlay visible - document.addEventListener('keydown', (e) => { + this.keyboardListener = (e) => { if (this.overlay.getAttribute('aria-hidden') === 'false') { if (e.code === 'Space' || e.code === 'Enter') { e.preventDefault(); @@ -496,7 +504,8 @@ } } } - }, { capture: true }); + }; + document.addEventListener('keydown', this.keyboardListener, { capture: true }); } setupVideoObserver() { @@ -709,6 +718,7 @@ } async fetchItemInfo(itemId) { + const generation = ++this.generation; this.clearDisplayData(); this.fetchAbort?.abort(); this.fetchAbort = new AbortController(); @@ -720,11 +730,13 @@ headers: { "Authorization": 'MediaBrowser Token="' + this.token + '"', "X-Emby-Token": this.token, "Accept": "application/json" }, signal: this.fetchAbort.signal }); + if (this.destroyed || generation !== this.generation) return; record = { item: itemResp, domain: ApiClient.serverAddress() }; this.itemCache.set(itemId, record); } await this.displayItemInfo(record.item, record.domain, itemId); } catch (err) { + if (this.destroyed || generation !== this.generation) return; if (err.name !== 'AbortError') { console.error("🪼 Jellyfin Enhanced: Error fetching item info:", err); this.overlayPlot.textContent = JE.t('pausescreen_fetch_error'); @@ -746,15 +758,22 @@ } async displayItemInfo(item, domain, itemId) { + const generation = this.generation; // Details const year = item.ProductionYear || ""; const rating = item.OfficialRating || ""; const runtime = this.formatRuntime(item.RunTimeTicks); - this.overlayDetails.innerHTML = [ - year && `${year}`, - rating && `${rating}`, - runtime && `${runtime}` - ].filter(Boolean).join(''); + this.overlayDetails.replaceChildren(); + for (const [value, isRating] of [[year, false], [rating, true], [runtime, false]]) { + if (!value) continue; + const span = document.createElement('span'); + span.textContent = String(value); + if (isRating) { + span.className = 'mediaInfoOfficialRating'; + span.setAttribute('rating', String(value)); + } + this.overlayDetails.appendChild(span); + } this.overlayPlot.textContent = item.Overview || JE.t('pausescreen_no_description'); @@ -769,6 +788,7 @@ this.firstAvailableBlobURL(backdropUrls) ]); + if (this.destroyed || generation !== this.generation) return; if (logoURL) this.overlayLogo.src = logoURL; if (discURL) this.overlayDisc.src = discURL; if (backdropURL) this.overlayBackdrop.style.backgroundImage = `url("${backdropURL}")`; @@ -830,9 +850,11 @@ // ------- Image helpers (blob cache) ------- async firstAvailableBlobURL(urls) { + const generation = this.generation; for (const url of urls) { if (!url) continue; const ok = await this.probeImage(url); + if (this.destroyed || generation !== this.generation) return null; if (!ok) continue; const blobURL = await this.toBlobURL(url); if (blobURL) return blobURL; @@ -840,6 +862,7 @@ return null; } async probeImage(url, timeoutMs = 2500) { + const generation = this.generation; if (this.imgProbeCache.has(url)) return this.imgProbeCache.get(url); try { const ctl = new AbortController(); @@ -850,15 +873,18 @@ signal: ctl.signal }); clearTimeout(t); + if (this.destroyed || generation !== this.generation) return false; const ok = res.ok; this.imgProbeCache.set(url, ok); return ok; } catch { + if (this.destroyed || generation !== this.generation) return false; this.imgProbeCache.set(url, false); return false; } } async toBlobURL(url, timeoutMs = 5000) { + const generation = this.generation; if (this.imgBlobCache.has(url)) return this.imgBlobCache.get(url); try { const ctl = new AbortController(); @@ -870,6 +896,7 @@ clearTimeout(t); if (!res.ok) return null; const blob = await res.blob(); + if (this.destroyed || generation !== this.generation) return null; const obj = URL.createObjectURL(blob); this.imgBlobCache.set(url, obj); return obj; @@ -905,6 +932,7 @@ } clearState() { + this.generation++; this.hideOverlay(); this.clearDisplayData(); @@ -921,7 +949,12 @@ } destroy() { - this.clearState(); + this.destroyed = true; + if (this.keyboardListener) { + document.removeEventListener('keydown', this.keyboardListener, { capture: true }); + this.keyboardListener = null; + } + if (this.overlay) this.clearState(); if (this.observer) { this.observer.unsubscribe(); this.observer = null; } // Clean up interaction listeners diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js index 898fea531..6f78c3b62 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js @@ -337,7 +337,7 @@ const val = Math.max(1, Math.min(60, parseInt(pauseScreenDelayInput.value, 10) || 5)); pauseScreenDelayInput.value = val; JE.currentSettings.pauseScreenDelaySeconds = val; - JE.saveUserSettings(); + JE.saveUserSettings('settings.json', JE.currentSettings); // Push the new delay into the already-running pause-screen // instance so it takes effect immediately, without a page reload. if (JE.pauseScreenInstance) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js index 6b421b8b7..9dc23f66c 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js @@ -20,12 +20,12 @@ const cacheBuster = `_sbcb=${Date.now()}`; const bust = url => internal.bustSpoilerImageUrl(url, cacheBuster); - document.querySelectorAll('img[src*="/Items/"]').forEach(function(img) { + document.querySelectorAll('img[src*="/Items/"], img[srcset*="/Items/"]').forEach(function(img) { const src = img.getAttribute('src') || ''; if (imagePath.test(src)) img.setAttribute('src', bust(src)); const srcset = img.getAttribute('srcset'); if (srcset && imagePath.test(srcset)) { - img.setAttribute('srcset', srcset.replace(/([^\s,]+)(?=\s*[\d.]+x|\s*,|\s*$)/g, + img.setAttribute('srcset', srcset.replace(/([^\s,]+)(?=\s*[\d.]+[wx]|\s*,|\s*$)/g, url => imagePath.test(url) ? bust(url) : url)); } }); @@ -33,7 +33,7 @@ document.querySelectorAll('source[srcset*="/Items/"]').forEach(function(source) { const srcset = source.getAttribute('srcset') || ''; if (imagePath.test(srcset)) { - source.setAttribute('srcset', srcset.replace(/([^\s,]+)(?=\s*[\d.]+x|\s*,|\s*$)/g, + source.setAttribute('srcset', srcset.replace(/([^\s,]+)(?=\s*[\d.]+[wx]|\s*,|\s*$)/g, url => imagePath.test(url) ? bust(url) : url)); } }); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js index c4b10ef59..9c69fd487 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js @@ -98,41 +98,65 @@ }; internal.enableForSeries = function(id) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/series/${encodeURIComponent(normalized)}`, { method: 'POST', skipRetry: true }) - .then(function() { enabledSeries.add(normalized); }); + .then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledSeries.add(normalized); + }); }; internal.disableForSeries = function(id) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/series/${encodeURIComponent(normalized)}`, { method: 'DELETE', skipRetry: true }) - .then(function() { enabledSeries.delete(normalized); }); + .then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledSeries.delete(normalized); + }); }; internal.enableForMovie = function(id, name) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/movies/${encodeURIComponent(normalized)}`, { method: 'POST', body: { MovieName: name || '' }, skipRetry: true - }).then(function() { enabledMovies.add(normalized); }); + }).then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledMovies.add(normalized); + }); }; internal.disableForMovie = function(id) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/movies/${encodeURIComponent(normalized)}`, { method: 'DELETE', skipRetry: true }) - .then(function() { enabledMovies.delete(normalized); }); + .then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledMovies.delete(normalized); + }); }; internal.enableForCollection = function(id, name) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/collections/${encodeURIComponent(normalized)}`, { method: 'POST', body: { CollectionName: name || '' }, skipRetry: true - }).then(function() { enabledCollections.add(normalized); }); + }).then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledCollections.add(normalized); + }); }; internal.disableForCollection = function(id) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const normalized = internal.normalizeId(id); return request(`/spoiler-blur/collections/${encodeURIComponent(normalized)}`, { method: 'DELETE', skipRetry: true }) - .then(function() { enabledCollections.delete(normalized); }); + .then(function() { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return; + enabledCollections.delete(normalized); + }); }; internal.isTmdbEnabled = function(mediaType, tmdbId, jellyfinMediaId) { @@ -146,12 +170,14 @@ }; internal.enableForTmdb = function(mediaType, tmdbId, displayName) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const type = String(mediaType || '').toLowerCase(); const id = String(tmdbId || '').trim(); if (!id || (type !== 'tv' && type !== 'movie')) return Promise.reject(new Error('invalid mediaType/tmdbId')); const query = displayName ? `?displayName=${encodeURIComponent(displayName)}` : ''; return request(`/spoiler-blur/pending/${type}/${encodeURIComponent(id)}${query}`, { method: 'POST', skipRetry: true }) .then(function(response) { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return response; const key = internal.pendingKey(type, id); if (response?.promoted === 'pending') { if (key) enabledPendingTmdb.add(key); @@ -169,11 +195,13 @@ }; internal.disableForTmdb = function(mediaType, tmdbId) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const type = String(mediaType || '').toLowerCase(); const id = String(tmdbId || '').trim(); if (!id || (type !== 'tv' && type !== 'movie')) return Promise.reject(new Error('invalid mediaType/tmdbId')); return request(`/spoiler-blur/pending/${type}/${encodeURIComponent(id)}`, { method: 'DELETE', skipRetry: true }) .then(function(response) { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return response; const key = internal.pendingKey(type, id); if (key) { enabledPendingTmdb.delete(key); tmdbToJellyfin.delete(key); } if (response?.removedFrom === 'series' && response.jellyfinId) { @@ -187,9 +215,11 @@ internal.getUserPrefs = function() { return Object.assign({}, userPrefs); }; internal.setUserPrefs = function(next) { + const requestEpoch = JE.session ? JE.session.getEpoch() : 0; const payload = next || {}; return request('/spoiler-blur/user-prefs', { method: 'POST', body: payload, skipRetry: true }) .then(function(response) { + if (JE.session && !JE.session.isCurrent(requestEpoch)) return response; userPrefs = Object.assign({}, payload); return response?.prefs || userPrefs; }) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js index 7656bfb80..c2bb4d38a 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js @@ -11,6 +11,7 @@ const internal = JE.internals.moreInfoModal; const state = internal.state; const logPrefix = '🪼 Jellyfin Enhanced: Jellyseerr More Info:'; + let openGeneration = 0; /** * Open the more info modal for a movie or TV show @@ -18,9 +19,11 @@ * @param {string} mediaType - 'movie' or 'tv' */ moreInfoModal.open = async function(tmdbId, mediaType) { + const generation = ++openGeneration; try { // Fetch details first so the modal can open immediately const data = await internal.fetchMediaDetails(tmdbId, mediaType); + if (generation !== openGeneration) return; if (!data) { internal.showError('Failed to load media information'); return; @@ -28,6 +31,7 @@ moreInfoModal.open = async function(tmdbId, mediaType) { // Render modal immediately showModal(data, mediaType); + const openedModal = state.currentModal; // For TV shows, backfill missing season metadata (poster, overview, airDate) from TMDB/episodes if (mediaType === 'tv' && data.seasons?.some(s => s.episodeCount > 0 && (!s.airDate || !s.posterPath))) { @@ -38,7 +42,7 @@ moreInfoModal.open = async function(tmdbId, mediaType) { internal.fetchRatings(tmdbId, mediaType) .then((ratings) => { // Modal might have been closed or replaced; ensure we're updating the correct one - if (!state.currentModal) return; + if (state.currentModal !== openedModal || openedModal._isClosing) return; const modalTmdbId = state.currentModal?.dataset?.tmdbId; const modalMediaType = state.currentModal?.dataset?.mediaType; if (String(modalTmdbId) !== String(data.id) || modalMediaType !== mediaType) return; @@ -55,6 +59,7 @@ moreInfoModal.open = async function(tmdbId, mediaType) { // Silently fail; modal is already shown without ratings }); } catch (error) { + if (generation !== openGeneration) return; console.error('Error opening more info modal:', error); internal.showError('Failed to load media information'); } @@ -71,6 +76,7 @@ async function refreshModalData(data, mediaType, modal, refreshBtn) { // Fetch fresh data const freshData = await internal.fetchMediaDetails(data.id, mediaType); + if (state.currentModal !== modal || modal._isClosing) return; if (!freshData) { internal.showError('Failed to refresh media information'); refreshBtn.classList.remove('loading'); @@ -91,6 +97,7 @@ async function refreshModalData(data, mediaType, modal, refreshBtn) { refreshBtn.disabled = false; } catch (error) { + if (state.currentModal !== modal || modal._isClosing) return; console.error('Error refreshing modal data:', error); internal.showError('Failed to refresh modal data'); refreshBtn.classList.remove('loading'); @@ -102,8 +109,8 @@ async function refreshModalData(data, mediaType, modal, refreshBtn) { * Show the modal with media information */ function showModal(data, mediaType) { - // Close existing modal if any - moreInfoModal.close(); + // Replace the current modal without canceling this open operation. + closeCurrentModal(); const modal = document.createElement('div'); modal.className = 'je-more-info-modal'; @@ -179,6 +186,7 @@ function showModal(data, mediaType) { try { // Refresh details to pull latest status/progress const fresh = await internal.fetchMediaDetails(data.id, 'tv'); + if (state.currentModal !== modal || modal._isClosing) return; if (fresh?.mediaInfo) { data.mediaInfo = fresh.mediaInfo; } else { @@ -187,6 +195,7 @@ function showModal(data, mediaType) { mediaInfo.status = mediaInfo.status || 2; } } catch (_) { + if (state.currentModal !== modal || modal._isClosing) return; const mediaInfo = data.mediaInfo || (data.mediaInfo = {}); mediaInfo.status = mediaInfo.status || 2; } @@ -205,7 +214,7 @@ function showModal(data, mediaType) { /** * Close the modal */ -moreInfoModal.close = function() { +function closeCurrentModal() { if (state.currentModal) { if (state.currentModal._isClosing) return; state.currentModal._isClosing = true; @@ -219,20 +228,28 @@ moreInfoModal.close = function() { state.currentModal._cleanupEscapeListener(); } state.currentModal.classList.remove('active'); + // A replacement can open during the exit animation. Remove only the + // modal this close owns, never the replacement stored in currentModal. + const closingModal = state.currentModal; setTimeout(() => { - if (document.body.contains(state.currentModal)) { - document.body.removeChild(state.currentModal); - } - state.currentModal = null; + closingModal.remove(); + if (state.currentModal === closingModal) state.currentModal = null; }, 300); } } + moreInfoModal.close = function() { + openGeneration++; + closeCurrentModal(); + }; + + // Request permissions and details belong to the active account. Invalidate + // pending opens immediately on identity changes, before navigation arrives. + JE.session?.onUserChange('jellyseerr-more-info', () => moreInfoModal.close()); + // Close modal on page navigation document.addEventListener('viewshow', function() { - if (state.currentModal) { - moreInfoModal.close(); - } + moreInfoModal.close(); }); // Expose helpers used by other modules (e.g., item-details.js for the diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js index a7af29897..6843a0637 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js @@ -13,6 +13,7 @@ const MediaStatus = JE.seerrStatus.MEDIA; const DisplayStatus = JE.seerrStatus.DISPLAY; const icons = internal.icons; // requires ui-icons.js to be loaded first + const requestHandlers = new WeakMap(); /** * Configures the request button based on item status and type. @@ -22,6 +23,13 @@ * @param {boolean} jellyseerrUserFound - If the current user is linked. */ function configureRequestButton(button, item, isJellyseerrActive, jellyseerrUserFound) { + // Results can refresh the same button with new data or a new user status. + // Remove the old closure so one click cannot submit twice or use stale media. + const previousHandler = requestHandlers.get(button); + if (previousHandler) { + button.removeEventListener('click', previousHandler); + requestHandlers.delete(button); + } if (!isJellyseerrActive) { button.innerHTML = `${JE.t('jellyseerr_btn_offline')}${icons.cloud_off}`; button.disabled = true; @@ -205,7 +213,7 @@ button.replaceWith(buttonGroup); if (!mainButtonDisabled) { - mainButton.addEventListener('click', async (e) => { + const requestHandler = async (e) => { e.stopPropagation(); if (JE.jellyseerrAPI.shouldShowAdvanced()) { ui.showMovieRequestModal(item.id, item.title || item.name, item, false); @@ -214,16 +222,19 @@ mainButton.innerHTML = `${JE.t('jellyseerr_btn_requesting')}`; try { await JE.jellyseerrAPI.requestMedia(item.id, 'movie', {}, false, item); + if (requestHandlers.get(mainButton) !== requestHandler) return; if (!item.mediaInfo) item.mediaInfo = {}; item.mediaInfo.status = 3; mainButton.innerHTML = `${JE.t('jellyseerr_btn_requested')}${icons.requested}`; mainButton.classList.remove('jellyseerr-button-request'); mainButton.classList.add('jellyseerr-button-pending'); } catch (error) { + if (requestHandlers.get(mainButton) !== requestHandler) return; mainButton.disabled = false; // Quota errors get a themed dialog; restore button to idle. if (ui.isQuotaError && ui.isQuotaError(error)) { await ui.showQuotaErrorDialog(error, 'movie'); + if (requestHandlers.get(mainButton) !== requestHandler) return; mainButton.innerHTML = `${icons.request}${JE.t('jellyseerr_btn_request')}`; return; } @@ -242,7 +253,9 @@ mainButton.classList.add('jellyseerr-button-error'); } } - }); + }; + requestHandlers.set(mainButton, requestHandler); + mainButton.addEventListener('click', requestHandler); } arrowButton.addEventListener('click', (e) => { @@ -271,7 +284,7 @@ // Add click handler for request button (for overview button and standard button) if (!button.disabled && !button.closest('.jellyseerr-button-group')) { - button.addEventListener('click', async (e) => { + const requestHandler = async (e) => { e.preventDefault(); e.stopPropagation(); if (JE.jellyseerrAPI.shouldShowAdvanced()) { @@ -281,16 +294,19 @@ button.innerHTML = `${JE.t('jellyseerr_btn_requesting')}`; try { await JE.jellyseerrAPI.requestMedia(item.id, 'movie', {}, false, item); + if (requestHandlers.get(button) !== requestHandler) return; if (!item.mediaInfo) item.mediaInfo = {}; item.mediaInfo.status = 3; button.innerHTML = `${JE.t('jellyseerr_btn_requested')}${icons.requested}`; button.classList.remove('jellyseerr-button-request'); button.classList.add('jellyseerr-button-pending'); } catch (error) { + if (requestHandlers.get(button) !== requestHandler) return; button.disabled = false; // Quota errors get a themed dialog; restore button to idle. if (ui.isQuotaError && ui.isQuotaError(error)) { await ui.showQuotaErrorDialog(error, 'movie'); + if (requestHandlers.get(button) !== requestHandler) return; button.innerHTML = `${icons.request}${JE.t('jellyseerr_btn_request')}`; return; } @@ -308,7 +324,9 @@ button.classList.add('jellyseerr-button-error'); } } - }); + }; + requestHandlers.set(button, requestHandler); + button.addEventListener('click', requestHandler); } } ui.configureRequestButton = configureRequestButton; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js b/Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js index 14221637f..36494e01c 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js @@ -107,6 +107,11 @@ const item = JE.helpers?.getItemCached ? await JE.helpers.getItemCached(itemId) : await ApiClient.getItem(ApiClient.getCurrentUserId(), itemId); + // The SPA can reuse this detail DOM while the lookup is pending. + // Never attach the previous item's link to the new route/page. + const currentItemId = new URLSearchParams(window.location.hash.split('?')[1]).get('id'); + if (currentItemId !== itemId || !anchorElement.isConnected || + document.querySelector('#itemDetailPage:not(.hide)') !== visiblePage) return; if (!item?.Type) { processedItemIds.add(itemId); return; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json b/Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json new file mode 100644 index 000000000..6d96527aa --- /dev/null +++ b/Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json @@ -0,0 +1,348 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "Jellyfin.Controller": { + "type": "Direct", + "requested": "[10.11.0, )", + "resolved": "10.11.0", + "contentHash": "WV+PQy9AHdZLvYqUsNq6ZyQoxaiaEWLz0EwZGOiu8xSrepQLFope2U1VFHVCNbARwesg7s/B+9uB03eXDsQw9w==", + "dependencies": { + "BitFaster.Caching": "2.5.4", + "Jellyfin.Common": "10.11.0", + "Jellyfin.MediaEncoding.Keyframes": "10.11.0", + "Jellyfin.Model": "10.11.0", + "Jellyfin.Naming": "10.11.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.Configuration.Binder": "9.0.10", + "System.Threading.Tasks.Dataflow": "9.0.10" + } + }, + "Jellyfin.Model": { + "type": "Direct", + "requested": "[10.11.0, )", + "resolved": "10.11.0", + "contentHash": "h+61RSXn4sk8fS6Zx9RkDyVnI5VnNbrsR2p8WcvybtNSW2pgU2uZ9pwEv2awD3ifX69weqYpQLMh91f6aidW2A==", + "dependencies": { + "Jellyfin.Data": "10.11.0", + "Jellyfin.Extensions": "10.11.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "System.Globalization": "4.3.0", + "System.Text.Json": "9.0.10" + } + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.4, )", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "SkiaSharp": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "DNDwbRjP+aMo27dV2h/uHCVTcWubWWxHnPLiePNyl24f4Pv43mQ8AQQeseOrKR+J3AOCEs6t0sUjo0aa3j3RWQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.116.1", + "SkiaSharp.NativeAssets.macOS": "3.116.1" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "ibDG1+quN86vBd9ztjDAC9wnvS1nRZ6ydTUOSod4NsRHWdLLGzWYn1IOF4Cg9iJh5cQHdpzhUZBQE0JMKznrow==", + "dependencies": { + "HarfBuzzSharp": "8.3.0.1", + "SkiaSharp": "3.116.1" + } + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.5.4", + "contentHash": "1QroTY1PVCZOSG9FnkkCrmCKk/+bZCgI/YXq376HnYwUDJ4Ho0EaV4YaA/5v5WYLnwIwIO7RZkdWbg9pxIpueQ==" + }, + "Diacritics": { + "type": "Transitive", + "resolved": "4.0.17", + "contentHash": "FmMvVQRsfon+x5P+dxz4mvV8wt45xr25EAOCkuo/Cjtc7lVYV5cZUSsNXwmKQpwO+TokIHpzxb8ENpqrm4yBlQ==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "rwLpl+W6uqu0DuvzqNhTMuFcXfy1Vc0uq0YXgPEmtTSfeUSAye1FcARrm2YIPOSiCBwBOGu3cLvMX5Fp6OKe2g==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.0.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.0.1" + } + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "2o6U05LAmK+rwX7TvmJ2X0anXJG2hSE7kHVmCshhHy0tKfByJ5ykBacvhmmooHchlOwq15KBZeROGafCT8nN+g==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "ow0DtGEUjo65qhiI22of7qiVbN1xDFsZ5P5xJljRmGZ5WSxNy+1batLNJFGxahqhB1MTHYV8kAXf0GqC8WaevQ==" + }, + "ICU4N": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "YMZtDnjcqWzziOKiE7w6Ma7Rl5vuFDxzOsUlHh1QyfghbNEIZQOLRs9MMfwCWAjX6n9UitrF6vLXy55Z5q+4Fg==", + "dependencies": { + "J2N": "2.0.0", + "Microsoft.Extensions.Caching.Memory": "2.0.0" + } + }, + "ICU4N.Transliterator": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "lFOSO6bbEtB6HkWMNDJAq+rFwVyi9g6xVc5O/2xHa6iZnV7wLVDqCbaQ4W4vIeBSQZAafqhxciaEkmAvSdzlCg==", + "dependencies": { + "ICU4N": "60.1.0-alpha.356" + } + }, + "J2N": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "M5bwDajAARZiyqupU+rHQJnsVLxNBOHJ8vKYHd8LcLIb1FgLfzzcJvc31Qo5Xz/GEHFjDF9ScjKL/ks/zRTXuA==" + }, + "Jellyfin.Common": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "TitN7+qWFt2l0V5b+KTRt7ABDCvfZdvSC6qBG1uHS18Y80xrbrSCJ9O6BH/of310h6a4lxKlQjUtTPHCzeG2AA==", + "dependencies": { + "Jellyfin.Model": "10.11.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Jellyfin.Data": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "YEz7/85b98Rj14IJJIVqmzJsi69LDOKo4Ox+VHbh1vj3tkWomSPayzvG3kyU8I0yFMrd6+Ta55C20kZ2XC7vQg==", + "dependencies": { + "Jellyfin.Database.Implementations": "10.11.0", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Jellyfin.Database.Implementations": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "oLblVZzqF9zuLMdfqp8pbusSVQq6b40/RcHjGF1hxYozVNEi+UhiDX8aJipYBOrh33FFAofoQq468BvZixpPcw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "9.0.10", + "Polly": "8.6.4" + } + }, + "Jellyfin.Extensions": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "1ufj+Rm0Bn6C990i2wwiT5UHPZfD65GOtJK6NcDU//DDMbuoGX1LQZxuCx+rhhRg1XdHPWzYASARYyNlFQa6cg==", + "dependencies": { + "Diacritics": "4.0.17", + "ICU4N.Transliterator": "60.1.0-alpha.356" + } + }, + "Jellyfin.MediaEncoding.Keyframes": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "/OBcg4Qj825elOGNj5bNRfABKzfAf4qNQj0/d/DwhG/+V/wsKuxS0Pc/xOEagVVjXOnqGPZz/+k8D4UvnvMoHw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "NEbml": "1.1.0.5" + } + }, + "Jellyfin.Naming": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "2++xSbhdFSb1J3XySjC6UU+uII6OdKc0DfkYx/E1oN7mSjoftyZR8eU045kVWBwsAxr+UcMI6t2DYfES2tJkRA==", + "dependencies": { + "Jellyfin.Common": "10.11.0", + "Jellyfin.Model": "10.11.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "WjjxVyOTVs85V7SUe+lZjtGOEeVzF4RO8amrqL4adgbyThNq7vGCFzPw8buZj44gHeQYD5V/uZ/6XuqG9Jq+kA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.10", + "Microsoft.Extensions.Caching.Memory": "9.0.10", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "I3TWAs5Lbzmzu8S0T6qXhzBiO3CznYLrfE59W0npkqNHfWGH8CgA66LrHMWxWOXVTD4145QwYqiWNCdLwpJ1Ew==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "mXNl0Gg3l3zGrClLCHepB+b7rYVuFfB9qQJwya0dUSHFuR1T0jMD8KxuNVyhQSfoWIepanhzjcG8TUNGXOcU0Q==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "IJNrG5vdmFUvHR8FLLFg9AWpuE8qW1DTEN+fNLGbNTu6cnpZzzqU6+aknAGtTSAEVWosJ3BZ3TOO9wpifUvv3A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.10", + "Microsoft.Extensions.Caching.Memory": "9.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "cL6iTxgJ4u5zP3eFOdBiDAtmE/B2WKTRhyJfEne7n6qvHpsMwwIDxljs210mWSO1ucBy7lR1Lo7/7kjeZeLcqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "2iuzwIoCoqZJfH2VLk1xvlQS4PQDEuhj4dWiGb+Qpt1vHFHyffp497cTO6ucsV54W/h4JmM1vzDBv8pVAFazZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "Microsoft.Extensions.Options": "9.0.10", + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ad3JxmFj0uxuFa1CT6oxTCC1lQ0xeRuOvzBRFT/I/ofIXVOnNsH/v2GZkAJWhlpZqKUvSexQZzp3EEAB2CdtJg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "D6Kng+9I+w1SQPxJybc6wzw9nnnyUQPutycjtI0svv1RHaWOpUk9PPlwIRfhhoQZ3yihejkEI2wNv/7VnVtkGA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "iEtXCkNd5XhjNJAOb/wO4IhDRdLIE2CsPxZggZQWJ/q2+sa8dmEPC393nnsiqdH8/4KV8Xn25IzgKPR1UEQ0og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "r9waLiOPe9ZF1PvzUT+RDoHvpMmY8MW+lb4lqjYGObwKpnyPMLI3odVvlmshwuZcdoHynsGWOrCPA0hxZ63lIA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UBXHqE9vyptVhaFnT1R7YJKCve7TqVI10yjjUZBNGMlW2lZ4c031Slt9hxsOzWCzlpPxxIFyf1Yk4a6Iubxx7w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.10", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "Microsoft.Extensions.Options": "9.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "MFUPv/nN1rAQ19w43smm6bbf0JDYN/1HEPHoiMYY50pvDMFpglzWAuoTavByDmZq7UuhjaxwrET3joU69ZHoHQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "zMNABt8eBv0B0XrWjFy9nZNgddavaOeq3ZdaD5IlHhRH65MrU7HM+Hd8GjWE3e2VDGFPZFfSAc6XVXC17f9fOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10", + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "3pl8D1O5ZwMpDkZAT2uXrhQ6NipkwEgDLMFuURiHTf72TvkoMP61QYH3Vk1yrzVHnHBdNZk3cQACz8Zc7YGNhQ==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "NEbml": { + "type": "Transitive", + "resolved": "1.1.0.5", + "contentHash": "svtqDc+hue9kbnqNN2KkK4om/hDrc7K127cNb5FIYfgKgzo+JNDPXNLp8NioCchHhBO3lxWd4Cp/iiZZ3aoUqg==" + }, + "Polly": { + "type": "Transitive", + "resolved": "8.6.4", + "contentHash": "uuBsDoBw0oYrMe3uTWRjkT2sIkKh+ZZnnDrLb4Z+QANfeA4+7FJacx6E8CY5GAxXRoSgFrvUADEAQ7DPF6fGiw==", + "dependencies": { + "Polly.Core": "8.6.4" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.4", + "contentHash": "4AWqYnQ2TME0E+Mzovt1Uu+VyvpR84ymUldMcPw7Mbj799Phaag14CKrMtlJGx5jsvYP+S3oR1QmysgmXoD5cw==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "3KPvpKysDmEMt0NnAZPX5U6KFk0LmG/72/IjAIJemIksIZ0Tjs9pGpr3L+zboVCv1MLVoJLKl3nJDXUG6Jda6A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "dRQ75MCI8oz6zAs2Y1w6pq6ARs4MhdNG+gf3doOxOxdnueDXffQLGQIxON54GDoxc0WjKOoHMKBR4DhaduwwQw==" + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "XM02ZBnzxk7Ti6l9YRy8Bp639wANqJzJzw4W4VYiCh+HXY9hBOWkGB4k89OLP/s/RxgM02P4a/mWcJTgFiLf1Q==" + }, + "System.Threading.Tasks.Dataflow": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "k1o6v6V3+4mznSnPnO0FBaRjiAPL1ouKPfPQH7hO9Z2SwJHt8E45F4wX5yQh1aeja1JHPYEungQedXibng654Q==" + } + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json b/Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json new file mode 100644 index 000000000..408a93969 --- /dev/null +++ b/Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json @@ -0,0 +1,305 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Jellyfin.Controller": { + "type": "Direct", + "requested": "[12.0.0, )", + "resolved": "12.0.0", + "contentHash": "CtqEPr2+oXjtHnlk2ZcavhTLLwaSLPa4DNu1qK8tv9S+qrz4PABYqEElO7NWkfPwGVcfaA+XD5mziHmZokAFHw==", + "dependencies": { + "Jellyfin.Common": "12.0.0", + "Jellyfin.MediaEncoding.Keyframes": "12.0.0", + "Jellyfin.Model": "12.0.0", + "Jellyfin.Naming": "12.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.11" + } + }, + "Jellyfin.Model": { + "type": "Direct", + "requested": "[12.0.0, )", + "resolved": "12.0.0", + "contentHash": "tunpZL+hOVh2CveVwBmGqMLC48ozlDsVbCLK/Xf3HBV0F1iIyeVMsxHahin7AMgYwCjbURfsov/ZA8ZO6CgYTQ==", + "dependencies": { + "Jellyfin.Data": "12.0.0", + "Jellyfin.Extensions": "12.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.4, )", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "SkiaSharp": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "DNDwbRjP+aMo27dV2h/uHCVTcWubWWxHnPLiePNyl24f4Pv43mQ8AQQeseOrKR+J3AOCEs6t0sUjo0aa3j3RWQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.116.1", + "SkiaSharp.NativeAssets.macOS": "3.116.1" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "ibDG1+quN86vBd9ztjDAC9wnvS1nRZ6ydTUOSod4NsRHWdLLGzWYn1IOF4Cg9iJh5cQHdpzhUZBQE0JMKznrow==", + "dependencies": { + "HarfBuzzSharp": "8.3.0.1", + "SkiaSharp": "3.116.1" + } + }, + "Diacritics": { + "type": "Transitive", + "resolved": "4.1.8", + "contentHash": "6+I0/ui1GvFS3SFSs+92b7yNqq6t32iaBQcO9RnepCiEViD2XmvsSIh4r1VTJe94gDxtkDo9fOSbRK1FjSSpGg==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "rwLpl+W6uqu0DuvzqNhTMuFcXfy1Vc0uq0YXgPEmtTSfeUSAye1FcARrm2YIPOSiCBwBOGu3cLvMX5Fp6OKe2g==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.0.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.0.1" + } + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "2o6U05LAmK+rwX7TvmJ2X0anXJG2hSE7kHVmCshhHy0tKfByJ5ykBacvhmmooHchlOwq15KBZeROGafCT8nN+g==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "ow0DtGEUjo65qhiI22of7qiVbN1xDFsZ5P5xJljRmGZ5WSxNy+1batLNJFGxahqhB1MTHYV8kAXf0GqC8WaevQ==" + }, + "ICU4N": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "YMZtDnjcqWzziOKiE7w6Ma7Rl5vuFDxzOsUlHh1QyfghbNEIZQOLRs9MMfwCWAjX6n9UitrF6vLXy55Z5q+4Fg==", + "dependencies": { + "J2N": "2.0.0", + "Microsoft.Extensions.Caching.Memory": "2.0.0" + } + }, + "ICU4N.Transliterator": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "lFOSO6bbEtB6HkWMNDJAq+rFwVyi9g6xVc5O/2xHa6iZnV7wLVDqCbaQ4W4vIeBSQZAafqhxciaEkmAvSdzlCg==", + "dependencies": { + "ICU4N": "60.1.0-alpha.356" + } + }, + "J2N": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "M5bwDajAARZiyqupU+rHQJnsVLxNBOHJ8vKYHd8LcLIb1FgLfzzcJvc31Qo5Xz/GEHFjDF9ScjKL/ks/zRTXuA==" + }, + "Jellyfin.Common": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "U0XY7mKv1FSc0Dv10GOB+XDoiafTKnPe8zR/e8N/Cy1CMGKpbrRoAGUv7CwkJWBzk8vridlE5rP2L2Xow3QUrA==", + "dependencies": { + "Jellyfin.Model": "12.0.0" + } + }, + "Jellyfin.Data": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "ogSgr4rjssk6vwAPgSTWrQOb8vj1XWQmROf64FKqq19AWUykGTHcaJiQWRkYiZFruOCXJQeNGOWv8IwCtfagBA==", + "dependencies": { + "Jellyfin.Database.Implementations": "12.0.0", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Jellyfin.Database.Implementations": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "9MPKdD1MymXICBOjnt96YooZz1oI4lsL34sV+4iaG0kx4Jmm2qfA2/9uZbb3Y7+ZSWSA1Fa+21SJtyQ3fakboQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Polly": "8.7.0" + } + }, + "Jellyfin.Extensions": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "p6XEGIE46Fn2SqZlPAlkeaP45SpPPtJ3fJPeNHXoGwOx83d09QENGrs4Ie8rTrWuoPoGvn/7g40d8eBrDzgADA==", + "dependencies": { + "Diacritics": "4.1.8", + "ICU4N.Transliterator": "60.1.0-alpha.356" + } + }, + "Jellyfin.MediaEncoding.Keyframes": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "RRyxFLr6JNDTatPPsn1xvQ1mhIUvwm7iWjEyh2Lt8FhLbEETsNaWSZKvfQxY2Gm5pMPRjlv0dH3tNUguyk6ZtQ==", + "dependencies": { + "NEbml": "1.1.0.5" + } + }, + "Jellyfin.Naming": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "lHf27aSFmzYQoQ8ggY/7gst2LnjywW7xN6mRkScMzBmH5STvD+kfepGT38Ee4xDM1Fe17yxCiRWJKsKaFwVIcg==", + "dependencies": { + "Jellyfin.Common": "12.0.0", + "Jellyfin.Model": "12.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + }, + "NEbml": { + "type": "Transitive", + "resolved": "1.1.0.5", + "contentHash": "svtqDc+hue9kbnqNN2KkK4om/hDrc7K127cNb5FIYfgKgzo+JNDPXNLp8NioCchHhBO3lxWd4Cp/iiZZ3aoUqg==" + }, + "Polly": { + "type": "Transitive", + "resolved": "8.7.0", + "contentHash": "0qR4f0OR8FeCAfLWcfwzAM7w6EmpUgwa22PgxKjcL25dEAto7sKpQQXbtxp38vVvK+V3RFkh/TeI7g/iUdoYIQ==", + "dependencies": { + "Polly.Core": "8.7.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.7.0", + "contentHash": "BS2t+nsBer16PIebCEPNBK5fgMisADQyRCd7K+BgkMWpFmSaiYE+rVVNpFhGRqUkJmNSLmw0uCNzHHWfgml28Q==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "3KPvpKysDmEMt0NnAZPX5U6KFk0LmG/72/IjAIJemIksIZ0Tjs9pGpr3L+zboVCv1MLVoJLKl3nJDXUG6Jda6A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "dRQ75MCI8oz6zAs2Y1w6pq6ARs4MhdNG+gf3doOxOxdnueDXffQLGQIxON54GDoxc0WjKOoHMKBR4DhaduwwQw==" + } + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..a1aff2e9e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1251 @@ +{ + "name": "jellyfin-enhanced-regression", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jellyfin-enhanced-regression", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "1.58.2", + "istanbul-lib-coverage": "3.2.2", + "istanbul-lib-instrument": "6.0.3", + "istanbul-lib-report": "3.0.1", + "istanbul-reports": "3.2.0", + "jsdom": "26.1.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..8a4a44255 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "jellyfin-enhanced-regression", + "private": true, + "version": "1.0.0", + "engines": { "node": ">=22.12.0" }, + "scripts": { + "test": "npm run test:frontend", + "test:frontend": "node tests/frontend/run.mjs", + "test:frontend:coverage": "node tests/frontend/helpers/coverage.mjs", + "test:browser": "playwright test --config tests/browser/playwright.config.cjs", + "test:visual": "playwright test --config tests/browser/playwright.config.cjs --grep @visual" + }, + "devDependencies": { + "@playwright/test": "1.58.2", + "jsdom": "26.1.0", + "istanbul-lib-instrument": "6.0.3", + "istanbul-lib-coverage": "3.2.2", + "istanbul-lib-report": "3.0.1", + "istanbul-reports": "3.2.0" + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..42a5f4a98 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,58 @@ +# JE regression tests + +All test code, fixtures, harnesses, runners, browser configuration and testing documentation live here. Production code stays in `Jellyfin.Plugin.JellyfinEnhanced/`. Root `package.json` and `package-lock.json` provide the normal npm entry points; `.github/workflows/regression.yml` calls this directory's runner. Generated evidence stays in the ignored root `artifacts/` directory. + +```text +tests/ +├── run.py # Entry point for every suite +├── backend/ # One xUnit project for both Jellyfin targets +│ ├── Api/ +│ ├── Core/ +│ ├── Integrations/ +│ ├── PosterTags/ +│ ├── Privacy/ +│ ├── ScheduledTasks/ +│ └── Support/ # Shared plugin, storage and HTTP fixtures +├── frontend/ # Actual JavaScript modules in isolated jsdom realms +│ ├── bootstrap/ +│ ├── core/ +│ ├── features/ +│ ├── pages/ +│ ├── requests/ +│ ├── regressions/ # Historical cases spanning several features +│ └── helpers/ # Shared realm, recursive discovery and coverage +├── browser/ # Playwright config, fixtures, fonts and visual baselines +├── host/ # Real disposable Jellyfin servers and browser smoke tests +├── poster/ +│ ├── rendering/ # Native renderer/assets harness +│ ├── pipeline/ # Image/filter/cache pipeline harness +│ └── parity/ # Browser/native metadata parity +├── history/ # Historical fault manifests and triage data +├── inventory/ # Production-surface inventory generator +├── runner/ # Coverage, mutation, poster and clean-copy orchestration +│ └── tests/ # Tests of the runner's failure/reporting guarantees +└── docs/ # Setup, coverage matrix, audits and validation history +``` + +From the repository root, after [setup](docs/README.md#setup): + +```sh +python3 tests/run.py fast # Tooling, both backend targets, frontend coverage +python3 tests/run.py all # Also browsers, posters, real hosts and fault replays +npm run test:frontend # Frontend only +npm run test:browser # Browser only +``` + +See [all commands and prerequisites](docs/README.md), the [coverage matrix](docs/coverage-matrix.md), and [historical fault checks](docs/history-audit.md). Older artifact logs retain the paths used when they were generated; current documentation uses the organized paths. + +## Adding and maintaining tests + +- Put a new test beside the behavior it exercises. Use `*Tests.cs` for backend tests and `*.test.mjs` for frontend tests. .NET includes nested C# files automatically; both frontend commands use the same recursive discovery and reject an empty suite. +- Keep one authoritative behavioral assertion where practical. Historical issue IDs belong in comments or fault manifests; avoid copying whole test implementations into audit-specific suites. Cross-feature historical fixtures already grouped in `frontend/regressions/` remain discoverable. +- Reuse `backend/Support/` and `frontend/helpers/`; keep fixtures used by only one feature next to that feature. Only extract a shared helper when multiple tests need it. Preserve the singleton collection for tests changing JE's plugin singleton. +- Test production code through observable contracts. Prefer controlled promises, time and isolated storage. Do not duplicate production algorithms, add order dependencies, or accept unexpected console/network failures. +- Put browser fixture assets and reviewed snapshots in `browser/`; put real-host setup in `host/`. Do not add test code or fixtures to production resource directories. +- Keep new runner code in `runner/`, with meaningful failure-handling tests in `runner/tests/`. CI, documentation and npm scripts should use the shared entry points rather than duplicating suite lists. +- Keep reports, coverage, traces and generated media out of source. Update the coverage matrix for new behavior; update historical manifests if moving a test they select. Review visual diffs before accepting baselines. + +The directory structure makes the suite easier to maintain; it does not imply complete behavioral coverage. Remaining gaps and known branch-specific failures stay documented. diff --git a/tests/backend/Api/ApiAssetTests.cs b/tests/backend/Api/ApiAssetTests.cs new file mode 100644 index 000000000..956d5fbb5 --- /dev/null +++ b/tests/backend/Api/ApiAssetTests.cs @@ -0,0 +1,139 @@ +using System.Text; +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Controllers; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace JE.Tests; + +public class ApiAssetTests +{ + internal static JellyfinEnhancedController Controller(CoreFixture fixture, params object[] services) + { + // Asset actions use only the logger and HTTP context; unrelated host services are deliberately absent. + var constructor = typeof(JellyfinEnhancedController).GetConstructors().Single(); + var arguments = constructor.GetParameters().Select(p => p.ParameterType == typeof(Jellyfin.Plugin.JellyfinEnhanced.Logger) ? (object)fixture.Logger : services.FirstOrDefault(p.ParameterType.IsInstanceOfType)).ToArray(); + var controller = (JellyfinEnhancedController)constructor.Invoke(arguments); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + return controller; + } + + [Fact] + public async Task EveryManifestModuleIsEmbeddedAndServedWithJavascriptMime() + { + using var fixture = new CoreFixture(); + var controller = Controller(fixture); + var paths = ClientScriptBundle.GetComponentScripts(); + Assert.NotEmpty(paths); + Assert.Equal(paths.Count, paths.Distinct().Count()); + foreach (var path in paths) + { + var result = Assert.IsType(controller.GetScript(path)); + using var reader = new StreamReader(result.FileStream); + Assert.Equal("application/javascript", result.ContentType); + Assert.False(string.IsNullOrWhiteSpace(await reader.ReadToEndAsync()), path); + } + Assert.Equal("public, max-age=31536000, immutable", controller.Response.Headers.CacheControl); + } + + [Theory] + [InlineData("not-a-component.js")] + [InlineData("../../Configuration/configPage.html")] + [InlineData("/etc/passwd")] + [InlineData("locales/not-a-locale.json")] + public void MissingAndTraversalResourceRequestsReturnNotFound(string path) + { + using var fixture = new CoreFixture(); + Assert.IsType(Controller(fixture).GetScript(path)); + } + + [Fact] + public async Task StylesManifestAndBootstrapHaveCorrectTypesAndNonemptyBodies() + { + using var fixture = new CoreFixture(); + var controller = Controller(fixture); + foreach (var (result, mime) in new[] { + (controller.GetConfigPageStylesheet(), "text/css"), + (controller.GetScript("component-scripts.json"), "application/json"), + (controller.GetMainScript(), "application/javascript") }) + { + var file = Assert.IsType(result); + using var reader = new StreamReader(file.FileStream); + Assert.Equal(mime, file.ContentType); + Assert.NotEmpty(await reader.ReadToEndAsync()); + } + } + + [Theory] + [InlineData("de-DE", "de")] + [InlineData("fr-CA", "fr")] + [InlineData("it-IT", "it")] + public async Task RegionalLocaleFallsBackToEmbeddedBaseLanguage(string regional, string language) + { + using var fixture = new CoreFixture(); + var controller = Controller(fixture); + var regionalFile = Assert.IsType(controller.GetLocale(regional)); + var baseFile = Assert.IsType(controller.GetLocale(language)); + using var regionalReader = new StreamReader(regionalFile.FileStream); + using var baseReader = new StreamReader(baseFile.FileStream); + Assert.Equal("application/json", regionalFile.ContentType); + Assert.Equal(await baseReader.ReadToEndAsync(), await regionalReader.ReadToEndAsync()); + Assert.IsType(controller.GetLocale("zz-ZZ")); + } + + [Theory] + [InlineData("materialsymbolsrounded-subset.woff2")] + [InlineData("materialsymbolsoutlined-subset.woff2")] + [InlineData("materialsymbolsrounded.woff2")] + [InlineData("materialsymbolsoutlined.woff2")] + public async Task BundledFontsAreValidWoff2AndCacheable(string name) + { + using var fixture = new CoreFixture(); + var controller = Controller(fixture); + var file = Assert.IsType(controller.GetBundledFont(name)); + using var stream = file.FileStream; + var signature = new byte[4]; + await stream.ReadExactlyAsync(signature); + Assert.Equal("wOF2", Encoding.ASCII.GetString(signature)); + Assert.Equal("font/woff2", file.ContentType); + Assert.Equal("public, max-age=31536000, immutable", controller.Response.Headers.CacheControl); + Assert.IsType(controller.GetBundledFont("private-font.woff2")); + } + + [Fact] + public void BundleSourceMapPointsAtExactOriginalModuleLinesInManifestOrder() + { + using var fixture = new CoreFixture(); + var bundle = ClientScriptBundle.GetBundle("regression-version", true, fixture.Logger); + var script = Encoding.UTF8.GetString(bundle.Script); + var lines = script.Split('\n'); + using var map = JsonDocument.Parse(bundle.SourceMap); + var sections = map.RootElement.GetProperty("sections").EnumerateArray().ToArray(); + var paths = ClientScriptBundle.GetComponentScripts(); + Assert.Equal(3, map.RootElement.GetProperty("version").GetInt32()); + Assert.Equal(paths.Count, sections.Length); + Assert.Contains($"window.__JE_BUNDLE_TOTAL = {paths.Count};", script); + Assert.EndsWith("//# sourceMappingURL=bundle.js.map?v=regression-version\n", script); + for (var i = 0; i < paths.Count; i++) + { + var section = sections[i]; + var sourceMap = section.GetProperty("map"); + Assert.Equal($"js/{paths[i]}?v=regression-version", sourceMap.GetProperty("sources")[0].GetString()); + var line = section.GetProperty("offset").GetProperty("line").GetInt32(); + using var original = typeof(JellyfinEnhancedController).Assembly.GetManifestResourceStream("Jellyfin.Plugin.JellyfinEnhanced.js." + paths[i].Replace('/', '.'))!; + using var reader = new StreamReader(original); + var source = reader.ReadToEnd(); + Assert.Equal(source, string.Join('\n', lines.Skip(line)).Substring(0, source.Length)); + var lineCount = source.Count(c => c == '\n') + (source.EndsWith('\n') ? 0 : 1); + Assert.Equal(lineCount, sourceMap.GetProperty("mappings").GetString()!.Split(';').Length); + } + var cached = ClientScriptBundle.GetBundle("regression-version", false, fixture.Logger); + Assert.Same(bundle.Script, cached.Script); + var rebuilt = ClientScriptBundle.GetBundle("regression-version", true, fixture.Logger); + Assert.NotSame(cached.Script, rebuilt.Script); + Assert.Equal(bundle.Script, rebuilt.Script); + var changed = ClientScriptBundle.GetBundle("next-version", false, fixture.Logger); + Assert.Contains("v=next-version", Encoding.UTF8.GetString(changed.SourceMap)); + } +} diff --git a/tests/backend/Api/ApiBrandingTests.cs b/tests/backend/Api/ApiBrandingTests.cs new file mode 100644 index 000000000..08e0001e5 --- /dev/null +++ b/tests/backend/Api/ApiBrandingTests.cs @@ -0,0 +1,97 @@ +using System.Security.Claims; +using Jellyfin.Plugin.JellyfinEnhanced.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Plugin = Jellyfin.Plugin.JellyfinEnhanced.JellyfinEnhanced; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class ApiBrandingTests +{ + private static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="); + private static JellyfinEnhancedController Controller(ApiPluginFixture f, bool admin = true) + { + var c = ApiAssetTests.Controller(f.Core); + c.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, admin ? "Administrator" : "User")], "test")); + return c; + } + private static void Form(JellyfinEnhancedController c, string? name, string mime = "image/png", long? length = null, bool withFile = true) + { + var files = new FormFileCollection(); + if (withFile) files.Add(new FormFile(new MemoryStream(Png), 0, length ?? Png.Length, "file", "client-name.png") { Headers = new HeaderDictionary(), ContentType = mime }); + c.Request.Form = new FormCollection(name == null ? new() : new Dictionary { ["fileName"] = name }, files); + } + + [Theory] + [InlineData("icon-transparent.png")] + [InlineData("ICON-TRANSPARENT.PNG")] + [InlineData("../icon-transparent.png")] + public async Task UploadReadStatusAndDeleteUseCanonicalFixedSlot(string requestedName) + { + using var f = new ApiPluginFixture(); + var c = Controller(f); + Form(c, requestedName); + Assert.IsType(await c.UploadBrandingImage()); + var canonicalPath = Path.Combine(Plugin.BrandingDirectory, "icon-transparent.png"); + Assert.True(File.Exists(canonicalPath), "Accepted filename must address the same fixed slot used by middleware and status."); + Assert.Equal(Png, File.ReadAllBytes(canonicalPath)); + var status = Assert.IsType>(Assert.IsType(c.GetBrandingStatus()).Value); + Assert.True(status["icon-transparent.png"]); + Assert.Equal(5, status.Count); + var image = Assert.IsType(c.GetBrandingImage(requestedName)); + Assert.Equal(canonicalPath, image.FileName); + Assert.Equal("image/png", image.ContentType); + Assert.IsType(c.DeleteBrandingImage()); + Assert.False(File.Exists(canonicalPath)); + Assert.IsType(c.DeleteBrandingImage()); + Assert.IsType(c.GetBrandingImage(requestedName)); + } + + [Fact] + public async Task NonAdminCannotUploadOrDeleteEvenBeforeReadingForm() + { + using var f = new ApiPluginFixture(); + var c = Controller(f, false); + Assert.IsType(await c.UploadBrandingImage()); + Assert.IsType(c.DeleteBrandingImage()); + Assert.False(Directory.Exists(Plugin.BrandingDirectory)); + } + + [Theory] + [InlineData(null, "image/png", 1, true)] + [InlineData("", "image/png", 1, true)] + [InlineData("unknown.png", "image/png", 1, true)] + [InlineData("../../private.xml", "image/png", 1, true)] + [InlineData("icon-transparent.png", "text/html", 1, true)] + [InlineData("icon-transparent.png", "application/octet-stream", 1, true)] + [InlineData("icon-transparent.png", "image/png", 10485761, true)] + [InlineData("icon-transparent.png", "image/png", 1, false)] + public async Task InvalidUploadsReturn400WithoutChangingExistingAsset(string? name, string mime, long length, bool withFile) + { + using var f = new ApiPluginFixture(); + var c = Controller(f); + Directory.CreateDirectory(Plugin.BrandingDirectory); + var path = Path.Combine(Plugin.BrandingDirectory, "icon-transparent.png"); + File.WriteAllBytes(path, Png); + Form(c, name, mime, length, withFile); + Assert.IsType(await c.UploadBrandingImage()); + Assert.Equal(Png, File.ReadAllBytes(path)); + Assert.Single(Directory.GetFiles(Plugin.BrandingDirectory)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("unknown.png")] + [InlineData("../../private.xml")] + public void InvalidReadAndDeleteNamesReturn400(string? name) + { + using var f = new ApiPluginFixture(); + var c = Controller(f); + Form(c, name, withFile: false); + Assert.IsType(c.GetBrandingImage(name)); + Assert.IsType(c.DeleteBrandingImage()); + } +} diff --git a/tests/backend/Api/ApiCdnRefreshTests.cs b/tests/backend/Api/ApiCdnRefreshTests.cs new file mode 100644 index 000000000..5255247d5 --- /dev/null +++ b/tests/backend/Api/ApiCdnRefreshTests.cs @@ -0,0 +1,83 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class ApiCdnRefreshTests +{ + private sealed class Reports : IProgress + { + public List Values { get; } = new(); + public void Report(double value) => Values.Add(value); + } + + [Fact] + public async Task FullKnownAssetRefreshContinuesAfterHttpTimeoutAndReportsCompletion() + { + using var f = new CoreFixture(); + var calls = 0; + using var transport = new IntegrationTransport((request, _) => + { + var index = calls++; + if (index == 0) throw new TaskCanceledException("simulated HttpClient timeout; caller remains active"); + var path = CdnAssetService.KnownAssets[index].Path; + var mime = Path.GetExtension(path).ToLowerInvariant() switch { + ".svg" => "image/svg+xml", ".css" => "text/css", ".ico" => "image/x-icon", + ".jpg" or ".jpeg" => "image/jpeg", ".txt" => "text/plain", _ => "image/png" + }; + return Task.FromResult(IntegrationTransport.Response("synthetic-asset", type: mime)); + }); + var service = new CdnAssetService(f.Logger, transport, f.Paths.Object); + var progress = new Reports(); + await service.RefreshKnownAsync(progress, CancellationToken.None); + Assert.Equal(CdnAssetService.KnownAssets.Count, calls); + Assert.Equal(calls, progress.Values.Count); + Assert.Equal(100, progress.Values[^1]); + Assert.True(progress.Values.SequenceEqual(progress.Values.Order())); + Assert.Equal(calls - 1, Directory.GetFiles(f.Root, "*.bin", SearchOption.AllDirectories).Length); + Assert.Empty(Directory.GetFiles(f.Root, "*.tmp", SearchOption.AllDirectories)); + // At least the next asset is durably cached; a timeout must not abort the sweep. + var afterFailure = CdnAssetService.KnownAssets[1]; + var asset = await service.GetAsync(afterFailure.Source, afterFailure.Path, false, CancellationToken.None); + Assert.NotNull(asset); + Assert.Equal("synthetic-asset", System.Text.Encoding.UTF8.GetString(asset.Content)); + Assert.Equal(calls, transport.Calls); + } + + [Fact] + public async Task RefreshPropagatesCallerCancellationWithoutReportingSuccess() + { + using var f = new CoreFixture(); + using var caller = new CancellationTokenSource(); + using var transport = new IntegrationTransport((_, _) => + { + caller.Cancel(); + throw new OperationCanceledException(caller.Token); + }); + var progress = new Reports(); + var service = new CdnAssetService(f.Logger, transport, f.Paths.Object); + await Assert.ThrowsAnyAsync(() => service.RefreshKnownAsync(progress, caller.Token)); + Assert.Equal(1, transport.Calls); + Assert.Empty(progress.Values); + } + + [Fact] + public async Task TimeoutOnForcedRefreshServesPreviouslyPersistedAsset() + { + using var f = new CoreFixture(); + var path = "svg/regression-" + Guid.NewGuid().ToString("N") + ".svg"; + var timeout = false; + using var transport = new IntegrationTransport((_, _) => timeout + ? throw new TaskCanceledException("simulated upstream timeout") + : Task.FromResult(IntegrationTransport.Response("", type: "image/svg+xml"))); + var service = new CdnAssetService(f.Logger, transport, f.Paths.Object); + var initial = await service.GetAsync("selfhst", path, false, CancellationToken.None); + Assert.NotNull(initial); + timeout = true; + var reopened = new CdnAssetService(f.Logger, transport, f.Paths.Object); + var stale = await reopened.GetAsync("selfhst", path, forceRefresh: true, CancellationToken.None); + Assert.NotNull(stale); + Assert.Equal(initial.Content, stale.Content); + Assert.Equal(initial.ETag, stale.ETag); + Assert.Equal(2, transport.Calls); + } +} diff --git a/tests/backend/Api/ApiConfigurationTests.cs b/tests/backend/Api/ApiConfigurationTests.cs new file mode 100644 index 000000000..a4e2b729f --- /dev/null +++ b/tests/backend/Api/ApiConfigurationTests.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class ApiConfigurationTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void PublicConfigRedactsTopologyPreLoginAndNeverIncludesApiKeys(bool authenticated, bool admin) + { + using var fixture = new ApiPluginFixture(); + var config = fixture.Plugin.Configuration; + config.TMDB_API_KEY = "SECRET_TMDB_SENTINEL"; + config.MdblistApiKey = "SECRET_MDBLIST_SENTINEL"; + config.JellyseerrApiKey = "SECRET_SEERR_SENTINEL"; + config.SonarrApiKey = "SECRET_SONARR_SENTINEL"; + config.RadarrApiKey = "SECRET_RADARR_SENTINEL"; + config.JellyseerrUrls = "http://private-seerr.invalid:5055"; + config.JellyseerrUrlMappings = "private-mapping-sentinel"; + config.SonarrUrl = "http://private-sonarr.invalid:8989"; + var maintenance = new MaintenanceModeService(Mock.Of(), Mock.Of(), fixture.Core.Paths.Object, fixture.Core.Logger); + var controller = ApiAssetTests.Controller(fixture.Core, maintenance); + controller.HttpContext.User = new ClaimsPrincipal(authenticated + ? new ClaimsIdentity([new Claim(ClaimTypes.Role, admin ? "Administrator" : "User")], "test") + : new ClaimsIdentity()); + var result = Assert.IsType(controller.GetPublicConfig()); + var json = JsonSerializer.Serialize(result.Value); + Assert.DoesNotContain("SECRET_", json); + Assert.DoesNotContain("private-sonarr", json); + using var document = JsonDocument.Parse(json); + Assert.True(document.RootElement.GetProperty("TmdbEnabled").GetBoolean()); + Assert.True(document.RootElement.GetProperty("MdblistEnabled").GetBoolean()); + Assert.Equal(authenticated, json.Contains("private-seerr", StringComparison.Ordinal)); + Assert.Equal(authenticated, json.Contains("private-mapping-sentinel", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrivateConfigContainsTopologyOnlyForAdminAndNeverApiKeys(bool admin) + { + using var fixture = new ApiPluginFixture(); + fixture.Plugin.Configuration.SonarrUrl = "http://private-sonarr.invalid:8989"; + fixture.Plugin.Configuration.SonarrApiKey = "SECRET_SONARR_SENTINEL"; + fixture.Plugin.Configuration.RadarrApiKey = "SECRET_RADARR_SENTINEL"; + fixture.Plugin.Configuration.TMDB_API_KEY = "SECRET_TMDB_SENTINEL"; + var controller = ApiAssetTests.Controller(fixture.Core, Mock.Of()); + controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, admin ? "Administrator" : "User")], "test")); + var json = JsonSerializer.Serialize(Assert.IsType(controller.GetPrivateConfig()).Value); + Assert.DoesNotContain("SECRET_", json); + if (admin) Assert.Contains("private-sonarr", json); + else Assert.Equal("{}", json); + } + + [Fact] + public void DevelopmentModeDisablesCachingForIndividualScriptsAndBundle() + { + using var fixture = new ApiPluginFixture(); + fixture.Plugin.Configuration.DevMode = true; + var controller = ApiAssetTests.Controller(fixture.Core); + using var stream = Assert.IsType(controller.GetMainScript()).FileStream; + Assert.Equal("no-store", controller.Response.Headers.CacheControl); + Assert.IsType(controller.GetScriptBundle()); + Assert.Equal("no-store", controller.Response.Headers.CacheControl); + Assert.Contains("dev=\"true\"", fixture.Plugin.BuildScriptTag()); + } + [Fact] + public async Task RegisteredPagesAndViewsArePackagedAndViewsHaveHtmlMime() + { + using var fixture = new ApiPluginFixture(); + var controller = ApiAssetTests.Controller(fixture.Core); + var pages = fixture.Plugin.GetPages().Concat(fixture.Plugin.GetViews()).ToArray(); + Assert.NotEmpty(pages); + Assert.Equal(pages.Length, pages.Select(p => p.Name).Distinct().Count()); + foreach (var page in pages) + { + using var embedded = fixture.Plugin.GetType().Assembly.GetManifestResourceStream(page.EmbeddedResourcePath); + Assert.NotNull(embedded); + using var reader = new StreamReader(embedded!); + Assert.NotEmpty(await reader.ReadToEndAsync()); + } + foreach (var view in fixture.Plugin.GetViews()) + { + var result = Assert.IsType(controller.GetView(view.Name)); + using var stream = result.FileStream; + Assert.Equal("text/html", result.ContentType); + Assert.True(stream.Length > 0); + } + Assert.IsType(controller.GetView("unknown-view")); + } + +} diff --git a/tests/backend/Api/ApiMiddlewareTests.cs b/tests/backend/Api/ApiMiddlewareTests.cs new file mode 100644 index 000000000..3f4c851af --- /dev/null +++ b/tests/backend/Api/ApiMiddlewareTests.cs @@ -0,0 +1,158 @@ +using System.Text; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Plugin = Jellyfin.Plugin.JellyfinEnhanced.JellyfinEnhanced; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class ApiMiddlewareTests +{ + private static async Task Invoke(Microsoft.AspNetCore.Hosting.IStartupFilter filter, string path, string method, RequestDelegate downstream) + { + using var services = new ServiceCollection().AddLogging().BuildServiceProvider(); + var app = new ApplicationBuilder(services); + filter.Configure(a => a.Run(downstream))(app); + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.Method = method; + context.Response.Body = new MemoryStream(); + context.Request.Headers.AcceptEncoding = "gzip"; + context.Request.Headers.Range = "bytes=0-10"; + context.Request.Headers.IfRange = "old"; + await app.Build()(context); + return context; + } + private static string Body(HttpContext context) => Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()); + + [Theory] + [InlineData("/web")][InlineData("/web/")][InlineData("/web/index.html")] + [InlineData("/jellyfin/web")][InlineData("/jellyfin/web/")][InlineData("/jellyfin/web/index.html")] + public async Task InjectsIntoAllShellPathsIncludingBaseUrlAndInvalidatesOriginalValidators(string path) + { + using var f = new ApiPluginFixture(); + var context = await Invoke(new ScriptInjectionStartupFilter(f.Core.Logger), path, "GET", async c => + { + Assert.Equal(0, c.Request.Headers.AcceptEncoding.Count); + Assert.Equal(0, c.Request.Headers.Range.Count); + Assert.Equal(0, c.Request.Headers.IfRange.Count); + c.Response.ContentType = "text/html"; + c.Response.Headers.ETag = "original"; + c.Response.Headers.LastModified = "old"; + c.Response.Headers.AcceptRanges = "bytes"; + await c.Response.WriteAsync("日本語"); + }); + Assert.Contains(f.Plugin.BuildScriptTag(), Body(context)); + Assert.Equal(0, context.Response.Headers.ETag.Count); + Assert.Equal(0, context.Response.Headers.LastModified.Count); + Assert.Equal(0, context.Response.Headers.AcceptRanges.Count); + Assert.Equal(Encoding.UTF8.GetByteCount(Body(context)), context.Response.ContentLength); + } + + [Theory] + [InlineData("/web/index.html", "HEAD", 200, "text/html", false)] + [InlineData("/web/not-index.html", "GET", 200, "text/html", false)] + [InlineData("/web/index.html", "GET", 304, "text/html", false)] + [InlineData("/web/index.html", "GET", 200, "application/json", false)] + [InlineData("/web/index.html", "GET", 200, "text/html", true)] + public async Task NonShellNonGetNonHtmlAndDisabledResponsesPassThrough(string path, string method, int status, string mime, bool disabled) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.DisableScriptInjectionMiddleware = disabled; + const string original = "Original"; + var context = await Invoke(new ScriptInjectionStartupFilter(f.Core.Logger), path, method, async c => + { + c.Response.StatusCode = status; + c.Response.ContentType = mime; + c.Response.Headers.ETag = "original"; + await c.Response.WriteAsync(original); + }); + Assert.Equal(original, Body(context)); + Assert.Equal("original", context.Response.Headers.ETag); + } + + [Theory] + [InlineData("")] + [InlineData("No body closing tag")] + public async Task ExistingInjectionAndMissingBodyDoNotAddAnotherTag(string original) + { + using var f = new ApiPluginFixture(); + var context = await Invoke(new ScriptInjectionStartupFilter(f.Core.Logger), "/web/", "GET", async c => + { + c.Response.ContentType = "text/html"; + await c.Response.WriteAsync(original); + }); + Assert.Equal(original, Body(context)); + } + + [Fact] + public async Task DownstreamFailureRestoresOriginalStreamWithoutFlushingPartialBody() + { + using var f = new ApiPluginFixture(); + using var services = new ServiceCollection().BuildServiceProvider(); + var app = new ApplicationBuilder(services); + new ScriptInjectionStartupFilter(f.Core.Logger).Configure(a => a.Run(async c => { await c.Response.WriteAsync("partial"); throw new InvalidOperationException("downstream"); }))(app); + var context = new DefaultHttpContext(); + context.Request.Path = "/web/"; + context.Request.Method = "GET"; + using var original = new MemoryStream(); + context.Response.Body = original; + await Assert.ThrowsAsync(() => app.Build()(context)); + Assert.Same(original, context.Response.Body); + Assert.Equal(0, original.Length); + } + + [Theory] + [InlineData("/web/icon-transparent.abc.png", "icon-transparent.png")] + [InlineData("/base/web/banner-light.a1.png", "banner-light.png")] + [InlineData("/web/banner-dark.deadbeef.png", "banner-dark.png")] + [InlineData("/web/favicon.abc.ico", "favicon.ico")] + [InlineData("/web/touchicon.abcdef.png", "apple-touch-icon.png")] + [InlineData("/web/favicons/touchicon144.png", "apple-touch-icon.png")] + public async Task BrandingServesFixedUploadNamesWithHeadAndConditionalGet(string path, string filename) + { + using var f = new ApiPluginFixture(); + Directory.CreateDirectory(Plugin.BrandingDirectory); + var bytes = new byte[] { 1, 2, 3, 4 }; + File.WriteAllBytes(Path.Combine(Plugin.BrandingDirectory, filename), bytes); + var filter = new BrandingAssetStartupFilter(f.Core.Logger); + RequestDelegate fail = _ => throw new Exception("Branding unexpectedly fell through"); + var get = await Invoke(filter, path, "GET", fail); + Assert.Equal(bytes, ((MemoryStream)get.Response.Body).ToArray()); + Assert.Equal("no-cache", get.Response.Headers.CacheControl); + Assert.NotEmpty(get.Response.Headers.ETag.ToString()); + var head = await Invoke(filter, path, "HEAD", fail); + Assert.Equal(4, head.Response.ContentLength); + Assert.Empty(Body(head)); + Assert.Equal(get.Response.Headers.ETag, head.Response.Headers.ETag); + using var services = new ServiceCollection().BuildServiceProvider(); + var app = new ApplicationBuilder(services); + filter.Configure(a => a.Run(fail))(app); + var conditional = new DefaultHttpContext(); + conditional.Request.Path = path; + conditional.Request.Method = "GET"; + conditional.Request.Headers.IfNoneMatch = "\"other\", W/" + get.Response.Headers.ETag; + conditional.Response.Body = new MemoryStream(); + await app.Build()(conditional); + Assert.Equal(304, conditional.Response.StatusCode); + Assert.Empty(Body(conditional)); + } + + [Theory] + [InlineData("/web/icon-transparent.abc.png", "POST", false, true)] + [InlineData("/web/icon-transparent.abc.png", "GET", true, true)] + [InlineData("/web/icon-transparent.abc.png", "GET", false, false)] + [InlineData("/other/icon-transparent.abc.png", "GET", false, true)] + [InlineData("/web/unrelated.png", "GET", false, true)] + public async Task BrandingPassesThroughNonAssetsDisabledMissingAndUnsupportedMethods(string path, string method, bool disabled, bool upload) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.DisableBrandingMiddleware = disabled; + Directory.CreateDirectory(Plugin.BrandingDirectory); + if (upload) File.WriteAllText(Path.Combine(Plugin.BrandingDirectory, "icon-transparent.png"), "custom"); + var context = await Invoke(new BrandingAssetStartupFilter(f.Core.Logger), path, method, c => c.Response.WriteAsync("stock")); + Assert.Equal("stock", Body(context)); + } +} diff --git a/tests/backend/Api/ApiRoutingTests.cs b/tests/backend/Api/ApiRoutingTests.cs new file mode 100644 index 000000000..0e1799195 --- /dev/null +++ b/tests/backend/Api/ApiRoutingTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Jellyfin.Plugin.JellyfinEnhanced.Controllers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace JE.Tests; + +// Actual ASP.NET routing, authentication/authorization and MVC result execution; +// Jellyfin services unrelated to public asset endpoints are deliberately absent. +public class ApiRoutingTests +{ + private sealed class AssetActivator(CoreFixture fixture) : IControllerActivator + { + public object Create(ControllerContext context) + { + var controller = ApiAssetTests.Controller(fixture); + controller.ControllerContext = context; + return controller; + } + public void Release(ControllerContext context, object controller) { } + } + private sealed class AnonymousAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() => Task.FromResult(AuthenticateResult.NoResult()); + } + private static TestServer Server(CoreFixture fixture) + { + return new TestServer(new WebHostBuilder().ConfigureServices(services => + { + services.AddLogging(); + services.AddRouting(); + services.AddAuthentication("test").AddScheme("test", _ => { }); + services.AddAuthorization(); + services.AddControllers().AddApplicationPart(typeof(JellyfinEnhancedController).Assembly); + services.AddSingleton(new AssetActivator(fixture)); + }).Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(e => e.MapControllers()); + })); + } + + [Theory] + [InlineData("script", "application/javascript")] + [InlineData("js/component-scripts.json", "application/json")] + [InlineData("Configuration/configPage.css", "text/css")] + [InlineData("locales/de-DE.json", "application/json")] + [InlineData("fonts/materialsymbolsrounded-subset.woff2", "font/woff2")] + public async Task PublicAssetsRouteAndExecuteWithoutAuthentication(string route, string mime) + { + using var fixture = new CoreFixture(); + using var server = Server(fixture); + using var client = server.CreateClient(); + using var response = await client.GetAsync("/JellyfinEnhanced/" + route); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(mime, response.Content.Headers.ContentType!.MediaType); + Assert.NotEmpty(await response.Content.ReadAsByteArrayAsync()); + } + + [Theory] + [InlineData("private-config")] + [InlineData("host-compat")] + [InlineData("jellyfin-urls")] + [InlineData("jellyseerr/status")] + [InlineData("jellyseerr/permission-audit")] + [InlineData("jellyseerr/quota")] + [InlineData("tmdb/movie/1")] + public async Task PrivateRoutesChallengeBeforeControllerUsesHostServices(string route) + { + using var fixture = new CoreFixture(); + using var server = Server(fixture); + using var client = server.CreateClient(); + using var response = await client.GetAsync("/JellyfinEnhanced/" + route); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("unknown-route/unknown-child")] + [InlineData("js/missing.js")] + [InlineData("fonts/missing.woff2")] + [InlineData("locales/zz-ZZ.json")] + public async Task UnknownRoutesAndAssetsReturn404(string route) + { + using var fixture = new CoreFixture(); + using var server = Server(fixture); + using var client = server.CreateClient(); + using var response = await client.GetAsync("/JellyfinEnhanced/" + route); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task AssetPostReturnsMethodNotAllowed() + { + using var fixture = new CoreFixture(); + using var server = Server(fixture); + using var client = server.CreateClient(); + using var response = await client.PostAsync("/JellyfinEnhanced/script", null); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } +} diff --git a/tests/backend/Api/ApiSeerrTests.cs b/tests/backend/Api/ApiSeerrTests.cs new file mode 100644 index 000000000..0ce4cff6d --- /dev/null +++ b/tests/backend/Api/ApiSeerrTests.cs @@ -0,0 +1,241 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Controllers; +using Jellyfin.Plugin.JellyfinEnhanced.Model.Jellyseerr; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class ApiSeerrTests +{ + private sealed class SeerrFixture : IDisposable + { + public ApiPluginFixture Plugin { get; } = new(); + public Guid UserId { get; } = Guid.NewGuid(); + public Guid OtherUserId { get; } = Guid.NewGuid(); + public JellyseerrPermission Permissions { get; set; } = JellyseerrPermission.REQUEST; + public ConcurrentQueue<(string Path, string Method, string? User, string? Body)> Calls { get; } = new(); + public Func Reply { get; set; } = _ => IntegrationTransport.Response("{\"id\":99}"); + public IntegrationTransport Transport { get; } + public JellyfinEnhancedController Controller { get; } + public SeerrFixture() + { + JellyfinEnhancedController.ClearAllSeerrCachesOnConfigChange(); + Plugin.Plugin.Configuration.JellyseerrEnabled = true; + Plugin.Plugin.Configuration.JellyseerrAutoImportUsers = false; + Plugin.Plugin.Configuration.JellyseerrUrls = "http://private-seerr.invalid"; + Plugin.Plugin.Configuration.JellyseerrApiKey = "test-secret"; + Plugin.Plugin.Configuration.SpoilerAutoEnableOnSeerrRequest = false; + Transport = new IntegrationTransport(async (request, _) => + { + var path = request.RequestUri!.PathAndQuery; + var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(); + var user = request.Headers.TryGetValues("X-Api-User", out var values) ? values.Single() : null; + Calls.Enqueue((path, request.Method.Method, user, body)); + Assert.Equal("test-secret", Assert.Single(request.Headers.GetValues("X-Api-Key"))); + if (path.StartsWith("/api/v1/user?")) return IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { + new { id = 27, jellyfinUserId = UserId.ToString("N"), permissions = (int)Permissions }, + new { id = 38, jellyfinUserId = OtherUserId.ToString("N"), permissions = (int)Permissions } + } })); + return Reply(request); + }); + var users = Mock.Of(); + var filter = new SeerrParentalFilter(Transport, users, Mock.Of(), Mock.Of(), Plugin.Core.Logger); + Controller = ApiAssetTests.Controller(Plugin.Core, Transport, users, filter); + SetUser(UserId); + } + public void SetUser(Guid user, bool admin = false) => Controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("Jellyfin-UserId", user.ToString()), new Claim(ClaimTypes.Role, admin ? "Administrator" : "User")], "test")); + public void Dispose() { Transport.Dispose(); JellyfinEnhancedController.ClearAllSeerrCachesOnConfigChange(); Plugin.Dispose(); } + } + private static JsonElement Body(string json) => JsonSerializer.Deserialize(json); + + [Theory] + [InlineData(JellyseerrPermission.REQUEST, false, false)] + [InlineData(JellyseerrPermission.REQUEST_MOVIE, false, false)] + [InlineData(JellyseerrPermission.REQUEST_TV, false, false)] + [InlineData(JellyseerrPermission.REQUEST | JellyseerrPermission.REQUEST_ADVANCED, false, true)] + [InlineData(JellyseerrPermission.REQUEST | JellyseerrPermission.MANAGE_REQUESTS, false, true)] + [InlineData(JellyseerrPermission.ADMIN, false, true)] + [InlineData(JellyseerrPermission.NONE, true, true)] + public async Task RequestsUsePrincipalIdentityPreserveSeasonSelectionAndGateAdvancedOptions(JellyseerrPermission permissions, bool jellyfinAdmin, bool advanced) + { + using var f = new SeerrFixture { Permissions = permissions }; + f.SetUser(f.UserId, jellyfinAdmin); + f.Controller.Request.Headers["X-Api-User"] = "1"; + f.Controller.Request.Headers["X-Emby-UserId"] = f.OtherUserId.ToString(); + var result = await f.Controller.JellyseerrRequest(Body("{\"mediaType\":\"tv\",\"mediaId\":101,\"seasons\":[1,3],\"is4k\":false,\"serverId\":9,\"profileId\":8,\"rootFolder\":\"/media\",\"languageProfileId\":7,\"tags\":[6]}")); + Assert.IsType(result); + var call = Assert.Single(f.Calls, c => c.Method == "POST"); + Assert.Equal("/api/v1/request", call.Path); + Assert.Equal("27", call.User); + using var request = JsonDocument.Parse(call.Body!); + Assert.Equal(new[] { 1, 3 }, request.RootElement.GetProperty("seasons").EnumerateArray().Select(v => v.GetInt32())); + Assert.Equal(101, request.RootElement.GetProperty("mediaId").GetInt32()); + foreach (var field in new[] { "serverId", "profileId", "rootFolder", "languageProfileId", "tags" }) + Assert.Equal(advanced, request.RootElement.TryGetProperty(field, out _)); + Assert.Single(f.Calls, c => c.Path.StartsWith("/api/v1/user?")); + } + + [Fact] + public async Task MissingRequestPermissionFailsBeforePostingAndMissingIdentityFailsBeforeLookup() + { + using var f = new SeerrFixture { Permissions = JellyseerrPermission.NONE }; + var denied = Assert.IsType(await f.Controller.JellyseerrRequest(Body("{\"mediaType\":\"movie\",\"mediaId\":5}"))); + Assert.Equal(403, denied.StatusCode); + Assert.Contains("no_request_permission", JsonSerializer.Serialize(denied.Value)); + Assert.DoesNotContain(f.Calls, c => c.Method == "POST"); + f.Calls.Clear(); + f.Controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); + Assert.IsType(await f.Controller.JellyseerrRequest(Body("{}"))); + Assert.Empty(f.Calls); + } + + [Theory] + [InlineData(403)] + [InlineData(409)] + [InlineData(429)] + [InlineData(500)] + public async Task RequestErrorsPreserveStatusDoNotRetryWritesOrExposePrivateUpstream(int status) + { + using var f = new SeerrFixture(); + f.Reply = _ => IntegrationTransport.Response("{\"error\":\"upstream details\"}", status); + var result = Assert.IsType(await f.Controller.JellyseerrRequest(Body("{\"mediaType\":\"movie\",\"mediaId\":5}"))); + Assert.Equal(status, result.StatusCode); + Assert.Single(f.Calls, c => c.Method == "POST"); + var json = JsonSerializer.Serialize(result.Value); + Assert.DoesNotContain("private-seerr", json); + Assert.DoesNotContain("test-secret", json); + Assert.DoesNotContain("upstream details", json); + } + + [Fact] + public async Task DetailCacheIsPerUserAndSuccessfulRequestInvalidatesMatchingTitleForEveryUser() + { + using var f = new SeerrFixture(); + var sequence = 0; + f.Reply = request => IntegrationTransport.Response(JsonSerializer.Serialize(new { sequence = ++sequence, user = request.Headers.GetValues("X-Api-User").Single() })); + var first = Assert.IsType(await f.Controller.GetTvSeason(101, 1)).Content; + Assert.Equal(first, Assert.IsType(await f.Controller.GetTvSeason(101, 1)).Content); + f.SetUser(f.OtherUserId); + var second = Assert.IsType(await f.Controller.GetTvSeason(101, 1)).Content; + Assert.NotEqual(first, second); + Assert.Contains("38", second!); + Assert.Equal(2, f.Calls.Count(c => c.Path == "/api/v1/tv/101/season/1")); + var unrelated = Assert.IsType(await f.Controller.GetTvShow(1010)).Content; + await f.Controller.JellyseerrRequest(Body("{\"mediaType\":\"tv\",\"mediaId\":101,\"seasons\":[1]}")); + Assert.NotEqual(second, Assert.IsType(await f.Controller.GetTvSeason(101, 1)).Content); + f.SetUser(f.UserId); + Assert.NotEqual(first, Assert.IsType(await f.Controller.GetTvSeason(101, 1)).Content); + f.SetUser(f.OtherUserId); + Assert.Equal(unrelated, Assert.IsType(await f.Controller.GetTvShow(1010)).Content); + Assert.Equal(4, f.Calls.Count(c => c.Path == "/api/v1/tv/101/season/1")); + Assert.Single(f.Calls, c => c.Path == "/api/v1/tv/1010"); + } + + [Fact] + public async Task QuotaResetUsesOldestNondeclinedRequestInsideWindowAndCurrentUser() + { + using var f = new SeerrFixture(); + var created = DateTime.UtcNow.AddDays(-2); + f.Reply = request => request.RequestUri!.AbsolutePath.EndsWith("/quota") + ? IntegrationTransport.Response("{\"movie\":{\"limit\":4,\"used\":2,\"days\":7},\"tv\":null}") + : IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { + new { status = 2, createdAt = created.ToString("o") }, + new { status = 3, createdAt = created.AddDays(-1).ToString("o") }, + new { status = 2, createdAt = created.AddDays(-20).ToString("o") }, + new { status = 2, createdAt = created.AddDays(1).ToString("o") } + } })); + var result = Assert.IsType(await f.Controller.GetJellyseerrQuota()); + using var quota = JsonDocument.Parse(result.Content!); + Assert.Equal(created.AddDays(7), quota.RootElement.GetProperty("movie").GetProperty("nextResetAt").GetDateTime()); + Assert.Equal(JsonValueKind.Null, quota.RootElement.GetProperty("tv").ValueKind); + Assert.Contains(f.Calls, c => c.Path == "/api/v1/user/27/quota" && c.User == "27"); + Assert.Contains(f.Calls, c => c.Path.Contains("requestedBy=27") && c.Path.Contains("mediaType=movie") && c.User == "27"); + } + + [Theory] + [InlineData("{\"movie\":null,\"tv\":null}")] + [InlineData("{\"movie\":{\"limit\":0,\"used\":9,\"days\":7},\"tv\":{\"limit\":2,\"used\":0,\"days\":7}}")] + public async Task UnlimitedAndUnusedQuotasDoNotFetchHistory(string body) + { + using var f = new SeerrFixture(); + f.Reply = _ => IntegrationTransport.Response(body); + var result = Assert.IsType(await f.Controller.GetJellyseerrQuota()); + Assert.Equal(body, result.Content); + Assert.DoesNotContain(f.Calls, c => c.Path.StartsWith("/api/v1/request")); + } + + [Theory] + [InlineData(JellyseerrPermission.NONE, false)] + [InlineData(JellyseerrPermission.REQUEST, true)] + [InlineData(JellyseerrPermission.REQUEST_ADVANCED, true)] + [InlineData(JellyseerrPermission.ADMIN, true)] + public async Task ServiceReadRequiresRequestOrAdvancedPermission(JellyseerrPermission permission, bool allowed) + { + using var f = new SeerrFixture { Permissions = permission }; + var result = await f.Controller.GetSonarrInstances(); + if (allowed) Assert.IsType(result); + else Assert.Equal(403, Assert.IsType(result).StatusCode); + Assert.Equal(allowed, f.Calls.Any(c => c.Path == "/api/v1/service/sonarr")); + var invalid = Assert.IsType(await f.Controller.GetServiceDetails("../settings", 1)); + Assert.Equal(400, invalid.StatusCode); + } + [Theory] + [InlineData(JellyseerrPermission.NONE, false, false)] + [InlineData(JellyseerrPermission.CREATE_ISSUES, false, true)] + [InlineData(JellyseerrPermission.VIEW_ISSUES, true, false)] + [InlineData(JellyseerrPermission.MANAGE_ISSUES, true, true)] + [InlineData(JellyseerrPermission.ADMIN, true, true)] + public async Task IssueReadAndWritePermissionsAreIndependentIncludingDirectId(JellyseerrPermission permission, bool read, bool write) + { + using var f = new SeerrFixture { Permissions = permission }; + var detail = await f.Controller.GetJellyseerrIssueById(42); + if (read) Assert.IsType(detail); + else Assert.Equal(403, Assert.IsType(detail).StatusCode); + var report = await f.Controller.ReportJellyseerrIssue(Body("{\"issueType\":1,\"mediaId\":42,\"message\":\"test\"}")); + if (write) Assert.IsType(report); + else Assert.Equal(403, Assert.IsType(report).StatusCode); + Assert.Equal(read, f.Calls.Any(c => c.Path == "/api/v1/issue/42")); + Assert.Equal(write, f.Calls.Any(c => c.Path == "/api/v1/issue" && c.Method == "POST")); + Assert.IsType(await f.Controller.GetJellyseerrIssueById(0)); + Assert.IsType(await f.Controller.GetJellyseerrIssueById(-1)); + } + + [Fact] + public async Task SecretBearingArrSettingsRequireJellyfinAdministratorEvenForSeerrAdmin() + { + using var f = new SeerrFixture { Permissions = JellyseerrPermission.ADMIN }; + Assert.IsType(await f.Controller.GetJellyseerrArrSettings("sonarr")); + Assert.Empty(f.Calls); + f.SetUser(f.UserId, admin: true); + Assert.IsType(await f.Controller.GetJellyseerrArrSettings("../users")); + Assert.Empty(f.Calls); + f.Reply = _ => IntegrationTransport.Response("[{\"apiKey\":\"expected-admin-secret\"}]"); + var result = Assert.IsType(await f.Controller.GetJellyseerrArrSettings("sonarr")); + Assert.Contains("expected-admin-secret", result.Content!); + Assert.Single(f.Calls, c => c.Path == "/api/v1/settings/sonarr"); + } + + [Fact] + public async Task BrowserDisconnectCancelsReadsButRequestWritesStillComplete() + { + using var f = new SeerrFixture(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + f.Controller.HttpContext.RequestAborted = cancellation.Token; + var read = Assert.IsType(await f.Controller.GetTvShow(333)); + Assert.Equal(499, read.StatusCode); + var write = await f.Controller.JellyseerrRequest(Body("{\"mediaType\":\"tv\",\"mediaId\":333,\"seasons\":[1]}")); + Assert.IsType(write); + Assert.Single(f.Calls, c => c.Method == "POST"); + } + +} diff --git a/tests/backend/Core/CoreAwardsTests.cs b/tests/backend/Core/CoreAwardsTests.cs new file mode 100644 index 000000000..cc243c919 --- /dev/null +++ b/tests/backend/Core/CoreAwardsTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Text; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Moq; + +namespace JE.Tests; + +public class CoreAwardsTests +{ + private sealed class Handler(string body, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public int Calls; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref Calls); + Assert.Equal("query.wikidata.org", request.RequestUri!.Host); + Assert.Contains("application/sparql-results+json", request.Headers.Accept.ToString()); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + } + private static Mock Factory(Handler handler) + { + var factory = new Mock(); + factory.Setup(x => x.CreateClient(It.IsAny())).Returns(() => new HttpClient(handler, false)); + return factory; + } + + [Fact] + public async Task ConfirmedEmptyResultCoalescesAndPersistsAcrossRestart() + { + using var f = new CoreFixture(); + using var handler = new Handler("{\"results\":{\"bindings\":[]}}"); + var factory = Factory(handler); + using (var service = new WikidataAwardsService(factory.Object, f.Paths.Object, f.Logger)) + { + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => service.GetAwardsAsync("movie", "123", default))); + Assert.All(results, r => { Assert.False(r.Found); Assert.True(r.Confirmed); }); + Assert.Equal(1, handler.Calls); + } + using var reopened = new WikidataAwardsService(factory.Object, f.Paths.Object, f.Logger); + Assert.True((await reopened.GetAwardsAsync("movie", "123", default)).Confirmed); + Assert.Equal(1, handler.Calls); + await reopened.GetAwardsAsync("tv", "123", default); + Assert.Equal(2, handler.Calls); + } + + [Theory] + [InlineData("broken", 200)][InlineData("{}", 200)][InlineData("{}", 429)][InlineData("{}", 503)] + public async Task ProviderFailuresAreUnconfirmedAndCachedBriefly(string body, int status) + { + using var f = new CoreFixture(); + using var handler = new Handler(body, (HttpStatusCode)status); + using var service = new WikidataAwardsService(Factory(handler).Object, f.Paths.Object, f.Logger); + var result = await service.GetAwardsAsync("person", "1", default); + Assert.False(result.Found); + Assert.False(result.Confirmed); + await service.GetAwardsAsync("person", "1", default); + Assert.Equal(1, handler.Calls); + } + + [Theory] + [InlineData("null")] + [InlineData("{\"Found\":true,\"FetchedAtUnixMs\":9223372036854775807}")] + public async Task InvalidPersistedEntriesAreRefetchedInsteadOfCrashing(string entry) + { + using var f = new CoreFixture(); + File.WriteAllText(Path.Combine(f.ConfigRoot, "awards.json"), "{\"SchemaVersion\":1,\"Items\":{\"movie-1\":" + entry + "}}"); + using var handler = new Handler("{\"results\":{\"bindings\":[]}}"); + using var service = new WikidataAwardsService(Factory(handler).Object, f.Paths.Object, f.Logger); + Assert.True((await service.GetAwardsAsync("movie", "1", default)).Confirmed); + Assert.Equal(1, handler.Calls); + } +} diff --git a/tests/backend/Core/CoreConfigurationTests.cs b/tests/backend/Core/CoreConfigurationTests.cs new file mode 100644 index 000000000..22b3f520f --- /dev/null +++ b/tests/backend/Core/CoreConfigurationTests.cs @@ -0,0 +1,72 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Newtonsoft.Json; + +namespace JE.Tests; + +public class CoreConfigurationTests +{ + [Fact] + public void DefaultUserSettingsPreserveOptInAndInheritedChoices() + { + var settings = new UserSettings(); + Assert.False(settings.AutoPauseEnabled); + Assert.False(settings.AutoResumeEnabled); + Assert.False(settings.AutoSkipIntro); + Assert.Null(settings.UseNativePosterTags); + Assert.True(settings.ShowResolutionTag); + Assert.True(settings.ShowAudioInfoTag); + Assert.Equal("percentage", settings.WatchProgressMode); + Assert.Equal("list", settings.CalendarDisplayMode); + Assert.Equal(85, settings.SubtitleVerticalPosition); + Assert.Equal(50, settings.SubtitleHorizontalPosition); + var hidden = new HiddenContentSettings(); + Assert.True(hidden.Enabled); + Assert.True(hidden.FilterLibrary); + Assert.False(hidden.FilterSearch); + Assert.False(hidden.ExperimentalHideCollections); + } + + [Theory] + [InlineData(false)][InlineData(true)] + public void SpoilerDictionaryLookupSurvivesBothSerializers(bool systemTextJson) + { + const string json = "{\"Series\":{\"ABC\":{\"SeriesName\":\"Series\"}},\"Movies\":{\"DEF\":{\"MovieName\":\"Movie\"}},\"Collections\":{\"GHI\":{}},\"PendingTmdb\":{\"TV:123\":{}}}"; + var state = systemTextJson + ? System.Text.Json.JsonSerializer.Deserialize(json)! + : JsonConvert.DeserializeObject(json)!; + Assert.Equal("Series", state.Series["abc"].SeriesName); + Assert.Equal("Movie", state.Movies["def"].MovieName); + Assert.True(state.Collections.ContainsKey("ghi")); + Assert.True(state.PendingTmdb.ContainsKey("tv:123")); + Assert.Null(state.Prefs.HideRatings); + Assert.Null(state.Prefs.UseAdvancedCategories); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"Series\":null,\"Movies\":null,\"Collections\":null,\"PendingTmdb\":null}")] + public void LegacyAndNullSpoilerCollectionsLoadAsEmpty(string json) + { + var state = System.Text.Json.JsonSerializer.Deserialize(json)!; + Assert.Empty(state.Series); + Assert.Empty(state.Movies); + Assert.Empty(state.Collections); + Assert.Empty(state.PendingTmdb); + Assert.NotNull(state.Prefs); + } + + [Fact] + public void BookmarkOptionalEpisodeIdentityAndUnicodeRoundTrip() + { + using var f = new CoreFixture(); + var id = Guid.NewGuid().ToString("N"); + var bookmarks = new UserBookmark { Bookmarks = new() { ["a"] = new BookmarkItem { ItemId = "a", Name = "日本語 🎬", Timestamp = 12.25, Label = "", SeasonNumber = 0, EpisodeNumber = 1 } } }; + f.Manager.SaveUserConfiguration(id, "bookmarks.json", bookmarks); + var result = f.Manager.GetUserConfigurationStrict(id, "bookmarks.json").Bookmarks["a"]; + Assert.Equal("日本語 🎬", result.Name); + Assert.Equal(12.25, result.Timestamp); + Assert.Equal("", result.Label); + Assert.Equal(0, result.SeasonNumber); + Assert.Equal(1, result.EpisodeNumber); + } +} diff --git a/tests/backend/Core/CoreMaintenanceTests.cs b/tests/backend/Core/CoreMaintenanceTests.cs new file mode 100644 index 000000000..d01af3199 --- /dev/null +++ b/tests/backend/Core/CoreMaintenanceTests.cs @@ -0,0 +1,286 @@ +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Users; +using Moq; +using Newtonsoft.Json; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class CoreMaintenanceTests +{ + private sealed class Fixture : IDisposable + { + public CoreFixture Core { get; } = new(); + public Mock Users { get; } = new(); + public Mock Sessions { get; } = new(); + public User Admin { get; } = Make("admin"); + public User Alice { get; } = Make("alice"); + public User Restricted { get; } = Make("restricted"); + public Dictionary Policies { get; } = new(); + public MaintenanceModeService Service { get; } + public Fixture() + { + Admin.SetPermission(PermissionKind.IsAdministrator, true); + var all = new[] { Admin, Alice, Restricted }; +#if NET9_0 + Users.SetupGet(x => x.Users).Returns(all); +#else + Users.Setup(x => x.GetUsers()).Returns(all); +#endif + foreach (var user in all) + { + Policies[user.Id] = new UserPolicy { IsDisabled = user == Restricted, EnableRemoteAccess = user != Restricted }; + Users.Setup(x => x.GetUserById(user.Id)).Returns(user); + Users.Setup(x => x.GetUserDto(user, It.IsAny())).Returns(() => new UserDto { Id = user.Id, Policy = Clone(Policies[user.Id]) }); + } + Users.Setup(x => x.UpdatePolicyAsync(It.IsAny(), It.IsAny())) + .Callback((id, policy) => Policies[id] = Clone(policy)).Returns(Task.CompletedTask); + Sessions.SetupGet(x => x.Sessions).Returns(Array.Empty()); + Service = Reopen(); + } + private static User Make(string name) => new(name, "default", "default") { Id = Guid.NewGuid() }; + private static UserPolicy Clone(UserPolicy policy) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(policy))!; + public MaintenanceModeService Reopen() => new(Users.Object, Sessions.Object, Core.Paths.Object, Core.Logger); + public void Dispose() => Core.Dispose(); + } + + [Theory] + [InlineData("none", false, true)][InlineData("disable_accounts", true, true)] + [InlineData("disable_remote", false, false)][InlineData("both", true, false)] + public async Task ActionsExcludeAdministratorsAndRestoreOnlyMaintenanceChanges(string action, bool disabled, bool remote) + { + using var f = new Fixture(); + await f.Service.EnableAsync("maintenance", 0, action, null); + Assert.Equal(disabled, f.Policies[f.Alice.Id].IsDisabled); + Assert.Equal(remote, f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.False(f.Policies[f.Admin.Id].IsDisabled); + Assert.True(f.Policies[f.Admin.Id].EnableRemoteAccess); + await f.Reopen().DisableAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.False(f.Policies[f.Restricted.Id].EnableRemoteAccess); + } + + [Fact] + public async Task SelectionReconciliationRestoresPreviousActionAndInvalidIdsDoNotTargetEveryone() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + await f.Service.EnableAsync("", 0, "disable_remote", [f.Alice.Id.ToString()]); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.False(f.Policies[f.Alice.Id].EnableRemoteAccess); + await f.Service.EnableAsync("", 0, "both", ["invalid", f.Admin.Id.ToString()]); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); + } + + [Fact] + public async Task RepeatManualSavePreservesClockAndManualWindowWinsOverSchedule() + { + using var f = new Fixture(); + await f.Service.EnableAsync("first", 60, "none", null); + var started = f.Service.GetStatus().StartedAt; + var end = f.Service.GetStatus().EndsAt; + await f.Service.EnableAsync("updated", 60, "none", []); + Assert.Equal(end, f.Service.GetStatus().EndsAt); + Assert.Equal(started, f.Service.GetStatus().StartedAt); + Assert.Equal("updated", f.Service.GetStatus().Message); + Assert.False(await f.Service.EnableScheduledAsync("scheduled", "", "both", null, DateTime.UtcNow.AddHours(2))); + await f.Service.DisableScheduledWindowAsync("schedule off"); + Assert.True(f.Service.GetStatus().IsActive); + Assert.Equal("manual", f.Service.GetStatus().Source); + } + + [Fact] + public async Task ScheduledWindowEndedByAdminStaysSkippedAcrossRestartButNextOccurrenceOpens() + { + using var f = new Fixture(); + var end = DateTime.UtcNow.AddHours(1); + Assert.True(await f.Service.EnableScheduledAsync("scheduled", "", "both", null, end)); + await f.Service.DisableAsync(includeScheduled: false); + Assert.True(f.Service.GetStatus().IsActive); + await f.Service.DisableAsync(); + var restarted = f.Reopen(); + Assert.False(await restarted.EnableScheduledAsync("scheduled", "", "both", null, end)); + Assert.True(await restarted.EnableScheduledAsync("scheduled", "", "both", null, end.AddDays(1))); + } + + [Fact] + public async Task ExpiredPersistedWindowRestoresPoliciesAndDisablesState() + { + using var f = new Fixture(); + await f.Service.EnableScheduledAsync("expired", "", "both", null, DateTime.UtcNow.AddHours(-1)); + var restarted = f.Reopen(); + var state = await restarted.ExpireIfDueAsync(); + Assert.False(state.IsActive); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.False(f.Reopen().GetStatus().IsActive); + } + + [Fact] + public async Task FailedPolicyUpdatesAreNotRecordedAsChangesToRestore() + { + using var f = new Fixture(); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + } + + [Fact] + public async Task FailedRestoreRemainsPendingAcrossRestartAndRetriesWithoutTouchingAlreadyRestoredUsers() + { + using var f = new Fixture(); + // Make both ordinary users eligible, so one restoration can succeed while the other fails. + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + await f.Service.EnableAsync("", 0, "both", null); + var fail = true; + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())) + .Returns((id, policy) => + { + if (fail) throw new IOException("policy store unavailable"); + f.Policies[id] = policy; + return Task.CompletedTask; + }); + await f.Service.DisableAsync(); + Assert.False(f.Service.GetStatus().IsActive); + Assert.Contains(f.Alice.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); + Assert.DoesNotContain(f.Restricted.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); + // An admin now changes the successfully restored user's policy. Retry must not undo it. + f.Policies[f.Restricted.Id].IsDisabled = true; + f.Policies[f.Restricted.Id].EnableRemoteAccess = false; + fail = false; + var restarted = f.Reopen(); + await restarted.ExpireIfDueAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.False(f.Policies[f.Restricted.Id].EnableRemoteAccess); + Assert.Empty(restarted.GetStatus().AccountDisabledUserIds); + Assert.Empty(restarted.GetStatus().RemoteDisabledUserIds); + Assert.Empty(f.Reopen().GetStatus().AccountDisabledUserIds); + } + + [Fact] + public async Task FailedReconcileCannotOverwritePreviousRestorationIntent() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); + await Assert.ThrowsAsync(() => f.Service.EnableAsync("new", 0, "none", null)); + Assert.Contains(f.Alice.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); + Assert.Equal("both", f.Reopen().GetStatus().Action); + await f.Service.DisableAsync(); + await Assert.ThrowsAsync(() => f.Service.EnableAsync("new", 0, "none", null)); + Assert.Contains(f.Alice.Id.ToString(), f.Reopen().GetStatus().AccountDisabledUserIds); + } + + [Fact] + public async Task NullPersistedRestoreListsDoNotBreakStartup() + { + using var f = new Fixture(); + File.WriteAllText(Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"), "{\"IsActive\":false,\"AccountDisabledUserIds\":null,\"RemoteDisabledUserIds\":null}"); + var reopened = f.Reopen(); + await reopened.ExpireIfDueAsync(); + await reopened.EnableAsync("", 0, "none", null); + await reopened.DisableAsync(); + Assert.False(reopened.GetStatus().IsActive); + } + + [Fact] + public async Task PartialReconcileFailureThenOriginalSelectionReappliesEveryUser() + { + using var f = new Fixture(); + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + await f.Service.EnableAsync("", 0, "both", null); + var fail = true; + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((id, policy) => + { + if (fail) throw new IOException("unavailable"); + f.Policies[id] = policy; + return Task.CompletedTask; + }); + await Assert.ThrowsAsync(() => f.Service.EnableAsync("", 0, "none", null)); + Assert.False(f.Policies[f.Restricted.Id].IsDisabled); + fail = false; + await f.Service.EnableAsync("", 0, "both", null); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.Equal(2, f.Service.GetStatus().AccountDisabledUserIds.Count); + } + + [Fact] + public async Task StateWriteFailureIsReportedBeforeRestoringPolicies() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", null); + var statePath = Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"); + File.Delete(statePath); + Directory.CreateDirectory(statePath); + await Assert.ThrowsAnyAsync(() => f.Service.DisableAsync()); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Service.GetStatus().IsActive); + Assert.Empty(Directory.GetFiles(f.Core.ConfigRoot, "maintenance-state.json.tmp.*")); + } + + [Fact] + public async Task CheckpointFailureAfterPolicyRestoreAbortsReconciliation() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + var statePath = Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((id, policy) => + { + f.Policies[id] = policy; + File.Delete(statePath); + Directory.CreateDirectory(statePath); + return Task.CompletedTask; + }); + await Assert.ThrowsAnyAsync(() => f.Service.EnableAsync("new", 0, "none", null)); + Assert.Contains(f.Alice.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); + Assert.Equal("both", f.Service.GetStatus().Action); + Assert.False(f.Service.GetStatus().IsActive); + } + + [Theory] + [InlineData("00:00", "08:00", true)][InlineData("22:00", "06:00", true)] + [InlineData(" 9:00 ", "10:00", true)][InlineData("24:00", "06:00", false)] + [InlineData("12:60", "06:00", false)][InlineData("06:00", "06:00", false)] + [InlineData(null, "06:00", false)][InlineData("bogus", "06:00", false)] + public void ScheduleParsingRejectsInvalidOrZeroLengthWindows(string? start, string? end, bool expected) + => Assert.Equal(expected, MaintenanceScheduleService.TryParseWindow(start, end, out _, out _)); + + [Theory] + [InlineData(21, 59, false, 0)][InlineData(22, 0, true, 0)][InlineData(23, 59, true, 0)] + [InlineData(0, 0, true, -1)][InlineData(5, 59, true, -1)][InlineData(6, 0, false, 0)] + public void OvernightWindowIncludesStartAndExcludesEnd(int hour, int minute, bool active, int startDay) + { + var now = new DateTime(2026, 10, 5, hour, minute, 0); + var result = MaintenanceScheduleService.CurrentWindow(TimeSpan.FromHours(22), TimeSpan.FromHours(6), now); + Assert.Equal(active, result.HasValue); + if (active) + { + Assert.Equal(now.Date.AddDays(startDay).AddHours(22), result!.Value.Start); + Assert.Equal(TimeSpan.FromHours(8), result.Value.End - result.Value.Start); + } + } + + [Theory] + [InlineData(-1, "0m")][InlineData(0, "0m")][InlineData(1, "1m")][InlineData(60, "1m")] + [InlineData(61, "2m")][InlineData(3900, "1h 05m")] + public void CountdownRoundsUpAndClampsExpiredWindows(int seconds, string expected) + => Assert.Equal(expected, MaintenanceModeService.FormatCountdown(TimeSpan.FromSeconds(seconds))); +} diff --git a/tests/backend/Core/CorePersistenceTests.cs b/tests/backend/Core/CorePersistenceTests.cs new file mode 100644 index 000000000..729744caa --- /dev/null +++ b/tests/backend/Core/CorePersistenceTests.cs @@ -0,0 +1,131 @@ +using Jellyfin.Plugin.JellyfinEnhanced; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Newtonsoft.Json; + +namespace JE.Tests; + +public class CorePersistenceTests +{ + private const string User = "abcdefabcdefabcdefabcdefabcdefab"; + public class Counter { public int Count { get; set; } } + + [Fact] + public void CanonicalIdentityPersistsAcrossManagersAndIsolatesUsers() + { + using var f = new CoreFixture(); + var id = Guid.Parse(User); + f.Manager.SaveUserConfiguration(id.ToString("D").ToUpperInvariant(), "settings.json", new UserSettings { DisplayLanguage = "日本語", AutoPauseEnabled = true }); + var reopened = new UserConfigurationManager(f.Paths.Object, f.Logger); + Assert.Equal("日本語", reopened.GetUserConfiguration(User, "settings.json").DisplayLanguage); + Assert.False(reopened.GetUserConfiguration(Guid.NewGuid().ToString("N"), "settings.json").AutoPauseEnabled); + Assert.Same(f.Manager.GetUserFileLock(User, "settings.json"), reopened.GetUserFileLock(id.ToString("D").ToUpperInvariant(), "settings.json")); + Assert.NotSame(f.Manager.GetUserFileLock(User, "settings.json"), reopened.GetUserFileLock(User, "other.json")); + } + + [Theory] + [InlineData("")][InlineData(" ")][InlineData("null")][InlineData("{broken")] + public void CorruptReadsAreLenientButStrictReadsQuarantineExactlyOnce(string content) + { + using var f = new CoreFixture(); + var path = f.Write(User, content); + Assert.Equal(5, f.Manager.GetUserConfiguration(User, "settings.json").PauseScreenDelaySeconds); + Assert.Equal(content, File.ReadAllText(path)); + Assert.ThrowsAny(() => f.Manager.GetUserConfigurationStrict(User, "settings.json")); + Assert.False(File.Exists(path)); + Assert.Equal(content, File.ReadAllText(Assert.Single(Directory.GetFiles(Path.GetDirectoryName(path)!, "*.corrupt-*")))); + Assert.Equal(5, f.Manager.GetUserConfigurationStrict(User, "settings.json").PauseScreenDelaySeconds); + } + + [Fact] + public void ReadOnlyStrictCheckPreservesCorruptionAndSchemaNullsPreserveOtherValues() + { + using var f = new CoreFixture(); + var path = f.Write(User, "broken"); + Assert.ThrowsAny(() => f.Manager.GetUserConfigurationStrict(User, "settings.json", false)); + Assert.Equal("broken", File.ReadAllText(path)); + f.Write(User, "{\"AutoPauseEnabled\":null,\"DisplayLanguage\":\"fr\"}"); + Assert.Equal("fr", f.Manager.GetUserConfigurationStrict(User, "settings.json").DisplayLanguage); + Assert.False(f.Manager.GetUserConfiguration(User, "settings.json").AutoPauseEnabled); + } + + [Theory] + [InlineData("../escape.json")][InlineData("/tmp/escape.json")][InlineData("a/b")][InlineData("a\\b")][InlineData("..")][InlineData("")] + public void InvalidFilenamesCannotReadOrWriteOutsideUserDirectory(string name) + { + using var f = new CoreFixture(); + Assert.Throws(() => f.Manager.SaveUserConfiguration(User, name, new Counter())); + Assert.Throws(() => f.Manager.GetUserConfiguration(User, name)); + Assert.False(f.Manager.UserConfigurationExists(User, name)); + } + + [Theory] + [InlineData("../outside")][InlineData("../../outside")][InlineData("/tmp/outside")][InlineData("")] + public void EscapingUserDirectoriesAreRejected(string user) + { + using var f = new CoreFixture(); + Assert.Throws(() => f.Manager.SaveUserConfiguration(user, "settings.json", new Counter())); + } + + [Fact] + public void ConcurrentRmwAcrossManagersLosesNoUpdatesAndLeavesNoTemporaryFiles() + { + using var f = new CoreFixture(); + var other = new UserConfigurationManager(f.Paths.Object, f.Logger); + Parallel.For(0, 100, i => (i % 2 == 0 ? f.Manager : other).RmwUserConfiguration(User, "settings.json", c => { c.Count++; return 1; })); + Assert.Equal(100, f.Manager.GetUserConfiguration(User, "settings.json").Count); + Assert.Empty(Directory.GetFiles(f.ConfigRoot, "*.tmp.*", SearchOption.AllDirectories)); + Assert.Equal(0, f.Manager.RmwUserConfiguration(User, "unused.json", c => 0)); + Assert.False(f.Manager.UserConfigurationExists(User, "unused.json")); + } + + [Fact] + public void SaveSupportsJsonElementAndSubscriberFailuresDoNotLoseData() + { + using var f = new CoreFixture(); + string? notified = null; + f.Manager.UserConfigurationSaved += (user, file) => { notified = user; throw new Exception("subscriber failure"); }; + using var document = System.Text.Json.JsonDocument.Parse("{\"Count\":42}"); + f.Manager.SaveUserConfiguration(Guid.Parse(User).ToString("D").ToUpperInvariant(), "settings.json", document.RootElement); + Assert.Equal(User, notified); + Assert.Equal(42, f.Manager.GetUserConfiguration(User, "settings.json").Count); + } + + [Fact] + public void RawReadDistinguishesMissingEmptyAndStoredContent() + { + using var f = new CoreFixture(); + Assert.Null(f.Manager.TryReadUserConfigurationText(User, "settings.json")); + f.Write(User, " \n"); + Assert.Equal("", f.Manager.TryReadUserConfigurationText(User, "settings.json")); + f.Write(User, "{\"Count\":9}"); + Assert.Equal("{\"Count\":9}", f.Manager.TryReadUserConfigurationText(User, "settings.json")); + } + + [Fact] + public void CaseVariantMigrationKeepsNewerSettingsAndForensicCopyAndIsIdempotent() + { + using var f = new CoreFixture(); + var canonical = f.Write(User, "{\"Count\":1}"); + var variant = f.Write(Guid.Parse(User).ToString("D").ToUpperInvariant(), "{\"Count\":2}"); + File.SetLastWriteTimeUtc(canonical, new DateTime(2020, 1, 1)); + File.SetLastWriteTimeUtc(variant, new DateTime(2021, 1, 1)); + var migrated = new UserConfigurationManager(f.Paths.Object, f.Logger); + Assert.Equal(2, migrated.GetUserConfiguration(User, "settings.json").Count); + Assert.Equal(new[] { User }, migrated.GetAllUserIds()); + var backups = Directory.GetDirectories(f.ConfigRoot, "*.migrated-*"); + Assert.Single(backups); + var again = new UserConfigurationManager(f.Paths.Object, f.Logger); + Assert.Equal(2, again.GetUserConfiguration(User, "settings.json").Count); + Assert.Equal(backups, Directory.GetDirectories(f.ConfigRoot, "*.migrated-*")); + } + + [Fact] + public void LoggerEscapesLineBreaksToPreventForgedEntries() + { + using var f = new CoreFixture(); + f.Logger.Warning("untrusted\r\n[INFO] forged"); + var lines = File.ReadAllLines(f.Logger.CurrentLogFilePath); + Assert.Single(lines); + Assert.Contains("untrusted\\r\\n[INFO] forged", lines[0]); + } +} diff --git a/tests/backend/Core/CoreServerConfigurationTests.cs b/tests/backend/Core/CoreServerConfigurationTests.cs new file mode 100644 index 000000000..301f2c9b9 --- /dev/null +++ b/tests/backend/Core/CoreServerConfigurationTests.cs @@ -0,0 +1,63 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; + +namespace JE.Tests; + +public class CoreServerConfigurationTests +{ + private static PluginConfiguration Legacy(string json) => new() + { + SonarrInstances = json, RadarrInstances = json, + SonarrUrl = "http://sonarr.local", SonarrApiKey = "sonarr-key", SonarrUrlMappings = "source|dest", + RadarrUrl = "http://radarr.local", RadarrApiKey = "radarr-key", RadarrUrlMappings = "source|dest" + }; + + [Theory] + [InlineData("")][InlineData(" ")][InlineData("[]")][InlineData("null")][InlineData("[null]")][InlineData("[{}]")] + public void EmptyOrIncompleteInstancesMigrateLegacyCredentials(string json) + { + var config = Legacy(json); + var sonarr = Assert.Single(config.GetSonarrInstances()); + var radarr = Assert.Single(config.GetRadarrInstances()); + Assert.Equal("http://sonarr.local", sonarr.Url); + Assert.Equal("sonarr-key", sonarr.ApiKey); + Assert.Equal("source|dest", sonarr.UrlMappings); + Assert.Equal("http://radarr.local", radarr.Url); + Assert.Equal("radarr-key", radarr.ApiKey); + Assert.False(config.IsSonarrInstancesCorrupt()); + Assert.False(config.IsRadarrInstancesCorrupt()); + } + + [Theory] + [InlineData("broken")][InlineData("{}")][InlineData("[]junk")][InlineData("[]\n{}")][InlineData("[42]")] + public void CorruptInstanceListsNeverReactivateLegacyCredentials(string json) + { + var config = Legacy(json); + Assert.Empty(config.GetSonarrInstances()); + Assert.Empty(config.GetRadarrInstances()); + Assert.True(config.IsSonarrInstancesCorrupt()); + Assert.True(config.IsRadarrInstancesCorrupt()); + Assert.Equal(json, config.SonarrInstances); + } + + [Fact] + public void ExplicitInstancesOverrideLegacyAndDisabledInstancesAreRetainedButNotUsed() + { + var config = Legacy("[{\"Name\":\"on\",\"Url\":\"http://on.local\",\"ApiKey\":\"key\",\"Enabled\":true},{\"Name\":\"off\",\"Url\":\"http://off.local\",\"ApiKey\":\"key\",\"Enabled\":false},null,{}]"); + Assert.Equal(2, config.GetSonarrInstances().Count); + Assert.Equal(2, config.GetRadarrInstances().Count); + Assert.Equal("on", Assert.Single(config.GetEnabledSonarrInstances()).Name); + Assert.Equal("on", Assert.Single(config.GetEnabledRadarrInstances()).Name); + Assert.DoesNotContain(config.GetSonarrInstances(), i => i.Url == config.SonarrUrl); + } + + [Theory] + [InlineData(null, "all")][InlineData("", "all")][InlineData("all", "all")] + [InlineData("[\"private-user-guid\"]", "selected")][InlineData("unexpected", "selected")] + public void PublicMaintenanceShapeNeverLeaksUserIdentity(string? raw, string expected) + => Assert.Equal(expected, PluginConfiguration.DeriveAffectedUsersShape(raw)); + + [Theory] + [InlineData("", "key", false)][InlineData("url", " ", false)][InlineData("url", "key", true)] + public void ShokoRequiresBothUrlAndKey(string url, string key, bool expected) + => Assert.Equal(expected, new PluginConfiguration { ShokoUrl = url, ShokoApiKey = key }.IsShokoConfigured()); +} diff --git a/tests/backend/Core/CoreSharedStoresTests.cs b/tests/backend/Core/CoreSharedStoresTests.cs new file mode 100644 index 000000000..8528ea0ea --- /dev/null +++ b/tests/backend/Core/CoreSharedStoresTests.cs @@ -0,0 +1,112 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Newtonsoft.Json; + +namespace JE.Tests; + +public class CoreSharedStoresTests +{ + [Fact] + public void ReviewUpdatesPreserveCreationAndIsolateUserAndMediaKeys() + { + using var f = new CoreFixture(); + var events = 0; + f.Manager.ReviewsChanged += () => events++; + Assert.Null(f.Manager.UpsertReview("a", "movie", "1", "first", 4, "2020")); + f.Manager.UpsertReview("b", "movie", "1", "other user", 2, "2020"); + f.Manager.UpsertReview("a", "tv", "1", "other media", 3, "2020"); + Assert.Equal(4, f.Manager.UpsertReview("a", "movie", "1", "edited", 5, "2021")); + var reviews = f.Manager.GetAllReviews().Reviews; + Assert.Equal(3, reviews.Count); + Assert.Equal("2020", reviews["a:movie:1"].CreatedAt); + Assert.Equal("2021", reviews["a:movie:1"].UpdatedAt); + Assert.Equal("edited", reviews["a:movie:1"].Content); + Assert.True(f.Manager.DeleteReview("a", "movie", "1", out var removed)); + Assert.Equal(5, removed); + Assert.False(f.Manager.DeleteReview("a", "movie", "1", out _)); + Assert.Equal(5, events); + Assert.Equal(2, f.Manager.GetAllReviews().Reviews.Count); + } + + [Theory] + [InlineData("")][InlineData("null")][InlineData("garbage")][InlineData("{}")][InlineData("{\"Reviews\":null}")][InlineData("{\"Reviews\":{\"a\":null}}")][InlineData("{\"Reviews\":{}} garbage")] + public void CorruptReviewsCannotBeOverwritten(string content) + { + using var f = new CoreFixture(); + var path = Path.Combine(f.ConfigRoot, "reviews.json"); + File.WriteAllText(path, content); + Assert.Empty(f.Manager.GetAllReviews().Reviews); + Assert.ThrowsAny(() => f.Manager.UpsertReview("a", "movie", "1", "new", 4, "2021")); + Assert.Equal(content, File.ReadAllText(path)); + Assert.Equal(content, File.ReadAllText(Assert.Single(Directory.GetFiles(f.ConfigRoot, "reviews.json.corrupt-*")))); + } + + [Fact] + public void ConcurrentReviewWritesPreserveEveryUser() + { + using var f = new CoreFixture(); + Parallel.For(0, 40, i => f.Manager.UpsertReview(i.ToString(), "movie", "1", "review", 4, "2020")); + Assert.Equal(40, f.Manager.GetAllReviews().Reviews.Count); + } + + [Fact] + public void WatchedActivityNeverDowngradesWhileFavoritesKeepOriginalTimestamp() + { + using var f = new CoreFixture(); + f.Manager.RecordActivity("u", "i", "Watched", "2020", true, 1); + f.Manager.RecordActivity("u", "i", "Watched", "2021", false, .2); + var watched = f.Manager.GetAllActivity().Entries["u:i:Watched"]; + Assert.True(watched.Completed); + Assert.Equal(1, watched.Progress); + Assert.Equal("2021", watched.OccurredAt); + f.Manager.RecordActivity("u", "i", "Favorited", "2020"); + f.Manager.RecordActivity("u", "i", "Favorited", "2021"); + Assert.Equal("2020", f.Manager.GetAllActivity().Entries["u:i:Favorited"].OccurredAt); + f.Manager.RemoveActivity("u", "i", "Favorited"); + f.Manager.RecordActivity("u", "i", "Favorited", "2022"); + Assert.Equal("2022", f.Manager.GetAllActivity().Entries["u:i:Favorited"].OccurredAt); + Assert.Equal(2, f.Manager.GetAllActivity().Entries.Count); + } + + [Theory] + [InlineData("")][InlineData("null")][InlineData("{}")][InlineData("{\"Entries\":null}")] + [InlineData("{\"Entries\":{\"a\":null}}")][InlineData("{\"Entries\":{}} garbage")] + public void CorruptActivityReadIsLenientButMutationsPreserveOriginal(string content) + { + using var f = new CoreFixture(); + var path = Path.Combine(f.ConfigRoot, "activity.json"); + File.WriteAllText(path, content); + Assert.Empty(f.Manager.GetAllActivity().Entries); + Assert.ThrowsAny(() => f.Manager.RecordActivity("u", "i", "Watched", "2020")); + Assert.ThrowsAny(() => f.Manager.RemoveActivity("u", "i", "Watched")); + Assert.Equal(content, File.ReadAllText(path)); + } + + [Fact] + public void ActivityRetentionDropsOldestAt500AndLeavesNoTemporaryFiles() + { + using var f = new CoreFixture(); + var store = new AllActivityStore(); + for (var i = 0; i < 500; i++) store.Entries[$"u:{i}:Watched"] = new ActivityEntry { OccurredAt = $"{i:D5}" }; + File.WriteAllText(Path.Combine(f.ConfigRoot, "activity.json"), JsonConvert.SerializeObject(store)); + f.Manager.RecordActivity("u", "new", "Watched", "99999"); + var result = f.Manager.GetAllActivity().Entries; + Assert.Equal(500, result.Count); + Assert.False(result.ContainsKey("u:0:Watched")); + Assert.True(result.ContainsKey("u:new:Watched")); + Assert.Empty(Directory.GetFiles(f.ConfigRoot, "*.tmp")); + } + + [Fact] + public void ProcessedWatchlistCleanupPersistsOnlyRecentRecordsAndIsUserIsolated() + { + using var f = new CoreFixture(); + var id = Guid.NewGuid(); + f.Manager.SaveProcessedWatchlistItems(id, new ProcessedWatchlistItems { Items = [ + new() { TmdbId = 1, ProcessedAt = DateTime.UtcNow.AddDays(-400) }, + new() { TmdbId = 2, ProcessedAt = DateTime.UtcNow.AddDays(-1) } + ] }); + f.Manager.CleanupOldProcessedWatchlistItems(id); + Assert.Equal(2, Assert.Single(f.Manager.GetProcessedWatchlistItems(id).Items).TmdbId); + Assert.Empty(f.Manager.GetProcessedWatchlistItems(Guid.NewGuid()).Items); + } +} diff --git a/tests/backend/Core/CoreUsageTests.cs b/tests/backend/Core/CoreUsageTests.cs new file mode 100644 index 000000000..d611de15e --- /dev/null +++ b/tests/backend/Core/CoreUsageTests.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class CoreUsageTests +{ + [Theory] + [InlineData(false, false)][InlineData(false, true)][InlineData(true, false)] + public void ConsentMustEnableAnalyticsAndUsageBeforeAnyDataIsCollected(bool analytics, bool usage) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AnalyticsEnabled = analytics; + f.Plugin.Configuration.AnalyticsShareUsageCounts = usage; + using (var service = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger)) + { + service.Increment("seerr.request_submitted"); + Assert.Empty(service.GetSnapshot().Counters); + } + Assert.False(File.Exists(Path.Combine(f.Core.ConfigRoot, "usage-counters.json"))); + } + + [Fact] + public void ResetSubtractsOnlyAcknowledgedSnapshotAndRestartPreservesConcurrentIncrements() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AnalyticsEnabled = true; + f.Plugin.Configuration.AnalyticsShareUsageCounts = true; + using (var service = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger)) + { + Parallel.For(0, 100, _ => service.Increment("seerr.request_submitted")); + var sent = service.GetSnapshot(); + service.Increment("seerr.request_submitted"); + Assert.Equal(100, sent.Counters["seerr.request_submitted"]); + service.ResetForNewPeriod("2026-10-05", sent.Counters); + Assert.Equal(1, service.GetSnapshot().Counters["seerr.request_submitted"]); + } + using var restarted = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + Assert.Equal("2026-10-05", restarted.GetSnapshot().PeriodStart); + Assert.Equal(1, restarted.GetSnapshot().Counters["seerr.request_submitted"]); + restarted.ResetForNewPeriod("2026-10-06"); + Assert.Empty(restarted.GetSnapshot().Counters); + } + + [Fact] + public void PersistedUnknownAndNonpositiveCountersAreFiltered() + { + using var f = new CoreFixture(); + File.WriteAllText(Path.Combine(f.ConfigRoot, "usage-counters.json"), "{\"SchemaVersion\":1,\"PeriodStart\":\"2026-10-01\",\"Counters\":{\"seerr.request_submitted\":3,\"continue_watching.auto_removed\":-1,\"total.users\":9999,\"private-user-id\":5}}"); + using var service = new UsageEventCounterService(f.Paths.Object, f.Logger); + Assert.Equal(3, Assert.Single(service.GetSnapshot().Counters).Value); + } + + [Theory] + [InlineData("broken")][InlineData("null")][InlineData("{\"SchemaVersion\":2}")] + [InlineData("{\"SchemaVersion\":1,\"Counters\":null}")] + public void CorruptOrUnsupportedDiskStateCannotPreventStartup(string content) + { + using var f = new CoreFixture(); + File.WriteAllText(Path.Combine(f.ConfigRoot, "usage-counters.json"), content); + using var service = new UsageEventCounterService(f.Paths.Object, f.Logger); + Assert.Empty(service.GetSnapshot().Counters); + } + + [Fact] + public void PeriodUsesGregorianCalendarUnderNonGregorianCulture() + { + using var f = new CoreFixture(); + var old = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("th-TH"); + using var service = new UsageEventCounterService(f.Paths.Object, f.Logger); + Assert.Equal(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), service.GetSnapshot().PeriodStart); + } + finally { CultureInfo.CurrentCulture = old; } + } + + [Theory] + [InlineData("secret.user")][InlineData("total.users")][InlineData("private-user-id")] + public void UnknownKeysCannotEnterPublicUsageSnapshot(string key) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AnalyticsEnabled = true; + f.Plugin.Configuration.AnalyticsShareUsageCounts = true; + using var service = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + service.Increment(key); + Assert.Empty(service.GetSnapshot().Counters); + } +} diff --git a/tests/backend/Core/CoreWhatsNewTests.cs b/tests/backend/Core/CoreWhatsNewTests.cs new file mode 100644 index 000000000..63a1f1972 --- /dev/null +++ b/tests/backend/Core/CoreWhatsNewTests.cs @@ -0,0 +1,62 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Newtonsoft.Json; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class CoreWhatsNewTests +{ + [Fact] + public void FreshInstallAcknowledgesCurrentSchemaWithoutNewBanner() + { + using var f = new ApiPluginFixture(); + var service = new WhatsNewService(f.Core.Paths.Object, f.Core.Logger); + service.CheckForNewSettings(); + Assert.Null(service.GetState()); + var path = Path.Combine(f.Core.ConfigRoot, "config-schema-snapshot.json"); + var baseline = JsonConvert.DeserializeObject(File.ReadAllText(path))!; + Assert.NotEmpty(baseline.SettingIds); + Assert.Equal(f.Plugin.Version.ToString(), baseline.PluginVersion); + Assert.Equal(baseline.SettingIds.Count, baseline.SettingIds.Distinct().Count()); + } + + [Fact] + public void StartupPreservesAcknowledgedBaselineAndFirstSeenVersionUntilDismissal() + { + using var f = new ApiPluginFixture(); + var service = new WhatsNewService(f.Core.Paths.Object, f.Core.Logger); + service.Dismiss(); + var path = Path.Combine(f.Core.ConfigRoot, "config-schema-snapshot.json"); + var baseline = JsonConvert.DeserializeObject(File.ReadAllText(path))!; + var missing = baseline.SettingIds[0]; + baseline.SettingIds.RemoveAt(0); + baseline.PluginVersion = "1.0.0.0"; + File.WriteAllText(path, JsonConvert.SerializeObject(baseline)); + service.CheckForNewSettings(); + Assert.Contains(missing, service.GetState()!.NewSettings.Keys); + var state = service.GetState()!; + state.NewSettings[missing] = "2.0.0.0"; + File.WriteAllText(Path.Combine(f.Core.ConfigRoot, "whats-new.json"), JsonConvert.SerializeObject(state)); + service.CheckForNewSettings(); + Assert.Equal("2.0.0.0", service.GetState()!.NewSettings[missing]); + Assert.DoesNotContain(missing, JsonConvert.DeserializeObject(File.ReadAllText(path))!.SettingIds); + service.Dismiss(); + Assert.Null(service.GetState()); + service.CheckForNewSettings(); + Assert.Null(service.GetState()); + Assert.Contains(missing, JsonConvert.DeserializeObject(File.ReadAllText(path))!.SettingIds); + } + + [Theory] + [InlineData("")][InlineData("null")][InlineData("not json")] + public void CorruptNotificationStateDoesNotPreventStartup(string content) + { + using var f = new ApiPluginFixture(); + File.WriteAllText(Path.Combine(f.Core.ConfigRoot, "whats-new.json"), content); + var service = new WhatsNewService(f.Core.Paths.Object, f.Core.Logger); + Assert.Null(service.GetState()); + service.CheckForNewSettings(); + Assert.True(File.Exists(Path.Combine(f.Core.ConfigRoot, "config-schema-snapshot.json"))); + } +} diff --git a/tests/backend/Integrations/IntegrationAnalyticsTests.cs b/tests/backend/Integrations/IntegrationAnalyticsTests.cs new file mode 100644 index 000000000..8af03bb39 --- /dev/null +++ b/tests/backend/Integrations/IntegrationAnalyticsTests.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class IntegrationAnalyticsTests +{ + private static AnalyticsReportingService Service(ApiPluginFixture f, UsageEventCounterService counters, IHttpClientFactory transport) + { + var host = new Mock(); + host.SetupGet(h => h.ApplicationVersionString).Returns("12.0.0-test"); + return new(transport, f.Core.Paths.Object, host.Object, counters, f.Core.Manager, f.Core.Logger); + } + + [Theory] + [InlineData("none", 1, 7, false)] + [InlineData("plugin", 1, 7, true)] + [InlineData("target", 1, 7, true)] + [InlineData("host", 1, 7, true)] + [InlineData("none", 8, 1, true)] + [InlineData("none", 2, 1, false)] + [InlineData("none", 31, 99, true)] + [InlineData("none", 29, 99, false)] + public async Task ReportingCadenceDetectsEnvironmentChangesAndClampsInterval(string changed, int ageDays, int interval, bool expectedSend) + { + using var f = new ApiPluginFixture(); + var c = f.Plugin.Configuration; + c.AnalyticsEnabled = true; + c.AnalyticsInstallId = "server-id"; c.AnalyticsInstallSecret = "server-secret"; + c.AnalyticsLastReportedAt = DateTimeOffset.UtcNow.AddDays(-ageDays).ToUnixTimeMilliseconds(); + c.AnalyticsLastReportedPluginVersion = changed == "plugin" ? "older" : f.Plugin.Version.ToString(); + c.AnalyticsLastReportedJellyfinTarget = changed == "target" ? "older-target" : HostCompatibilityService.BuiltFor; + c.AnalyticsLastReportedJellyfinVersion = changed == "host" ? "older-host" : "12.0.0-test"; + c.AnalyticsReportIntervalDays = interval; + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response())); + await Service(f, counters, transport).ReportIfDueAsync(default); + Assert.Equal(expectedSend ? 1 : 0, transport.Calls); + if (expectedSend) + { + Assert.Equal(f.Plugin.Version.ToString(), c.AnalyticsLastReportedPluginVersion); + Assert.Equal(HostCompatibilityService.BuiltFor, c.AnalyticsLastReportedJellyfinTarget); + Assert.Equal("12.0.0-test", c.AnalyticsLastReportedJellyfinVersion); + } + } + + [Theory] + [InlineData(200)][InlineData(403)][InlineData(429)][InlineData(500)] + public async Task OnlyAcknowledgedReportAdvancesCheckpointAndSubtractsExactlySentCounters(int status) + { + using var f = new ApiPluginFixture(); + var c = f.Plugin.Configuration; + c.AnalyticsEnabled = true; c.AnalyticsShareUsageCounts = true; + c.AnalyticsInstallId = "server-id"; c.AnalyticsInstallSecret = "server-secret"; + c.AnalyticsLastReportedAt = 123; + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + counters.Increment("seerr.request_submitted"); + using var transport = new IntegrationTransport(async (request, _) => + { + using var payload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()); + var sent = payload.RootElement.GetProperty("p_events").EnumerateArray().Single(e => e.GetProperty("key").GetString() == "seerr.request_submitted"); + Assert.Equal(1, sent.GetProperty("count").GetInt32()); + counters.Increment("seerr.request_submitted"); // an action completed while HTTP was in flight + return IntegrationTransport.Response("{}", status); + }); + await Service(f, counters, transport).ForceSendAsync(default); + Assert.Equal(status == 200 ? 1 : 2, counters.GetSnapshot().Counters["seerr.request_submitted"]); + if (status == 200) Assert.True(c.AnalyticsLastReportedAt > 123); + else Assert.Equal(123, c.AnalyticsLastReportedAt); + Assert.Equal("server-id", c.AnalyticsInstallId); + } + + [Theory] + [InlineData(false, false, false)][InlineData(true, false, false)] + [InlineData(false, true, false)][InlineData(false, false, true)] + [InlineData(true, true, true)] + public void PayloadHonorsIndependentPrivacyCategoriesWithoutSecretsOrUserIds(bool flags, bool usage, bool sizes) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.AnalyticsEnabled = true; config.AnalyticsShareUsageCounts = true; + config.AnalyticsInstallId = "random-install-id"; + config.AnalyticsInstallSecret = "private-install-secret"; + config.TMDB_API_KEY = "private-tmdb-key"; + config.MdblistApiKey = "private-mdblist-key"; + config.ShokoUrl = "http://private-shoko.internal"; + var userId = Guid.NewGuid().ToString(); + config.MaintenanceModeAffectedUsers = "[\"" + userId + "\"]"; + config.LanguageTagsPriority = "English,ja,zh-Hans-CN,private@email.invalid"; + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + counters.Increment("seerr.request_submitted"); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("Building previews must not make HTTP calls")); + var payload = Service(f, counters, transport).BuildPayload(config, flags, usage, sizes); + Assert.Equal(flags, payload.Config != null); + Assert.Equal(flags, payload.Settings != null); + Assert.Equal(usage, payload.Events != null); + Assert.Equal(sizes, payload.DataFileSizes != null); + Assert.Equal("random-install-id", payload.InstallId); + if (flags) + { + Assert.True(payload.Config!["TmdbEnabled"]); + Assert.True(payload.Config["MdblistEnabled"]); + Assert.Equal("ja,zh-hans-cn", payload.Settings!["LanguageTagsPriority"]); + Assert.DoesNotContain(payload.Config.Keys, k => k.StartsWith("Analytics", StringComparison.Ordinal)); + } + if (usage) Assert.Equal(1, Assert.Single(payload.Events!, e => e.Key == "seerr.request_submitted").Count); + var serialized = JsonSerializer.Serialize(payload); + foreach (var secret in new[] { "private-install-secret", "private-tmdb-key", "private-mdblist-key", "private-shoko.internal", userId, "private@email.invalid" }) Assert.DoesNotContain(secret, serialized); + Assert.Equal(0, transport.Calls); + } + + [Theory] + [InlineData("[]")][InlineData("null")][InlineData("{}")][InlineData("{\"install_id\":1,\"secret\":\"secret\"}")][InlineData("{")] + public async Task MalformedRegistrationCannotReplaceIdentity(string body) + { + using var f = new ApiPluginFixture(); + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response(body))); + await Service(f, counters, transport).EnsureRegisteredAsync(f.Plugin.Configuration, default); + Assert.True(string.IsNullOrEmpty(f.Plugin.Configuration.AnalyticsInstallId)); + Assert.True(string.IsNullOrEmpty(f.Plugin.Configuration.AnalyticsInstallSecret)); + } + + [Fact] + public async Task RegistrationUsesServerIssuedIdentityAndSubsequentCallsAreIdempotent() + { + using var f = new ApiPluginFixture(); + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + using var transport = new IntegrationTransport(async (request, _) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.EndsWith("/register_install", request.RequestUri!.AbsolutePath); + Assert.Equal("{}", await request.Content!.ReadAsStringAsync()); + return IntegrationTransport.Response("[{\"install_id\":\"server-id\",\"secret\":\"server-secret\"}]"); + }); + var service = Service(f, counters, transport); + await service.EnsureRegisteredAsync(f.Plugin.Configuration, default); + await service.EnsureRegisteredAsync(f.Plugin.Configuration, default); + Assert.Equal("server-id", f.Plugin.Configuration.AnalyticsInstallId); + Assert.Equal("server-secret", f.Plugin.Configuration.AnalyticsInstallSecret); + Assert.Equal(1, transport.Calls); + Assert.DoesNotContain("server-secret", JsonSerializer.Serialize(service.BuildPayload(f.Plugin.Configuration, false, false, false))); + } +} diff --git a/tests/backend/Integrations/IntegrationAwardsTests.cs b/tests/backend/Integrations/IntegrationAwardsTests.cs new file mode 100644 index 000000000..02b95c53e --- /dev/null +++ b/tests/backend/Integrations/IntegrationAwardsTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class IntegrationAwardsTests +{ + private static object Row(string award, string result, string detail, int year) => new { awardLabel = new { value = award }, result = new { value = result }, personLabel = new { value = detail }, workLabel = new { value = detail }, year = new { value = year.ToString() } }; + private static string Body(params object[] rows) => JsonSerializer.Serialize(new { results = new { bindings = rows } }); + + [Theory] + [InlineData("movie", "P4947")][InlineData("tv", "P4983")][InlineData("person", "P4985")] + public async Task AwardsGroupRecipientsMergeNominationsIntoWinsAndDiscardUnlabelledEntities(string type, string property) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((request, _) => + { + Assert.Contains("wdt:" + property, Uri.UnescapeDataString(request.RequestUri!.Query)); + Assert.NotEmpty(request.Headers.UserAgent); + Assert.Equal("application/sparql-results+json", Assert.Single(request.Headers.Accept).MediaType); + return Task.FromResult(IntegrationTransport.Response(Body(Row("Best Picture", "Won", "Alice", 2025), Row("Best Picture", "Won", "Alice", 2025), Row("Best Picture", "Nominated", "Bob", 2025), Row("Other", "Nominated", "Q123", 2024), Row("Q1234", "Won", "Ignored", 2023)))); + }); + using var service = new WikidataAwardsService(transport, env.Paths.Object, env.Logger); + var result = await service.GetAwardsAsync(type, "1", default); + Assert.True(result.Found); Assert.True(result.Confirmed); + Assert.Equal(1, result.Wins); Assert.Equal(1, result.Nominations); + Assert.Equal("Best Picture", result.Awards[0].Name); + Assert.Equal(new[] { "Alice", "Bob" }, type == "person" ? result.Awards[0].Works : result.Awards[0].Recipients); + Assert.Empty(type == "person" ? result.Awards[0].Recipients : result.Awards[0].Works); + Assert.Empty(result.Awards[1].Recipients); Assert.Empty(result.Awards[1].Works); + Assert.Same(result, await service.GetAwardsAsync(type, "1", default)); + Assert.Equal(1, transport.Calls); + } + + [Theory] + [InlineData(200, "{\"results\":{\"bindings\":[]}}", true)] + [InlineData(429, "{}", false)][InlineData(503, "{}", false)] + [InlineData(200, "{", false)][InlineData(200, "{}", false)] + public async Task FailuresAreUnconfirmedButValidEmptyResponseIsConfirmed(int status, string body, bool confirmed) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response(body, status))); + using var service = new WikidataAwardsService(transport, env.Paths.Object, env.Logger); + var result = await service.GetAwardsAsync("movie", "1", default); + Assert.False(result.Found); Assert.Equal(confirmed, result.Confirmed); + Assert.Same(result, await service.GetAwardsAsync("movie", "1", default)); + Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task ConcurrentLookupsShareFetchAndResultsSurviveRestartWithMediaIsolation() + { + using var env = new IntegrationEnvironment(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var transport = new IntegrationTransport(async (_, ct) => { await release.Task.WaitAsync(ct); return IntegrationTransport.Response(Body(Row("Award", "Won", "A", 2025))); }); + using (var service = new WikidataAwardsService(transport, env.Paths.Object, env.Logger)) + { + var pending = Enumerable.Range(0, 15).Select(_ => service.GetAwardsAsync("movie", "1", default)).ToArray(); + release.SetResult(); + Assert.All(await Task.WhenAll(pending), r => Assert.Equal(1, r.Wins)); + Assert.Equal(1, transport.Calls); + } + using var restarted = new WikidataAwardsService(transport, env.Paths.Object, env.Logger); + Assert.Equal(1, (await restarted.GetAwardsAsync("movie", "1", default)).Wins); + Assert.Equal(1, transport.Calls); + await restarted.GetAwardsAsync("tv", "1", default); + Assert.Equal(2, transport.Calls); + } + + [Theory] + [InlineData(true, true, 179, false)][InlineData(true, true, 181, true)] + [InlineData(false, true, 29, false)][InlineData(false, true, 31, true)] + [InlineData(false, false, 0.02, false)][InlineData(false, false, 0.05, true)] + public async Task PersistedCacheAppliesDifferentPositiveNegativeAndRetryLifetimes(bool found, bool confirmed, double days, bool fetch) + { + using var env = new IntegrationEnvironment(); + var path = Path.Combine(env.Root, "configurations/Jellyfin.Plugin.JellyfinEnhanced/awards.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(new AwardsCacheDiskFormat { SchemaVersion = 1, Items = new() { ["movie-1"] = new() { Found = found, Confirmed = confirmed, FetchedAtUnixMs = DateTimeOffset.UtcNow.AddDays(-days).ToUnixTimeMilliseconds() } } })); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response(Body()))); + using var service = new WikidataAwardsService(transport, env.Paths.Object, env.Logger); + await service.GetAwardsAsync("movie", "1", default); + Assert.Equal(fetch ? 1 : 0, transport.Calls); + } +} diff --git a/tests/backend/Integrations/IntegrationCalendarTests.cs b/tests/backend/Integrations/IntegrationCalendarTests.cs new file mode 100644 index 000000000..f3d4bcc46 --- /dev/null +++ b/tests/backend/Integrations/IntegrationCalendarTests.cs @@ -0,0 +1,167 @@ +using System.Security.Claims; +using System.Text.Json; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Plugin.JellyfinEnhanced.Model.Arr; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class IntegrationCalendarTests +{ + private sealed class CalendarDatabase : IDbContextFactory + { + private readonly DbContextOptions options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + public JellyfinDbContext CreateDbContext() + { + var locking = new Mock(); + locking.Setup(l => l.OnSaveChanges(It.IsAny(), It.IsAny())).Callback((_, save) => save()); + return new JellyfinDbContext(options, NullLogger.Instance, Mock.Of(), locking.Object); + } + public Task CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext()); + } + private static (List Events, JsonElement Errors) Unpack(IActionResult result) + { + var root = JsonSerializer.SerializeToElement(Assert.IsType(result).Value); + return (JsonSerializer.Deserialize>(root.GetProperty("events"))!, root.GetProperty("errors")); + } + private static void Configure(ApiPluginFixture f) + { + f.Plugin.Configuration.ShokoUrl = "http://127.0.0.1/shoko"; + f.Plugin.Configuration.ShokoApiKey = "shoko-secret"; + f.Plugin.Configuration.CalendarFilterByLibraryAccess = false; + } + private static string Episode(string type, int number, int id) => JsonSerializer.Serialize(new { Type = type, AirDate = "2026-10-10T12:00:00Z", SeriesTitle = "Same anime", Title = "Episode " + number, Number = number, IDs = new { ShokoSeries = 7, ShokoEpisode = id } }); + + [Theory] + [InlineData("Episode")][InlineData("Special")][InlineData("Credits")][InlineData("Trailer")][InlineData("Parody")][InlineData("Other")] + public async Task EachEpisodeTypeToggleIncludesOnlyItsTypeAndExcludesRestrictedUpstreamEntries(string type) + { + using var f = new ApiPluginFixture(); Configure(f); + var c = f.Plugin.Configuration; + c.ShokoShowEpisodes = type == "Episode"; c.ShokoShowSpecials = type == "Special"; + c.ShokoShowCredits = type == "Credits"; c.ShokoShowTrailers = type == "Trailer"; + c.ShokoShowParodies = type == "Parody"; c.ShokoShowOther = type == "Other"; + using var transport = new IntegrationTransport((request, _) => + { + Assert.Equal("shoko-secret", Assert.Single(request.Headers.GetValues("apikey"))); + Assert.False(request.Headers.Contains("X-Api-Key")); + Assert.Contains("includeMissing=false&includeRestricted=false", request.RequestUri!.Query); + Assert.Contains("startDate=2026-10-01&endDate=2026-11-01", request.RequestUri.Query); + return Task.FromResult(IntegrationTransport.Response("[" + string.Join(",", new[] { "Episode", "Special", "Credits", "Trailer", "Parody", "Other" }.Select((t, i) => Episode(t, i, i + 1))) + "]")); + }); + var controller = ApiAssetTests.Controller(f.Core, transport, new CalendarDatabase()); + controller.Request.QueryString = new QueryString("?start=2026-10-01&end=2026-11-01"); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal(type, Assert.Single(result.Events).Subtitle!.Split(' ')[0]); + Assert.Equal(0, result.Errors.GetArrayLength()); + Assert.Null(result.Events[0].ItemId); + Assert.Null(result.Events[0].PosterUrl); + } + + [Theory] + [InlineData(401, "{}", "authentication failed")] + [InlineData(500, "{}", "HTTP 500")] + [InlineData(200, "{", "invalid response")] + public async Task BrokenShokoPreservesOtherProviderEvents(int status, string body, string error) + { + using var f = new ApiPluginFixture(); Configure(f); + f.Plugin.Configuration.SonarrUrl = "http://127.0.0.1/sonarr"; + f.Plugin.Configuration.SonarrApiKey = "arr-secret"; + using var transport = new IntegrationTransport((request, _) => Task.FromResult(request.RequestUri!.AbsolutePath.Contains("/shoko/") ? IntegrationTransport.Response(body, status) : IntegrationTransport.Response("[{\"airDateUtc\":\"2026-10-10\",\"title\":\"Episode\",\"series\":{\"title\":\"Unaffected series\"}}]"))); + var controller = ApiAssetTests.Controller(f.Core, transport); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal("Sonarr", Assert.Single(result.Events).Source); + Assert.Equal("Shoko", result.Errors[0].GetProperty("source").GetString()); + Assert.Contains(error, result.Errors[0].GetProperty("reason").GetString()); + } + + [Fact] + public async Task ShokofinMappingResolvesSeriesAndEpisodeAndRetainsSonarrDuplicate() + { + using var f = new ApiPluginFixture(); Configure(f); + f.Plugin.Configuration.SonarrUrl = "http://127.0.0.1/sonarr"; + f.Plugin.Configuration.SonarrApiKey = "arr-secret"; + var database = new CalendarDatabase(); + var seriesId = Guid.NewGuid(); var episodeId = Guid.NewGuid(); + using (var db = database.CreateDbContext()) + { + db.BaseItemProviders.AddRange(new BaseItemProvider { Item = null!, ItemId = seriesId, ProviderId = "Shoko Series", ProviderValue = "7" }, new BaseItemProvider { Item = null!, ItemId = episodeId, ProviderId = "Shoko Episode", ProviderValue = "42" }); + db.SaveChanges(); + } + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response(request.RequestUri!.AbsolutePath.Contains("/shoko/") ? "[" + Episode("Episode", 1, 42) + "]" : "[{\"airDateUtc\":\"2026-10-10T12:00:00Z\",\"title\":\"Episode 1\",\"episodeNumber\":1,\"series\":{\"title\":\"Same anime\"}}]"))); + var controller = ApiAssetTests.Controller(f.Core, transport, database); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal(2, result.Events.Count); + var shoko = Assert.Single(result.Events, e => e.Source == "Shoko"); + Assert.Equal(seriesId, shoko.ItemId); + Assert.Equal(episodeId, shoko.ItemEpisodeId); + Assert.Single(result.Events, e => e.Source == "Sonarr"); + } + + [Theory] + [InlineData(true)][InlineData(false)] + public async Task LibraryAccessFilterHidesMappedAnimeFromRestrictedUser(bool allowed) + { + using var f = new ApiPluginFixture(); Configure(f); + f.Plugin.Configuration.CalendarFilterByLibraryAccess = true; + var database = new CalendarDatabase(); + var seriesId = Guid.NewGuid(); + using (var db = database.CreateDbContext()) + { + db.BaseItemProviders.Add(new BaseItemProvider { Item = null!, ItemId = seriesId, ProviderId = "Shoko Series", ProviderValue = "7" }); + db.SaveChanges(); + } + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var users = new Mock(); + users.Setup(u => u.GetUserById(user.Id)).Returns(user); + var library = new Mock(); + library.Setup(l => l.GetItemById(seriesId, user)).Returns(allowed ? new MediaBrowser.Controller.Entities.TV.Series { Id = seriesId } : null); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response("[" + Episode("Episode", 1, 42) + "]"))); + var controller = ApiAssetTests.Controller(f.Core, transport, database, users.Object, library.Object); + controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("Jellyfin-UserId", user.Id.ToString())], "test")); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal(allowed ? 1 : 0, result.Events.Count); + } + + [Fact] + public async Task ThreeSourcesMergeAndMalformedDatesAreSkipped() + { + using var f = new ApiPluginFixture(); Configure(f); + f.Plugin.Configuration.SonarrUrl = "http://127.0.0.1/sonarr"; + f.Plugin.Configuration.SonarrApiKey = "arr-secret"; + f.Plugin.Configuration.RadarrUrl = "http://127.0.0.1/radarr"; + f.Plugin.Configuration.RadarrApiKey = "arr-secret"; + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response( + request.RequestUri!.AbsolutePath.Contains("/shoko/") ? "[" + Episode("Episode", 1, 42) + ",{\"Type\":\"Episode\",\"AirDate\":\"invalid\"}]" : + request.RequestUri.AbsolutePath.Contains("/radarr/") ? "[{\"id\":1,\"title\":\"Movie\",\"digitalRelease\":\"2026-10-10\"}]" : + "[{\"airDateUtc\":\"2026-10-10\",\"series\":{\"title\":\"TV\"}}]"))); + var controller = ApiAssetTests.Controller(f.Core, transport, new CalendarDatabase()); + controller.Request.QueryString = new QueryString("?start=2026-10-01&end=2026-11-01"); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal(new[] { "Radarr", "Shoko", "Sonarr" }, result.Events.Select(e => e.Source).OrderBy(x => x)); + Assert.Equal(0, result.Errors.GetArrayLength()); + Assert.Equal(3, transport.Calls); + } + + [Theory] + [InlineData(true)][InlineData(false)] + public async Task PartialConfigurationReturnsErrorWithoutSendingRequest(bool urlOnly) + { + using var f = new ApiPluginFixture(); Configure(f); + if (urlOnly) f.Plugin.Configuration.ShokoApiKey = ""; else f.Plugin.Configuration.ShokoUrl = ""; + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("Must not fetch partial configuration")); + var result = Unpack(await ApiAssetTests.Controller(f.Core, transport).GetCalendarEvents()); + Assert.Empty(result.Events); + Assert.Equal(1, result.Errors.GetArrayLength()); + Assert.Equal(0, transport.Calls); + } +} diff --git a/tests/backend/Integrations/IntegrationCompanyDiscoverTests.cs b/tests/backend/Integrations/IntegrationCompanyDiscoverTests.cs new file mode 100644 index 000000000..0de90c6f8 --- /dev/null +++ b/tests/backend/Integrations/IntegrationCompanyDiscoverTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class IntegrationCompanyDiscoverTests +{ + [Fact] + public void PaginationAndTvFieldsAreTranslatedWhileOnlyVisibleLibraryRowsReceiveMediaInfo() + { + const string input = "{\"page\":3,\"total_pages\":9,\"total_results\":173,\"results\":[{\"id\":1,\"name\":\"日本語\",\"original_name\":\"Original\",\"poster_path\":\"/p.jpg\",\"vote_average\":7.5,\"genre_ids\":[18]},{\"id\":2,\"name\":\"Other\"},{\"id\":0},{\"id\":\"3\"},null]}"; + Assert.Equal(new[] { 1, 2 }, TmdbCompanyTvDiscover.ReadRowIds(input)); + const string partial = "{\"status\":4,\"seasons\":[{\"seasonNumber\":1,\"status\":5}]}"; + using var doc = JsonDocument.Parse(TmdbCompanyTvDiscover.ToSeerrShape(input, new Dictionary { [1] = partial })); + var root = doc.RootElement; + Assert.Equal(3, root.GetProperty("page").GetInt32()); + Assert.Equal(9, root.GetProperty("totalPages").GetInt32()); + Assert.Equal(173, root.GetProperty("totalResults").GetInt32()); + var rows = root.GetProperty("results"); Assert.Equal(2, rows.GetArrayLength()); + Assert.Equal("tv", rows[0].GetProperty("mediaType").GetString()); + Assert.Equal("Original", rows[0].GetProperty("originalName").GetString()); + Assert.Equal(7.5, rows[0].GetProperty("voteAverage").GetDouble()); + Assert.Equal(4, rows[0].GetProperty("mediaInfo").GetProperty("status").GetInt32()); + Assert.False(rows[1].TryGetProperty("mediaInfo", out _)); + Assert.False(rows[0].TryGetProperty("original_name", out _)); + } + + [Fact] + public void EmptyPageUsesDocumentedDefaultsAndLibraryFallbackLinksCorrectItem() + { + using var result = JsonDocument.Parse(TmdbCompanyTvDiscover.ToSeerrShape("{}", new Dictionary())); + Assert.Equal(1, result.RootElement.GetProperty("page").GetInt32()); + Assert.Equal(1, result.RootElement.GetProperty("totalPages").GetInt32()); + Assert.Equal(0, result.RootElement.GetProperty("totalResults").GetInt32()); + Assert.Equal(0, result.RootElement.GetProperty("results").GetArrayLength()); + var id = Guid.NewGuid(); + using var info = JsonDocument.Parse(TmdbCompanyTvDiscover.LibraryMediaInfo(id)); + Assert.Equal(5, info.RootElement.GetProperty("status").GetInt32()); + Assert.Equal(id.ToString("N"), info.RootElement.GetProperty("jellyfinMediaId").GetString()); + } + + [Theory] + [InlineData("{")][InlineData("null")][InlineData("[]")][InlineData("{}")] [InlineData("{\"mediaInfo\":null}")] + public void InvalidOrAbsentSeerrDetailDoesNotInventAvailability(string json) => Assert.Null(TmdbCompanyTvDiscover.ExtractMediaInfo(json)); + + [Fact] + public void MalformedTmdbJsonIsSurfacedToCallerInsteadOfInventingEmptySuccess() + { + Assert.ThrowsAny(() => TmdbCompanyTvDiscover.ReadRowIds("{")); + Assert.ThrowsAny(() => TmdbCompanyTvDiscover.ToSeerrShape("{", new Dictionary())); + } + + [Theory] + [InlineData("popularity.desc", true)][InlineData("first_air_date.asc", true)][InlineData("vote_count.desc", true)] + [InlineData("name.asc", true)][InlineData("original_name.desc", true)][InlineData("popularity.desc&include_adult=true", false)] + [InlineData("popularity.desc\n", false)][InlineData(null, false)] + public void SortAllowlistRejectsInjectedQueryOrUnknownSort(string? value, bool allowed) => Assert.Equal(allowed, TmdbCompanyTvDiscover.IsValidSort(value)); +} diff --git a/tests/backend/Integrations/IntegrationHttpTests.cs b/tests/backend/Integrations/IntegrationHttpTests.cs new file mode 100644 index 000000000..2204cd5c1 --- /dev/null +++ b/tests/backend/Integrations/IntegrationHttpTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers.Jellyseerr; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Jellyfin.Plugin.JellyfinEnhanced.Model; + +namespace JE.Tests; + +public class IntegrationSeerrTests +{ + [Fact] + public async Task RequestCarriesIdentityAndJsonWithoutPuttingSecretsInUrl() + { + using var request = SeerrHttpHelper.BuildRequest(HttpMethod.Post, "http://127.0.0.1/api/v1/request", "secret", "27", "{\"mediaId\":12}"); + Assert.Equal("secret", Assert.Single(request.Headers.GetValues("X-Api-Key"))); + Assert.Equal("27", Assert.Single(request.Headers.GetValues("X-Api-User"))); + Assert.Equal("application/json", request.Content!.Headers.ContentType!.MediaType); + Assert.Equal("{\"mediaId\":12}", await request.Content.ReadAsStringAsync()); + Assert.DoesNotContain("secret", request.RequestUri!.ToString()); + Assert.NotEmpty(request.Headers.UserAgent); + } + + [Theory] + [InlineData(200, "application/json", null)] + [InlineData(200, "application/problem+json", null)] + [InlineData(200, "text/html", SeerrErrorCode.HtmlResponse)] + [InlineData(401, "application/json", SeerrErrorCode.Unauthorized)] + [InlineData(403, "application/json", SeerrErrorCode.Forbidden)] + [InlineData(429, "application/json", SeerrErrorCode.UpstreamError)] + [InlineData(500, "application/json", SeerrErrorCode.UpstreamError)] + [InlineData(520, "text/html", SeerrErrorCode.Cloudflare5xx)] + [InlineData(530, "application/json", SeerrErrorCode.Cloudflare5xx)] + public async Task ResponseClassificationAndPublicErrorsDoNotDiscloseUpstream(int status, string type, SeerrErrorCode? expected) + { + using var response = IntegrationTransport.Response("{}", status, type); + response.Headers.Add("cf-ray", "private-ray"); + var (json, error) = await SeerrHttpHelper.ReadResponseAsync(response, "https://private.internal:5055"); + if (expected == null) { Assert.Equal("{}", json); Assert.Null(error); return; } + Assert.Null(json); + Assert.Equal(expected, error!.Code); + Assert.Equal(status, error.HttpStatus); + Assert.Equal("private-ray", error.CfRay); + var publicJson = JsonSerializer.Serialize(error.ToResponseShape()); + Assert.DoesNotContain("private.internal", publicJson); + Assert.DoesNotContain("private-ray", publicJson); + Assert.Contains("private.internal", JsonSerializer.Serialize(error.ToAdminResponseShape())); + } + + [Fact] + public async Task RedirectIsRejectedBeforeLoginPageParsing() + { + using var response = IntegrationTransport.Response("login", 302, "text/html"); + response.Headers.Location = new Uri("https://login.invalid"); + var result = await SeerrHttpHelper.ReadResponseAsync(response, "http://127.0.0.1"); + Assert.Equal(SeerrErrorCode.UpstreamRedirect, result.Error!.Code); + Assert.Null(result.Json); + } + + [Theory] + [InlineData("{")] + [InlineData("login")] + [InlineData("[]")] + public void InvalidProviderPayloadProducesSafeParseError(string json) + { + var result = SeerrHttpHelper.TryDeserialize>(json, "https://private.internal"); + Assert.Null(result.Result); + Assert.Equal(SeerrErrorCode.ParseError, result.Error!.Code); + Assert.DoesNotContain("private.internal", JsonSerializer.Serialize(result.Error.ToResponseShape())); + } +} + +public class IntegrationArrTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ResolvesKnownTagsAndSkipsMissingProviderIdsAndUnknownTags(bool sonarr) + { + using var env = new IntegrationEnvironment(); + var paths = new List(); + using var transport = new IntegrationTransport((request, _) => + { + Assert.Equal("test-key", Assert.Single(request.Headers.GetValues("X-Api-Key"))); + paths.Add(request.RequestUri!.AbsolutePath); + var body = paths.Count == 1 ? "[{\"id\":1,\"label\":\"family\"},{\"id\":2,\"label\":\"日本語\"}]" : + "[{\"imdbId\":\"tt1\",\"tmdbId\":1,\"tags\":[1,2,999]},{\"tmdbId\":0,\"tags\":[1]},{\"imdbId\":\"tt3\",\"tmdbId\":3,\"tags\":[999]}]"; + return Task.FromResult(IntegrationTransport.Response(body)); + }); + if (sonarr) + { + var result = await new SonarrService(transport, env.Logger).GetSeriesTagsByTvdbId("http://127.0.0.1/base/", "test-key"); + Assert.Equal(new[] { "family", "日本語" }, Assert.Single(result).Value); + Assert.True(result.ContainsKey("tt1")); + } + else + { + var result = await new RadarrService(transport, env.Logger).GetMovieTagsByTmdbId("http://127.0.0.1/base/", "test-key"); + Assert.Equal(new[] { "family", "日本語" }, Assert.Single(result).Value); + Assert.True(result.ContainsKey(1)); + } + Assert.Equal(new[] { "/base/api/v3/tag", "/base/api/v3/" + (sonarr ? "series" : "movie") }, paths); + } + + [Theory] + [InlineData("http://169.254.169.254/latest")] + [InlineData("http://100.100.100.200")] + [InlineData("file:///etc/passwd")] + [InlineData("")] + public async Task RejectedDestinationsNeverReachTransport(string url) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("No requests allowed")); + Assert.Empty(await new RadarrService(transport, env.Logger).GetMovieTagsByTmdbId(url, "key")); + Assert.Empty(await new SonarrService(transport, env.Logger).GetSeriesTagsByTvdbId(url, "key")); + Assert.Equal(0, transport.Calls); + } + + [Theory] + [InlineData("status")] + [InlineData("json")] + [InlineData("network")] + [InlineData("timeout")] + public async Task ProviderFailuresDoNotEscapeOrStartSecondRequest(string failure) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => failure switch + { + "network" => throw new HttpRequestException("offline"), + "timeout" => throw new TaskCanceledException("timeout"), + "status" => Task.FromResult(IntegrationTransport.Response("{}", 503)), + _ => Task.FromResult(IntegrationTransport.Response("{")) + }); + Assert.Empty(await new RadarrService(transport, env.Logger).GetMovieTagsByTmdbId("http://127.0.0.1", "key")); + Assert.Empty(await new SonarrService(transport, env.Logger).GetSeriesTagsByTvdbId("http://127.0.0.1", "key")); + Assert.Equal(2, transport.Calls); + } + + [Fact] + public async Task ScheduledTaskCancellationIsPropagated() + { + using var env = new IntegrationEnvironment(); + using var cts = new CancellationTokenSource(); + using var transport = new IntegrationTransport((_, _) => { cts.Cancel(); throw new OperationCanceledException(cts.Token); }); + await Assert.ThrowsAnyAsync(() => new RadarrService(transport, env.Logger).GetMovieTagsByTmdbId("http://127.0.0.1", "key", cts.Token)); + await Assert.ThrowsAnyAsync(() => new SonarrService(transport, env.Logger).GetSeriesTagsByTvdbId("http://127.0.0.1", "key", cts.Token)); + } +} diff --git a/tests/backend/Integrations/IntegrationItemStatsTests.cs b/tests/backend/Integrations/IntegrationItemStatsTests.cs new file mode 100644 index 000000000..2d016ccd3 --- /dev/null +++ b/tests/backend/Integrations/IntegrationItemStatsTests.cs @@ -0,0 +1,73 @@ +using System.Linq.Expressions; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using Moq; + +namespace JE.Tests; + +public class IntegrationItemStatsTests +{ + [Theory] + [InlineData(false, 25L, 100L, 25)] + [InlineData(true, 0L, 100L, 100)] + [InlineData(false, 150L, 100L, 100)] + [InlineData(false, -20L, 100L, 0)] + [InlineData(false, 20L, 0L, 0)] + public void ProgressUsesUserScopedDataAndClampsToWholePercent(bool watched, long position, long runtime, int expected) + { + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var item = new Audio { Id = Guid.NewGuid(), Size = 2048, RunTimeTicks = runtime }; + var data = new UserItemData { Key = "test", Played = watched, PlaybackPositionTicks = position }; + var manager = new Mock(); +#if NET10_0_OR_GREATER + manager.Setup(m => m.GetUserDataBatch(It.IsAny>(), user)).Returns(new Dictionary { [item.Id] = data }); +#else + manager.Setup(m => m.GetUserData(user, item)).Returns(data); +#endif + var result = new ItemStatsService(Mock.Of(), manager.Object).Compute(user, item, null); + Assert.Equal(2048, result.Size); Assert.Equal(expected, result.Progress); + Assert.Equal(runtime, result.TotalRuntimeTicks); + Assert.Equal(watched ? runtime : position, result.TotalPlaybackTicks); + manager.VerifyAll(); + } + + [Fact] + public void FolderTraversalDeduplicatesLeavesAndCyclesAndIgnoresNonPlayableItems() + { + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var item = new Audio { Id = Guid.NewGuid(), Size = 2048, RunTimeTicks = 100 }; + var folder = new Mock(); folder.Object.Id = Guid.NewGuid(); + // Jellyfin 10 has additional optional arguments; describe the same + // user-scoped call explicitly so Moq sees their real default values. + var method = typeof(Folder).GetMethods().Single(m => m.Name == "GetChildren" && m.GetParameters().Length >= 2 && m.GetParameters()[0].ParameterType == typeof(User) && m.GetParameters()[1].ParameterType == typeof(bool) && m.GetParameters().Skip(2).All(p => p.IsOptional)); + var parameter = Expression.Parameter(typeof(Folder), "folder"); + var arguments = method.GetParameters().Select((p, i) => (Expression)Expression.Constant(i == 0 ? user : i == 1 ? true : p.DefaultValue, p.ParameterType)); + var children = Expression.Lambda>>(Expression.Call(parameter, method, arguments), parameter); + folder.Setup(children).Returns(new BaseItem[] { item, item, folder.Object, new Person { Id = Guid.NewGuid() } }); + var manager = new Mock(); +#if NET10_0_OR_GREATER + manager.Setup(m => m.GetUserDataBatch(It.Is>(items => items.Count == 1 && items[0] == item), user)).Returns(new Dictionary()); +#endif + var result = new ItemStatsService(Mock.Of(), manager.Object).Compute(user, folder.Object, null); + Assert.Equal(2048, result.Size); Assert.Equal(100, result.TotalRuntimeTicks); + folder.Verify(children, Times.Once); + } + + [Fact] + public void SelectedMediaSourceControlsSizeCaseInsensitivelyWithoutChangingRuntime() + { + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var item = new Audio { Id = Guid.NewGuid(), Size = 2048, RunTimeTicks = 100 }; + var manager = new Mock(); +#if NET10_0_OR_GREATER + manager.Setup(m => m.GetUserDataBatch(It.IsAny>(), user)).Returns(new Dictionary()); +#endif + var service = new ItemStatsService(Mock.Of(), manager.Object); + Assert.Equal(2048, service.Compute(user, item, item.Id.ToString("N").ToUpperInvariant()).Size); + var missing = service.Compute(user, item, Guid.NewGuid().ToString("N")); + Assert.Equal(0, missing.Size); Assert.Equal(100, missing.TotalRuntimeTicks); Assert.Equal(0, missing.Progress); + } +} diff --git a/tests/backend/Integrations/IntegrationLifecycleTests.cs b/tests/backend/Integrations/IntegrationLifecycleTests.cs new file mode 100644 index 000000000..a3caad3f9 --- /dev/null +++ b/tests/backend/Integrations/IntegrationLifecycleTests.cs @@ -0,0 +1,24 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Library; +using Moq; + +namespace JE.Tests; + +public class IntegrationLifecycleTests +{ + [Fact] + public void RepeatedScanBridgeInitializationAndDisposalDoNotDuplicateSubscriptions() + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("No outbound calls expected")); + var library = new Mock(); + var service = new SeerrScanTriggerService(library.Object, transport, env.Logger); + service.Initialize(); + service.Initialize(); + service.Dispose(); + service.Dispose(); + library.VerifyAdd(l => l.ItemAdded += It.IsAny>(), Times.Once); + library.VerifyRemove(l => l.ItemAdded -= It.IsAny>(), Times.Once); + Assert.Equal(0, transport.Calls); + } +} diff --git a/tests/backend/Integrations/IntegrationMdblistTests.cs b/tests/backend/Integrations/IntegrationMdblistTests.cs new file mode 100644 index 000000000..5beeb4380 --- /dev/null +++ b/tests/backend/Integrations/IntegrationMdblistTests.cs @@ -0,0 +1,95 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class IntegrationMdblistTests +{ + [Fact] + public async Task TestingUnsavedKeyParsesAccountButCannotPolluteSavedQuota() + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((request, _) => + { + Assert.Equal("/user", request.RequestUri!.AbsolutePath); + Assert.Contains("apikey=a%26b", request.RequestUri.Query); + return Task.FromResult(IntegrationTransport.Response("{\"plan\":\"supporter\",\"is_supporter\":true,\"rate_limit\":1000,\"rate_limit_remaining\":42,\"rate_limit_reset\":1800000000}")); + }); + using var service = new MdblistService(transport, env.Paths.Object, env.Logger); + var result = await service.GetAccountStatusAsync(default, apiKeyOverride: "a&b"); + Assert.Equal(42, result!.RateLimitRemaining); + Assert.Equal(1000, result.RateLimit); + Assert.True(result.IsSupporter); + Assert.Equal("supporter", result.Plan); + Assert.Equal(1800000000, result.RateLimitResetUnixSeconds); + Assert.Null(service.RemainingQuota()); + await service.GetAccountStatusAsync(default, apiKeyOverride: "a&b"); + Assert.Equal(2, transport.Calls); + } + + [Theory] + [InlineData(401, "{}")] + [InlineData(429, "{}")] + [InlineData(500, "{}")] + [InlineData(200, "{")] + [InlineData(200, "null")] + public async Task AccountFailuresAreSafeAndDoNotInventQuota(int status, string body) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response(body, status))); + using var service = new MdblistService(transport, env.Paths.Object, env.Logger); + Assert.Null(await service.GetAccountStatusAsync(default, apiKeyOverride: "test-key")); + Assert.Null(service.RemainingQuota()); + } + + [Fact] + public void BatchMergePersistsConfirmedMissesSeparatesMediaTypesAndIsIdempotent() + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("No network required")); + var entry = new MdblistCacheEntry { Found = true, Confirmed = true, FetchedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Ratings = [new() { Source = "tmdb", Score = 78 }], Ids = new() { ["imdb"] = "tt1" } }; + using (var service = new MdblistService(transport, env.Paths.Object, env.Logger)) + { + for (var i = 0; i < 2; i++) service.MergeMediaBatchIntoCache("movie", ["1", "2"], new Dictionary { ["1"] = entry }); + Assert.Same(entry, service.GetCachedEntry("movie", "1")); + Assert.False(service.GetCachedEntry("movie", "2")!.Found); + Assert.True(service.GetCachedEntry("movie", "2")!.Confirmed); + Assert.False(service.NeedsFetch("movie", "2")); + Assert.True(service.NeedsFetch("tv", "1")); + } + using var reloaded = new MdblistService(transport, env.Paths.Object, env.Logger); + Assert.Equal("tt1", reloaded.GetCachedEntry("movie", "1")!.Ids["imdb"]); + Assert.Equal(7.8, MdblistService.GetCommunityRating(reloaded.GetCachedEntry("movie", "1")!)); + Assert.False(reloaded.NeedsFetch("movie", "2")); + Assert.Equal(0, transport.Calls); + } + + [Theory] + [InlineData(true, true, 6, false)] + [InlineData(true, true, 8, true)] + [InlineData(false, true, 2, false)] + [InlineData(false, true, 4, true)] + [InlineData(false, false, 0.02, false)] + [InlineData(false, false, 0.05, true)] + public void RetryTtlDistinguishesFailuresFromConfirmedNegativeAndPositive(bool found, bool confirmed, double days, bool needsFetch) + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException()); + using var service = new MdblistService(transport, env.Paths.Object, env.Logger); + service.MergeMediaBatchIntoCache("movie", ["1"], new Dictionary { ["1"] = new() { Found = found, Confirmed = confirmed, FetchedAtUnixMs = DateTimeOffset.UtcNow.AddDays(-days).ToUnixTimeMilliseconds() } }); + Assert.Equal(needsFetch, service.NeedsFetch("movie", "1")); + } + + [Theory] + [InlineData(80.0, 6.0, 8.0)] + [InlineData(null, 60.0, 6.0)] + [InlineData(0.0, 60.0, 0.0)] + [InlineData(null, null, null)] + public void NativeRatingPrefersScorePreservesZeroAndScalesTmdb(double? score, double? value, double? expected) + { + var entry = new MdblistCacheEntry { Ratings = [new() { Source = "tmdb", Score = score, Value = value }, new() { Source = "tomatoes", Score = 92 }] }; + Assert.Equal(expected, MdblistService.GetCommunityRating(entry)); + Assert.Equal(92, MdblistService.GetCriticRating(entry)); + Assert.Null(MdblistService.GetCommunityRating(new())); + } +} diff --git a/tests/backend/Integrations/IntegrationTmdbTests.cs b/tests/backend/Integrations/IntegrationTmdbTests.cs new file mode 100644 index 000000000..5edd5531d --- /dev/null +++ b/tests/backend/Integrations/IntegrationTmdbTests.cs @@ -0,0 +1,152 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class IntegrationTmdbTests +{ + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public async Task CacheSeparatesCredentialsQueryAndPathAndExpiresAtBoundary() + { + var clock = new Clock(); + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response("{\"url\":\"" + request.RequestUri + "\"}"))); + using var cache = new TmdbResponseCache(transport, clock); + var first = await cache.GetAsync("search/movie", "?query=a", "key", default); + Assert.Same(first, await cache.GetAsync("search/movie", "?query=a", "key", default)); + await cache.GetAsync("search/movie", "?query=b", "key", default); + await cache.GetAsync("search/tv", "?query=a", "key", default); + await cache.GetAsync("search/movie", "?query=a", "other", default); + Assert.Equal(4, transport.Calls); + clock.Now += TimeSpan.FromMinutes(30); + Assert.NotSame(first, await cache.GetAsync("search/movie", "?query=a", "key", default)); + Assert.Equal(5, transport.Calls); + } + + [Theory] + [InlineData(200, "application/json", true)] + [InlineData(404, "application/json", true)] + [InlineData(401, "application/json", false)] + [InlineData(429, "application/json", false)] + [InlineData(503, "application/json", false)] + [InlineData(200, "text/html", false)] + public async Task OnlySuccessfulJsonAndNotFoundAreCached(int status, string type, bool cached) + { + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response("{}", status, type))); + using var cache = new TmdbResponseCache(transport, TimeProvider.System); + Assert.Equal(status, (await cache.GetAsync("movie/1", "", "key", default)).StatusCode); + await cache.GetAsync("movie/1", "", "key", default); + Assert.Equal(cached ? 1 : 2, transport.Calls); + } + + [Fact] + public async Task ConcurrentMissesShareFetchAndOneCancelledWaiterCannotCancelOthers() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var transport = new IntegrationTransport(async (_, ct) => { await release.Task.WaitAsync(ct); return IntegrationTransport.Response(); }); + using var cache = new TmdbResponseCache(transport, TimeProvider.System); + using var cancelled = new CancellationTokenSource(); + var abandoned = cache.GetAsync("person/1", "", "key", cancelled.Token); + var waiters = Enumerable.Range(0, 15).Select(_ => cache.GetAsync("person/1", "", "key", default)).ToArray(); + cancelled.Cancel(); + await Assert.ThrowsAnyAsync(() => abandoned); + release.SetResult(); + var results = await Task.WhenAll(waiters); + Assert.All(results, result => Assert.Equal("{}", result.Content)); + Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task FailedInFlightCallIsRemovedAndRetried() + { + var calls = 0; + using var transport = new IntegrationTransport((_, _) => ++calls == 1 ? throw new HttpRequestException("offline") : Task.FromResult(IntegrationTransport.Response())); + using var cache = new TmdbResponseCache(transport, TimeProvider.System); + await Assert.ThrowsAsync(() => cache.GetAsync("movie/1", "", "key", default)); + Assert.True((await cache.GetAsync("movie/1", "", "key", default)).IsSuccess); + Assert.Equal(2, transport.Calls); + } + + [Fact] + public async Task MovieBundlePopulatesIndependentResourcesAndDoesNotLeakSiblingsIntoTitle() + { + using var transport = new IntegrationTransport((request, _) => + { + Assert.Contains("append_to_response=", request.RequestUri!.Query); + return Task.FromResult(IntegrationTransport.Response("{\"id\":12,\"title\":\"test\",\"release_dates\":{\"results\":[]},\"watch/providers\":{\"results\":{}},\"reviews\":{\"results\":[],\"page\":1}}")); + }); + using var cache = new TmdbResponseCache(transport, TimeProvider.System); + var title = await cache.GetAsync("movie/12", "", "key", default, true); + Assert.DoesNotContain("watch/providers", title.Content); + Assert.Contains("test", title.Content); + var reviews = await cache.GetAsync("movie/12/reviews", "?language=en-US&page=1", "key", default, true); + Assert.Contains("page", reviews.Content); + Assert.DoesNotContain("title", reviews.Content); + await cache.GetAsync("movie/12/watch/providers", "", "key", default); + await cache.GetAsync("movie/12/release_dates", "", "key", default); + Assert.Equal(1, transport.Calls); + } + + [Theory] + [InlineData("*", true)] + [InlineData("\"tag\"", true)] + [InlineData("W/\"tag\"", true)] + [InlineData("\"other\", W/\"tag\"", true)] + [InlineData("tag", false)] + [InlineData("\"other\"", false)] + [InlineData("", false)] + public void ConditionalGetUsesWeakComparisonAndRejectsMalformedValidators(string header, bool matches) => + Assert.Equal(matches, TmdbResponseCache.IfNoneMatchMatches(header, "\"tag\"")); + + [Theory] + [InlineData("{")] + [InlineData("{\"V\":1,\"Entries\":null}")] + [InlineData("{\"V\":1,\"Entries\":[null]}")] + [InlineData("{\"V\":1,\"Entries\":[{\"K\":\"key\",\"C\":null,\"E\":1800000000000}]}")] + [InlineData("{\"V\":1,\"Entries\":[{\"K\":\"key\",\"S\":200,\"C\":\"{}\",\"E\":9223372036854775807}]}")] + public async Task CorruptPersistedCacheCannotPreventStartup(string persisted) + { + using var env = new IntegrationEnvironment(); + var path = Path.Combine(env.Root, "configurations/Jellyfin.Plugin.JellyfinEnhanced/tmdb-cache.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, persisted); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response())); + using var cache = new TmdbResponseCache(transport, env.Paths.Object, env.Logger); + Assert.True((await cache.GetAsync("movie/1", "", "key", default)).IsSuccess); + Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task CorruptRecordDoesNotDiscardLaterValidPersistedResponse() + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response("{\"id\":1}"))); + using (var cache = new TmdbResponseCache(transport, env.Paths.Object, env.Logger)) + await cache.GetAsync("movie/1", "", "key", default); + var path = Path.Combine(env.Root, "configurations/Jellyfin.Plugin.JellyfinEnhanced/tmdb-cache.json"); + var file = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path))!; + file["Entries"]!.AsArray().Insert(0, System.Text.Json.Nodes.JsonNode.Parse("{\"K\":\"broken\",\"C\":\"{}\",\"E\":9223372036854775807}")); + File.WriteAllText(path, file.ToJsonString()); + using var reloaded = new TmdbResponseCache(transport, env.Paths.Object, env.Logger); + Assert.Equal("{\"id\":1}", (await reloaded.GetAsync("movie/1", "", "key", default)).Content); + Assert.Equal(1, transport.Calls); + } + + [Fact] + public async Task DisposePersistsResponsesAcrossRestartWithoutPlaintextApiKey() + { + using var env = new IntegrationEnvironment(); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response("{\"id\":1}"))); + using (var first = new TmdbResponseCache(transport, env.Paths.Object, env.Logger)) + await first.GetAsync("movie/1", "", "top-secret-key", default); + var persisted = File.ReadAllText(Path.Combine(env.Root, "configurations/Jellyfin.Plugin.JellyfinEnhanced/tmdb-cache.json")); + Assert.DoesNotContain("top-secret-key", persisted); + using var reloaded = new TmdbResponseCache(transport, env.Paths.Object, env.Logger); + Assert.Equal("{\"id\":1}", (await reloaded.GetAsync("movie/1", "", "top-secret-key", default)).Content); + Assert.Equal(1, transport.Calls); + } +} diff --git a/tests/backend/Integrations/IntegrationWatchProvidersTests.cs b/tests/backend/Integrations/IntegrationWatchProvidersTests.cs new file mode 100644 index 000000000..67cc1aaa5 --- /dev/null +++ b/tests/backend/Integrations/IntegrationWatchProvidersTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Services; + +namespace JE.Tests; + +public class IntegrationWatchProvidersTests +{ + [Theory] + [InlineData("US", true)][InlineData("AU", true)][InlineData("us", false)] + [InlineData("US\n", false)][InlineData("US\r\n", false)][InlineData(" US", false)] + [InlineData("USA", false)][InlineData("", false)][InlineData(null, false)] + public void RegionRequiresExactlyTwoUppercaseLetters(string? value, bool expected) => Assert.Equal(expected, WatchProvidersBatch.IsValidRegion(value)); + + [Fact] + public void BatchCanonicalizesIdsDeduplicatesInFirstSeenOrderAndSkipsMalformedEntries() + { + Assert.True(WatchProvidersBatch.TryParseItems(" movie:001,tv:2,movie:1,tv:2,bad,movie:0,movie:-2,movie:1234567890,TV:1,movie:3 ", out var items, out var error)); + Assert.Equal(new[] { "movie:1", "tv:2", "movie:3" }, items.Select(x => x.Key)); + Assert.Equal("tv/2/watch/providers", items[1].ApiPath); + Assert.Equal("", error); + } + + [Theory] + [InlineData(null)][InlineData("")][InlineData(" ")][InlineData("movie:0,tv:-1,person:2")] + public void BatchRejectsMissingOrEntirelyInvalidInput(string? value) + { + Assert.False(WatchProvidersBatch.TryParseItems(value, out var items, out var error)); + Assert.Empty(items); Assert.NotEmpty(error); + } + + [Fact] + public void BatchAcceptsMaximumDistinctIdsButRejectsOneMoreAndOversizedInput() + { + var maximum = string.Join(',', Enumerable.Range(1, WatchProvidersBatch.MaxItems).Select(x => "movie:" + x)); + Assert.True(WatchProvidersBatch.TryParseItems(maximum + ",movie:1", out var parsed, out _)); + Assert.Equal(100, parsed.Count); + Assert.False(WatchProvidersBatch.TryParseItems(maximum + ",movie:101", out _, out _)); + Assert.False(WatchProvidersBatch.TryParseItems(new string('x', 2049), out _, out _)); + } + + [Theory] + [InlineData("/logo.png", true)][InlineData("/logo.WEBP", true)] + [InlineData("/logo.png\n", false)][InlineData("https://evil.invalid/logo.png", false)] + [InlineData("/../logo.png", false)][InlineData("/logo.svg", false)] + public void ProviderLogoMustBeSafeSingleTmdbImagePath(string path, bool expected) + { + var json = JsonSerializer.Serialize(new { results = new { US = new { flatrate = new[] { new { provider_id = 7, provider_name = "Provider", logo_path = path } } } } }); + Assert.Equal(expected ? 1 : 0, WatchProvidersBatch.ExtractFlatrate(json, "US")!.Count); + } + + [Fact] + public void OutputDistinguishesProviderAbsenceFromFailedLookupAndEscapesNames() + { + var providers = WatchProvidersBatch.ExtractFlatrate("{\"results\":{\"US\":{\"flatrate\":[{\"provider_id\":7,\"provider_name\":\"A & 日本語\",\"logo_path\":\"/a.png\"}],\"rent\":[{}]},\"AU\":{\"flatrate\":[{}]}}}", "US")!; + Assert.Equal("A & 日本語", Assert.Single(providers).ProviderName); + Assert.Empty(WatchProvidersBatch.ExtractFlatrate("{}", "US")!); + Assert.Null(WatchProvidersBatch.ExtractFlatrate("{", "US")); + Assert.Null(WatchProvidersBatch.ExtractFlatrate("[]", "US")); + var items = new[] { new WatchProvidersBatch.Item("movie", 1), new WatchProvidersBatch.Item("tv", 2), new WatchProvidersBatch.Item("tv", 3) }; + using var result = JsonDocument.Parse(WatchProvidersBatch.Serialize("US", items, new Dictionary?> { ["movie:1"] = providers, ["tv:2"] = [] })); + Assert.Equal("A & 日本語", result.RootElement.GetProperty("results").GetProperty("movie:1")[0].GetProperty("provider_name").GetString()); + Assert.Equal(0, result.RootElement.GetProperty("results").GetProperty("tv:2").GetArrayLength()); + Assert.Equal(JsonValueKind.Null, result.RootElement.GetProperty("results").GetProperty("tv:3").ValueKind); + } +} diff --git a/tests/backend/JE.Tests.csproj b/tests/backend/JE.Tests.csproj new file mode 100644 index 000000000..8eb7baa62 --- /dev/null +++ b/tests/backend/JE.Tests.csproj @@ -0,0 +1,31 @@ + + + jf12 + true + $(MSBuildProjectDirectory)/packages.$(JellyfinTarget).lock.json + net10.0 + net9.0 + enable + enable + false + true + JE.Tests + + + + + + + + + + + + + + + + + + + diff --git a/tests/backend/PosterTags/HistoryNativeClientTests.cs b/tests/backend/PosterTags/HistoryNativeClientTests.cs new file mode 100644 index 000000000..98d8f7d98 --- /dev/null +++ b/tests/backend/PosterTags/HistoryNativeClientTests.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class HistoryNativeClientTests +{ + // Independent examples: iterating production's exclusion list cannot detect + // a missing client, which is exactly the regression reported in #905. + [Theory] + [InlineData("Jellium Desktop", false)] + [InlineData("jellium desktop", false)] + [InlineData(" Jellium Desktop ", false)] + [InlineData("Jellyfin Web", false)] + [InlineData("Jellyfin Media Player", false)] + [InlineData("Jellyfin Desktop", false)] + [InlineData("Jellyfin for WebOS", false)] + [InlineData("Jellyfin for Android", false)] + [InlineData("Jellyfin for Android TV", true)] + [InlineData("Jellium Desktop TV", true)] + [InlineData("Swiftfin", true)] + [InlineData("", false)] + public void ReportedDesktopShellMustNotReceiveNativeTags(string client, bool expected) + { + using var fixture = new ApiPluginFixture(); + fixture.Plugin.Configuration.NativePosterTagsWebClientNames = ""; + var principal = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("Jellyfin-Client", client)], "test")); + Assert.Equal(expected, new NativeClientPolicy().IsNativeClient(principal)); + } + + [Fact] + public void ChangingCustomShellExclusionsPreservesBuiltInDesktopExclusion() + { + using var fixture = new ApiPluginFixture(); + var policy = new NativeClientPolicy(); + ClaimsPrincipal Client(string name) => new(new ClaimsIdentity( + [new Claim("Jellyfin-Client", name)], "test")); + fixture.Plugin.Configuration.NativePosterTagsWebClientNames = "Custom Shell"; + Assert.False(policy.IsNativeClient(Client("Custom Shell"))); + Assert.False(policy.IsNativeClient(Client("Jellium Desktop"))); + fixture.Plugin.Configuration.NativePosterTagsWebClientNames = "Other Shell"; + Assert.True(policy.IsNativeClient(Client("Custom Shell"))); + Assert.False(policy.IsNativeClient(Client("Other Shell"))); + Assert.False(policy.IsNativeClient(Client("Jellium Desktop"))); + } +} diff --git a/tests/backend/PosterTags/PerfRound2TagCacheResponseTests.cs b/tests/backend/PosterTags/PerfRound2TagCacheResponseTests.cs new file mode 100644 index 000000000..3d43ab370 --- /dev/null +++ b/tests/backend/PosterTags/PerfRound2TagCacheResponseTests.cs @@ -0,0 +1,97 @@ +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class PerfRound2TagCacheResponseTests +{ + [Fact] + public async Task ExecutedResponsesAndValidatorsRemainUserIsolatedAfterAccessChanges() + { + using var fixture = new ApiPluginFixture(); + fixture.Plugin.Configuration.TagCacheServerMode = true; + fixture.Plugin.Configuration.SpoilerBlurEnabled = false; + fixture.Plugin.Configuration.ShowUserReviews = false; + var alice = new User("alice", "default", "default") { Id = Guid.NewGuid() }; + var bob = new User("bob", "default", "default") { Id = Guid.NewGuid() }; + var first = Guid.NewGuid(); var second = Guid.NewGuid(); + Guid[] aliceAccess = [first]; + var users = new Mock(); + users.Setup(x => x.GetUserById(alice.Id)).Returns(alice); + users.Setup(x => x.GetUserById(bob.Id)).Returns(bob); + var library = new Mock(); + library.Setup(x => x.GetItemIds(It.IsAny())).Returns((InternalItemsQuery q) => q.User == alice ? aliceAccess : [second]); + Directory.CreateDirectory(fixture.Core.ConfigRoot); + File.WriteAllText(Path.Combine(fixture.Core.ConfigRoot, "tag-cache.json"), JsonSerializer.Serialize(new + { + SchemaVersion = 7, Version = 9, LastModified = 200, + Items = new Dictionary + { + [first.ToString("N")] = new() { Type = "Movie", Genres = ["First secret"], LastUpdated = 100 }, + [second.ToString("N")] = new() { Type = "Movie", Genres = ["Second secret"], LastUpdated = 200 } + } + })); + using var cache = new TagCacheService(library.Object, fixture.Core.Paths.Object, Mock.Of(), fixture.Core.Logger); + cache.LoadFromDisk(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = null; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + using var provider = services.BuildServiceProvider(); + var controller = ApiAssetTests.Controller(fixture.Core, users.Object, cache); + + async Task<(int Status, string ETag, string Body)> Read(User user, string? validator = null) + { + var http = new DefaultHttpContext { RequestServices = provider }; + http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("Jellyfin-UserId", user.Id.ToString())], "test")); + http.Request.Method = "GET"; + http.Request.Headers.Accept = "application/json"; + if (validator != null) http.Request.Headers.IfNoneMatch = validator; + using var stream = new MemoryStream(); + http.Response.Body = stream; + controller.ControllerContext = new ControllerContext { HttpContext = http }; + var result = controller.GetTagCache(user.Id); + await result.ExecuteResultAsync(new ActionContext(http, new RouteData(), new ActionDescriptor())); + return (http.Response.StatusCode, http.Response.Headers.ETag.ToString(), System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + + var a = await Read(alice); + Assert.Equal(200, a.Status); Assert.NotEmpty(a.ETag); + Assert.Contains("First secret", a.Body); Assert.DoesNotContain("Second secret", a.Body); + using (var body = JsonDocument.Parse(a.Body)) + { + Assert.Equal(1, body.RootElement.GetProperty("count").GetInt32()); + Assert.Equal("Movie", body.RootElement.GetProperty("items").GetProperty(first.ToString("N")).GetProperty("Type").GetString()); + Assert.False(body.RootElement.TryGetProperty("reviewRatings", out _)); + } + var aAgain = await Read(alice, a.ETag); + Assert.Equal(304, aAgain.Status); Assert.Empty(aAgain.Body); + var b = await Read(bob, a.ETag); + Assert.Equal(200, b.Status); Assert.NotEqual(a.ETag, b.ETag); + Assert.Contains("Second secret", b.Body); Assert.DoesNotContain("First secret", b.Body); + Assert.Equal(304, (await Read(bob, b.ETag)).Status); + aliceAccess = [second]; + cache.InvalidateUserAccess(); + var changed = await Read(alice, a.ETag); + Assert.Equal(200, changed.Status); + Assert.Contains("Second secret", changed.Body); Assert.DoesNotContain("First secret", changed.Body); + Assert.NotEqual(a.ETag, changed.ETag); + } +} diff --git a/tests/backend/PosterTags/PerfRound2TagCacheTests.cs b/tests/backend/PosterTags/PerfRound2TagCacheTests.cs new file mode 100644 index 000000000..6f74cae37 --- /dev/null +++ b/tests/backend/PosterTags/PerfRound2TagCacheTests.cs @@ -0,0 +1,77 @@ +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class PerfRound2TagCacheTests +{ + private sealed class Episode : MediaBrowser.Controller.Entities.TV.Episode + { + public List Sources { get; init; } = []; + public override List GetMediaSources(bool enablePathSubstitution) => Sources; + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void FullBuildAndOnDemandAgreeOnRegularRepresentativeAndLanguageUnion(bool directSeriesParent, bool unprobedFirst) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.TagCacheServerMode = true; + var series = new Series { Id = Guid.NewGuid(), Name = "Series", OfficialRating = "TV-14" }; + var season = new Season { Id = Guid.NewGuid(), ParentId = series.Id, SeriesId = series.Id, IndexNumber = 1, Name = "Season" }; + var specialSeason = new Season { Id = Guid.NewGuid(), ParentId = series.Id, SeriesId = series.Id, IndexNumber = 0, Name = "Specials" }; + Episode Make(int n, int seasonNumber, string lang, bool usable = true) => new() + { + Id = Guid.NewGuid(), Name = "Episode " + n, IndexNumber = n, ParentIndexNumber = seasonNumber, + PremiereDate = new DateTime(2025, 1, n), SeriesId = series.Id, + ParentId = directSeriesParent ? series.Id : seasonNumber == 0 ? specialSeason.Id : season.Id, + SeasonId = seasonNumber == 0 ? specialSeason.Id : season.Id, + Genres = [seasonNumber == 0 ? "Special genre" : "Drama"], + Sources = usable ? [new() { MediaStreams = [new() { Type = MediaStreamType.Audio, Language = lang }, new() { Type = MediaStreamType.Video, Height = seasonNumber == 0 ? 720 : 1080 }] }] : [] + }; + var episodes = new List { Make(1, 0, "fra"), Make(2, 1, "eng", !unprobedFirst), Make(3, 1, "deu") }; + var all = new BaseItem[] { series, season, specialSeason }.Concat(episodes).ToArray(); + var library = new Mock(); + IEnumerable Select(InternalItemsQuery q) + { + IEnumerable selected = all; + if (q.ItemIds?.Length > 0) selected = selected.Where(i => q.ItemIds.Contains(i.Id)); + else if (q.IncludeItemTypes?.Length > 0) selected = selected.Where(i => q.IncludeItemTypes.Contains(i.GetBaseItemKind())); + if (q.ParentId != Guid.Empty) selected = selected.OfType().Where(e => q.ParentId == series.Id || e.SeasonId == q.ParentId); + return selected.Skip(q.StartIndex ?? 0).Take(q.Limit ?? int.MaxValue); + } + library.Setup(l => l.GetItemIds(It.IsAny())).Returns((InternalItemsQuery q) => Select(q).Select(i => i.Id).ToArray()); + library.Setup(l => l.GetItemList(It.IsAny())).Returns((InternalItemsQuery q) => Select(q).ToArray()); + library.Setup(l => l.GetItemById(It.IsAny())).Returns((Guid id) => all.SingleOrDefault(i => i.Id == id)); + library.Setup(l => l.GetItemById(It.IsAny())).Returns((Guid id) => all.SingleOrDefault(i => i.Id == id)); + using var cache = new TagCacheService(library.Object, f.Core.Paths.Object, Mock.Of(), f.Core.Logger); + cache.BuildFullCache(null, CancellationToken.None); + Assert.Equal(all.Length, cache.Count); + foreach (var container in new BaseItem[] { series, season, specialSeason }) + { + var expected = cache.BuildEntryOnDemand(container); + Assert.NotNull(expected); + Assert.True(cache.TryGetEntry(container.Id, out var actual)); + Assert.Equal(expected.AudioLanguages, actual.AudioLanguages); + Assert.Equal(expected.PartialAudioLanguages, actual.PartialAudioLanguages); + Assert.Equal(expected.Genres, actual.Genres); + Assert.Equal(expected.StreamData?.ItemName, actual.StreamData?.ItemName); + Assert.Equal(expected.StreamData?.Streams?.Select(s => s.Height), actual.StreamData?.Streams?.Select(s => s.Height)); + } + Assert.True(cache.TryGetEntry(series.Id, out var builtSeries)); + Assert.Equal(unprobedFirst ? "Episode 3" : "Episode 2", builtSeries.StreamData?.ItemName); + Assert.Equal(unprobedFirst ? new[] { "fra", "deu" } : new[] { "fra", "eng", "deu" }, builtSeries.AudioLanguages); + Assert.Equal(new[] { "Drama" }, builtSeries.Genres); + } +} diff --git a/tests/backend/PosterTags/PosterBlurFilterTests.cs b/tests/backend/PosterTags/PosterBlurFilterTests.cs new file mode 100644 index 000000000..ca63bfade --- /dev/null +++ b/tests/backend/PosterTags/PosterBlurFilterTests.cs @@ -0,0 +1,181 @@ +using System.Security.Claims; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Moq; +using SkiaSharp; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class PosterBlurFilterTests +{ + private sealed class ResponseFeature : HttpResponseFeature + { + private readonly List<(Func Callback,object State)> callbacks=[]; + public override void OnStarting(Func callback,object state)=>callbacks.Add((callback,state)); + public async Task Start() { foreach(var (callback,state) in callbacks.AsEnumerable().Reverse()) await callback(state); } + } + private sealed class Fixture : IDisposable + { + public readonly ApiPluginFixture Plugin=new(); + public readonly Guid UserId=Guid.NewGuid(); + public readonly Movie Movie=new(){Id=Guid.NewGuid()}; + public readonly Mock Library=new(); + public readonly Mock Users=new(); + public readonly Mock UserData=new(); + public readonly Mock Chapters=new(); + public readonly User User=new("test","default","default"); + public readonly SpoilerBlurImageFilter Filter; + private readonly SpoilerNextUnwatchedService next; + public Fixture(bool guarded=true) + { + Plugin.Plugin.Configuration.SpoilerBlurEnabled=true; Plugin.Plugin.Configuration.SpoilerKeepMoviePosters=false; + Plugin.Plugin.Configuration.SpoilerBlurMode="blur"; + User.Id=UserId; Users.Setup(u=>u.GetUserById(UserId)).Returns(User); + Library.Setup(l=>l.GetItemById(Movie.Id)).Returns(Movie); + UserData.Setup(u=>u.GetUserData(User,Movie)).Returns(new UserItemData{Key="movie",Played=false}); + var state=new UserSpoilerBlur(); if(guarded) state.Movies[Movie.Id.ToString("N")]=new(); + Plugin.Core.Manager.SaveUserConfiguration(UserId.ToString("N"),"spoilerblur.json",state); + var markers=new SpoilerIdentityService(Users.Object,Plugin.Core.Logger); + var identity=new RequestIdentityService(Mock.Of(),Users.Object,markers,Plugin.Core.Logger); + var resolver=new SpoilerUserResolver(Plugin.Core.Manager,Library.Object,Plugin.Core.Logger,identity); + next=new SpoilerNextUnwatchedService(Library.Object,Users.Object,UserData.Object,Plugin.Core.Logger); + Filter=new SpoilerBlurImageFilter(Library.Object,Users.Object,UserData.Object,Chapters.Object,resolver,new ImageBlurService(Plugin.Core.Logger),next,Plugin.Core.Logger); + } + public async Task<(ActionExecutedContext Executed,DefaultHttpContext Http)> Run(IActionResult original,string type="Primary",string method="GET",string controller="Image",string action="GetItemImage",Guid? viewer=null,string query="") + { + var http=new DefaultHttpContext(); var response=new ResponseFeature(); http.Features.Set(response); + http.Request.Method=method; http.Request.QueryString=new QueryString(query); + http.User=new ClaimsPrincipal(new ClaimsIdentity([new Claim("Jellyfin-UserId",(viewer??UserId).ToString())],"Test")); + var descriptor=new ActionDescriptor{RouteValues=new Dictionary{{"controller",controller},{"action",action}}}; + var ac=new ActionContext(http,new RouteData(),descriptor,new ModelStateDictionary()); + var executing=new ActionExecutingContext(ac,[],new Dictionary{{"itemId",Movie.Id},{"imageType",type},{"imageIndex",0}},new object()); + var executed=new ActionExecutedContext(ac,[],new object()){Result=original}; + await Filter.OnActionExecutionAsync(executing,()=>{http.Response.Headers.CacheControl="public, max-age=31536000";http.Response.Headers.ETag="original";http.Response.Headers.LastModified="yesterday";return Task.FromResult(executed);}); + await response.Start(); return(executed,http); + } + public void Dispose(){Filter.Dispose();next.Dispose();SpoilerUserResolver.InvalidateUser(UserId.ToString("N"));Plugin.Dispose();} + } + private static void Private(HttpContext http,bool chapter=false) + { + Assert.Equal(chapter?"private, max-age=30, must-revalidate":"private, no-store, max-age=0, must-revalidate",http.Response.Headers.CacheControl.ToString()); + Assert.False(http.Response.Headers.ContainsKey("ETag")); Assert.False(http.Response.Headers.ContainsKey("Last-Modified")); + Assert.Equal(true,http.Items[SpoilerBlurImageFilter.NoStoreHttpContextItem]); + } + [Theory] + [InlineData("Primary")][InlineData("Thumb")][InlineData("Screenshot")][InlineData("Chapter")] + public async Task Protected_movie_surfaces_replace_pixels_and_scrub_shared_cache_headers(string type) + { + using var f=new Fixture(); var bytes=PosterBlurTests.Source(); + var (executed,http)=await f.Run(new FileContentResult(bytes,"image/png"),type); + var result=Assert.IsType(executed.Result);Assert.Equal("image/jpeg",result.ContentType);Assert.NotEqual(bytes,result.FileContents); + using var image=SKBitmap.Decode(result.FileContents);Assert.Equal(96,image.Width);Private(http,type=="Chapter"); + } + [Theory] + [InlineData("blur")][InlineData("hide")] + public async Task Corrupt_protected_images_fail_closed_in_both_modes(string mode) + { + using var f=new Fixture(); f.Plugin.Plugin.Configuration.SpoilerBlurMode=mode; + var (executed,http)=await f.Run(new FileContentResult([1,2,3],"image/png")); + var result=Assert.IsType(executed.Result); using var image=SKBitmap.Decode(result.FileContents);Assert.NotNull(image);Private(http); + } + [Fact] + public async Task Stream_read_failure_returns_structural_fallback() + { + using var f=new Fixture();using var stream=new MemoryStream(PosterBlurTests.Source());stream.Dispose(); + var(executed,http)=await f.Run(new FileStreamResult(stream,"image/png")); + var result=Assert.IsType(executed.Result);using var image=SKBitmap.Decode(result.FileContents);Assert.Equal(16,image.Width);Private(http); + } + [Theory] + [InlineData(true,false,"Primary")][InlineData(false,true,"Primary")][InlineData(false,false,"Backdrop")] + public async Task Watched_exempt_posters_and_disabled_artwork_keep_bytes_but_disallow_public_cache(bool watched,bool keep,string type) + { + using var f=new Fixture(); f.Plugin.Plugin.Configuration.SpoilerKeepMoviePosters=keep;f.Plugin.Plugin.Configuration.SpoilerBlurArtwork=false; + f.UserData.Setup(u=>u.GetUserData(f.User,f.Movie)).Returns(new UserItemData{Key="movie",Played=watched}); + var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(executed,http)=await f.Run(original,type);Assert.Same(original,executed.Result);Private(http); + } + [Fact] + public async Task Head_preserves_body_result_but_scrubs_cache() + { + using var f=new Fixture();var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(executed,http)=await f.Run(original,method:"HEAD");Assert.Same(original,executed.Result);Private(http); + } + [Theory] + [InlineData("Logo","Image","GetItemImage")][InlineData("Primary","Items","GetItems")][InlineData("Primary","Image","OtherAction")] + public async Task Unrelated_routes_or_image_types_are_untouched(string type,string controller,string action) + { + using var f=new Fixture();var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(executed,http)=await f.Run(original,type,controller:controller,action:action); + Assert.Same(original,executed.Result);Assert.Equal("original",http.Response.Headers.ETag.ToString());Assert.False(http.Items.ContainsKey(SpoilerBlurImageFilter.NoStoreHttpContextItem)); + } + [Fact] + public async Task Guarded_users_cached_blur_does_not_leak_into_other_users_response() + { + using var f=new Fixture();var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(first,_)=await f.Run(original);Assert.NotSame(original,first.Result); + var(second,http)=await f.Run(original,viewer:Guid.NewGuid());Assert.Same(original,second.Result);Assert.Equal("original",http.Response.Headers.ETag.ToString()); + } + [Fact] + public async Task Different_size_query_parameters_cannot_reuse_wrong_image_dimensions() + { + using var f=new Fixture();var(first,_)=await f.Run(new FileContentResult(PosterBlurTests.Source(64,32),"image/png"),query:"?maxWidth=64"); + var(second,_)=await f.Run(new FileContentResult(PosterBlurTests.Source(32,64),"image/png"),query:"?maxHeight=64"); + using var a=SKBitmap.Decode(Assert.IsType(first.Result).FileContents);using var b=SKBitmap.Decode(Assert.IsType(second.Result).FileContents); + Assert.Equal(64,a.Width);Assert.Equal(32,b.Width);Assert.Equal(64,b.Height); + } + [Theory] + [InlineData(99L,true)][InlineData(100L,false)][InlineData(101L,false)] + public async Task Chapters_reveal_only_strictly_before_resume_position(long chapterStart,bool reveal) + { + using var f=new Fixture(); + f.UserData.Setup(u=>u.GetUserData(f.User,f.Movie)).Returns(new UserItemData{Key="movie",PlaybackPositionTicks=100}); + f.Chapters.Setup(c=>c.GetChapter(f.Movie.Id,0)).Returns(new MediaBrowser.Model.Entities.ChapterInfo{StartPositionTicks=chapterStart}); + var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(executed,http)=await f.Run(original,"Chapter"); + Assert.Equal(reveal,ReferenceEquals(original,executed.Result));Private(http,true); + } + [Theory] + [InlineData(true)][InlineData(false)] + public async Task Disabled_master_or_user_without_scope_preserves_original(bool disableMaster) + { + using var f=new Fixture(guarded:disableMaster);f.Plugin.Plugin.Configuration.SpoilerBlurEnabled=!disableMaster; + var original=new FileContentResult(PosterBlurTests.Source(),"image/png");var(executed,http)=await f.Run(original); + Assert.Same(original,executed.Result);Assert.Equal("original",http.Response.Headers.ETag.ToString()); + } + [Theory] + [InlineData("stream")][InlineData("physical")][InlineData("virtual")] + public async Task All_supported_file_result_shapes_are_protected(string shape) + { + using var f=new Fixture();var bytes=PosterBlurTests.Source();var path=Path.Combine(f.Plugin.Core.Root,"spoiler.png");await File.WriteAllBytesAsync(path,bytes); + using var stream=new MemoryStream(bytes); + IActionResult original=shape switch {"stream"=>new FileStreamResult(stream,"image/png"),"physical"=>new PhysicalFileResult(path,"image/png"),_=>new VirtualFileResult(path,"image/png")}; + var(executed,http)=await f.Run(original);var result=Assert.IsType(executed.Result);Assert.NotEqual(bytes,result.FileContents);Private(http); + } + + [Fact] + public async Task Replaced_stream_result_disposes_original_stream() + { + using var f=new Fixture();using var stream=new MemoryStream(PosterBlurTests.Source()); + var(executed,_)=await f.Run(new FileStreamResult(stream,"image/png")); + Assert.IsType(executed.Result); + Assert.False(stream.CanRead); + } + + [Theory] + [InlineData("HEAD",false)][InlineData("GET",true)] + public async Task Unreplaced_stream_result_retains_mvc_ownership(string method,bool empty) + { + using var f=new Fixture();using var stream=new MemoryStream(empty?[]:PosterBlurTests.Source());var original=new FileStreamResult(stream,"image/png"); + var(executed,_)=await f.Run(original,method:method);Assert.Same(original,executed.Result);Assert.True(stream.CanRead); + } + +} diff --git a/tests/backend/PosterTags/PosterBlurTests.cs b/tests/backend/PosterTags/PosterBlurTests.cs new file mode 100644 index 000000000..26b2d7b7e --- /dev/null +++ b/tests/backend/PosterTags/PosterBlurTests.cs @@ -0,0 +1,62 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using SkiaSharp; +namespace JE.Tests; + +public class PosterBlurTests +{ + internal static byte[] Source(int width=96, int height=64, bool inverse=false) + { + using var bitmap = new SKBitmap(width,height); + for(int y=0;y(double)p.Red); return pixels.Average(p=>Math.Abs(p.Red-mean)); } + [Theory] + [InlineData(1f)][InlineData(15f)][InlineData(100f)] + public void Blur_reduces_detail_returns_jpeg_and_preserves_dimensions(float sigma) + { + using var f=new CoreFixture(); var service=new ImageBlurService(f.Logger); var source=Source(); + var result=service.Blur(source,sigma,null); Assert.NotNull(result); + using var data=SKData.CreateCopy(result); using var codec=SKCodec.Create(data); using var output=SKBitmap.Decode(result); + Assert.Equal(SKEncodedImageFormat.Jpeg,codec.EncodedFormat); Assert.Equal(96,output.Width); Assert.Equal(64,output.Height); + using var original=SKBitmap.Decode(source); Assert.True(Contrast(output)Assert.InRange(p.Red,14,18)); + using var corrupt=SKBitmap.Decode(service.StockCard([1,2,3],null)); Assert.Equal(600,corrupt.Width); Assert.Equal(900,corrupt.Height); + using var fallback=SKBitmap.Decode(service.HardcodedFallbackJpeg); Assert.Equal(16,fallback.Width); Assert.Equal(16,fallback.Height); + } + [Theory] + [InlineData(128,32,128,32)][InlineData(2000,100,1920,96)] + public void Resize_and_blur_bound_output_dimensions(int width,int height,int expectedWidth,int expectedHeight) + { + using var f=new CoreFixture(); var service=new ImageBlurService(f.Logger); var reference=Source(width,height); + using var resized=SKBitmap.Decode(service.ResizeToMatch(Source(),reference,null)); Assert.Equal(expectedWidth,resized.Width); Assert.Equal(expectedHeight,resized.Height); + using var blurred=SKBitmap.Decode(service.Blur(reference,10,null)); Assert.Equal(expectedWidth,blurred.Width); Assert.Equal(expectedHeight,blurred.Height); + using var stock=SKBitmap.Decode(service.StockCard(reference,null)); Assert.Equal(expectedWidth,stock.Width); Assert.Equal(expectedHeight,stock.Height); + } + [Fact] + public async Task Cached_concurrent_outputs_remain_identical_and_distinct_keys_are_isolated() + { + using var f=new CoreFixture(); var service=new ImageBlurService(f.Logger); var source=Source(); + var expected=service.Blur(source,10,"first"); + var results=await Task.WhenAll(Enumerable.Range(0,20).Select(_=>Task.Run(()=>service.Blur(source,10,"first")))); + Assert.All(results,r=>Assert.Same(expected,r)); + var second=service.Blur(Source(32,16),10,"second"); using var output=SKBitmap.Decode(second); Assert.Equal(32,output.Width); + Assert.Same(expected,service.Blur(source,10,"first")); + } +} diff --git a/tests/backend/PosterTags/PosterEpisodeSelectorTests.cs b/tests/backend/PosterTags/PosterEpisodeSelectorTests.cs new file mode 100644 index 000000000..46925e13c --- /dev/null +++ b/tests/backend/PosterTags/PosterEpisodeSelectorTests.cs @@ -0,0 +1,63 @@ +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using Moq; +namespace JE.Tests; + +public class PosterEpisodeSelectorTests +{ + private sealed class StreamEpisode : Episode + { + public List Sources { get; init; }=[]; + public override List GetMediaSources(bool enablePathSubstitution)=>Sources; + } + private static StreamEpisode Episode(int season,params MediaStreamType[] streams)=>new(){Id=Guid.NewGuid(),ParentIndexNumber=season,Sources=[new(){MediaStreams=streams.Select(t=>new MediaStream{Type=t}).ToList()}]}; + private static Mock Library(IReadOnlyList episodes,List offsets) + { + var library=new Mock(); + library.Setup(l=>l.GetItemList(It.IsAny())).Returns((InternalItemsQuery q)=>{offsets.Add(q.StartIndex??0);Assert.Equal(50,q.Limit);Assert.False(q.IsVirtualItem);Assert.True(q.Recursive);Assert.Equal(new[]{BaseItemKind.Episode},q.IncludeItemTypes);return episodes.Skip(q.StartIndex??0).Take(q.Limit??50).ToArray();}); + return library; + } + [Theory] + [InlineData(MediaStreamType.Audio,true)][InlineData(MediaStreamType.Video,true)][InlineData(MediaStreamType.Subtitle,false)][InlineData(MediaStreamType.EmbeddedImage,false)] + public void Representative_requires_actual_audio_or_video_not_merely_media_source(MediaStreamType stream,bool expected)=>Assert.Equal(expected,TagEpisodeSelector.HasAudioOrVideoStreams(Episode(1,stream))); + [Fact] + public void Empty_sources_and_unprobed_files_do_not_supply_tags() + { + Assert.False(TagEpisodeSelector.HasAudioOrVideoStreams(new StreamEpisode()));Assert.False(TagEpisodeSelector.HasAudioOrVideoStreams(Episode(1))); + Assert.False(TagEpisodeSelector.HasAudioOrVideoStreams(new StreamEpisode{Sources=[new(){MediaStreams=null!}]})); + } + [Fact] + public void Selection_walks_past_two_pages_of_unprobed_episodes_and_specials() + { + var unusable=Enumerable.Range(0,55).Select(_=>(BaseItem)Episode(1)).ToList();var special=Episode(0,MediaStreamType.Audio);unusable.Add(special); + unusable.AddRange(Enumerable.Range(0,51).Select(_=>(BaseItem)Episode(0,MediaStreamType.Video)));var regular=Episode(1,MediaStreamType.Video);unusable.Add(regular); + var offsets=new List();var library=Library(unusable,offsets);var container=new Series{Id=Guid.NewGuid()};var user=new User("test","default","default"){Id=Guid.NewGuid()}; + Assert.Same(regular,TagEpisodeSelector.GetFirstEpisode(library.Object,container,user));Assert.Equal(new[]{0,50,100},offsets); + library.Verify(l=>l.GetItemList(It.Is(q=>q.User==user&&q.ParentId==container.Id)),Times.Exactly(3)); + } + [Theory] + [InlineData(false)][InlineData(true)] + public void Specials_fallback_and_special_season_select_first_usable_special(bool season) + { + var first=Episode(0,MediaStreamType.Audio);var second=Episode(0,MediaStreamType.Video);var offsets=new List();var library=Library([Episode(0),first,second],offsets); + BaseItem container=season?new Season():new Series();Assert.Same(first,TagEpisodeSelector.GetFirstEpisode(library.Object,container)); + } + [Fact] + public void Aggregation_visits_every_episode_after_representative_is_found() + { + var episodes=Enumerable.Range(0,101).Select(_=>(BaseItem)Episode(1,MediaStreamType.Audio)).ToArray();var offsets=new List();var visited=new List();var library=Library(episodes,offsets); + var result=TagEpisodeSelector.ScanEpisodes(library.Object,new Series(),null,ep=>{visited.Add(ep);return TagEpisodeSelector.HasAudioOrVideoStreams(ep);},false); + Assert.Same(episodes[0],result);Assert.Equal(episodes,visited);Assert.Equal(new[]{0,50,100},offsets); + } + [Fact] + public void All_unusable_or_empty_library_returns_no_representative() + { + foreach(var episodes in new BaseItem[][]{[],[Episode(1),Episode(0,MediaStreamType.Subtitle)]}){var offsets=new List();Assert.Null(TagEpisodeSelector.GetFirstEpisode(Library(episodes,offsets).Object,new Series()));} + } +} diff --git a/tests/backend/PosterTags/PosterRegressionTests.cs b/tests/backend/PosterTags/PosterRegressionTests.cs new file mode 100644 index 000000000..b2727a524 --- /dev/null +++ b/tests/backend/PosterTags/PosterRegressionTests.cs @@ -0,0 +1,215 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags; +using Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags.Rendering; +using SkiaSharp; + +namespace JE.Tests; + +public class PosterRegressionTests +{ + private static PosterTagSettings Settings(string json = "{}") => PosterTagSettings.FromSettingsJson(json, new PluginConfiguration()); + private static PosterTagItemContext Context(string type = "Movie", bool played = false) => new(type, played, null, null, null); + + [Theory] + [InlineData("", false)] + [InlineData("null", false)] + [InlineData("[]", false)] + [InlineData("{broken", false)] + [InlineData("{qualityTagsEnabled:true}", true)] + [InlineData("{QualityTagsEnabled:true, qualityTagsEnabled:false}", false)] + public void Settings_use_server_lenient_read_and_defaults(string json, bool quality) => Assert.Equal(quality, Settings(json).QualityTagsEnabled); + + [Fact] + public void Missing_settings_inherit_admin_but_existing_empty_file_uses_user_defaults() + { + var config = new PluginConfiguration { QualityTagsEnabled = true }; + Assert.True(PosterTagSettings.FromSettingsJson(null, config).QualityTagsEnabled); + Assert.False(PosterTagSettings.FromSettingsJson("", config).QualityTagsEnabled); + } + + [Theory] + [InlineData("none", null, false)] + [InlineData("auto", "jpn", true)] + [InlineData("fra", "fra", false)] + [InlineData("", "jpn", true)] + public void Audio_preference_precedence(string choice, string? expected, bool followsUser) + { + var settings = PosterTagSettings.FromUserSettings(new UserSettings { QualityTagsPreferredAudioLanguage = choice }, new PluginConfiguration { QualityTagsAudioLanguageFromUser = true, QualityTagsPreferredAudioLanguage = "eng" }, "jpn"); + Assert.Equal(expected, settings.PreferredAudioLanguage); + Assert.Equal(followsUser, settings.UsesJellyfinAudioPreference); + } + + [Fact] + public void Digest_changes_only_when_effective_rendering_changes() + { + Assert.Equal(Settings("{qualityTagsEnabled:false,showResolutionTag:true}").Digest, Settings("{qualityTagsEnabled:false,showResolutionTag:false}").Digest); + Assert.NotEqual(Settings("{qualityTagsEnabled:true,showResolutionTag:true}").Digest, Settings("{qualityTagsEnabled:true,showResolutionTag:false}").Digest); + Assert.Equal(Settings("{qualityTagsEnabled:true,genreTagsEnabled:true}").Digest, Settings("{genreTagsEnabled:true,qualityTagsEnabled:true}").Digest); + } + + [Theory] + [InlineData(true, null, true)] + [InlineData(false, 1, true)] + [InlineData(false, 0, false)] + [InlineData(false, -1, false)] + public void Played_indicator_boundary(bool played, int? count, bool expected) => Assert.Equal(expected, PosterTagResolver.ShowsPlayedIndicator(new("Series", played, count, null, null))); + + [Fact] + public void Disabled_and_empty_metadata_produce_no_tags() + { + Assert.Null(PosterTagResolver.Resolve(new() { Genres = ["Drama"] }, Settings(), Context(), false)); + Assert.Null(PosterTagResolver.Resolve(new(), Settings("{qualityTagsEnabled:true,genreTagsEnabled:true,languageTagsEnabled:true,ratingTagsEnabled:true,ageRatingTagsEnabled:true}"), Context(), false)); + } + + [Theory] + [InlineData(2160, "4K")] + [InlineData(1080, "1080p")] + [InlineData(720, "720p")] + [InlineData(480, "480p")] + public void Quality_detects_stream_height(int height, string expected) + { + var entry = new TagCacheEntry { StreamData = new() { Streams = [new() { Type = "Video", Height = height, Codec = "h264" }] } }; + var result = PosterTagResolver.Resolve(entry, Settings("{qualityTagsEnabled:true}"), Context(), false)!; + Assert.Contains(result.Groups.SelectMany(g => g.Tags), t => t is QualityTag q && q.Label == expected); + Assert.Contains(result.Groups.SelectMany(g => g.Tags), t => t is QualityTag q && q.Label == "H264"); + var filtered = PosterTagResolver.Resolve(entry, Settings("{qualityTagsEnabled:true,showResolutionTag:false}"), Context(), false)!; + Assert.DoesNotContain(filtered.Groups.SelectMany(g => g.Tags), t => t is QualityTag q && q.Category == "resolution"); + } + + [Fact] + public void Rating_scope_and_top_right_offset_follow_viewer_context() + { + var entry = new TagCacheEntry { Type = "Movie", CommunityRating = 8.4f, Genres = ["Drama"] }; + var settings = Settings("{ratingTagsEnabled:true,ratingTagsOnMovies:false,genreTagsEnabled:true}"); + var result = PosterTagResolver.Resolve(entry, settings, Context(played: true), true)!; + Assert.DoesNotContain(result.Groups, g => g.Group == PosterTagGroup.Rating); + Assert.True(result.TopRightOffset); + Assert.True(result.Landscape); + Assert.False(PosterTagResolver.Resolve(entry, settings, Context(), false)!.TopRightOffset); + } + + [Fact] + public void Language_flags_deduplicate_and_mark_partial_audio() + { + var result = PosterTagResolver.Resolve(new() { AudioLanguages = ["eng", "eng", "jpn"], PartialAudioLanguages = ["jpn"] }, Settings("{languageTagsEnabled:true}"), Context(), false)!; + var tags = result.Groups.SelectMany(g => g.Tags).Cast().ToArray(); + Assert.Equal(2, tags.Length); + Assert.Contains(tags, t => t.FlagCode == "jp" && t.Partial); + Assert.Contains(tags, t => t.FlagCode == "gb" && !t.Partial); + } + + private static PosterTagLayout Layout(PosterTagCorner corner = PosterTagCorner.TopLeft) => new([new(PosterTagGroup.Quality, corner, [new QualityTag("4K", "resolution")])], false, false); + private static SKBitmap Poster() { var b = new SKBitmap(180, 270); b.Erase(SKColors.Navy); return b; } + + [Fact] + public void Renderer_changes_tag_corner_preserves_source_and_center() + { + using var renderer = new PosterTagRenderer(); + using var source = Poster(); + using var output = renderer.Render(source, Layout()); + Assert.NotNull(output); + Assert.Equal(source.Width, output.Width); + Assert.Equal(source.Height, output.Height); + Assert.Equal(SKColors.Navy, source.GetPixel(8, 8)); + Assert.Equal(SKColors.Navy, output.GetPixel(90, 135)); + Assert.Contains(Enumerable.Range(0, 40).SelectMany(y => Enumerable.Range(0, 60).Select(x => output.GetPixel(x,y))), p => p != SKColors.Navy); + Assert.All(Enumerable.Range(150, 30).SelectMany(x => Enumerable.Range(200, 70).Select(y => output.GetPixel(x,y))), p => Assert.Equal(SKColors.Navy, p)); + } + + [Theory] + [InlineData(SKEncodedImageFormat.Png, "image/png")] + [InlineData(SKEncodedImageFormat.Jpeg, "image/jpeg")] + [InlineData(SKEncodedImageFormat.Webp, "image/webp")] + public void Encoded_output_retains_format_and_dimensions(SKEncodedImageFormat format, string mime) + { + using var renderer = new PosterTagRenderer(); + using var source = Poster(); + using var image = SKImage.FromBitmap(source); + using var encoded = image.Encode(format, 95); + var bytes = renderer.RenderEncoded(encoded.ToArray(), mime, 95, Layout()); + Assert.NotNull(bytes); + using var data = SKData.CreateCopy(bytes); + using var codec = SKCodec.Create(data); + Assert.Equal(format, codec.EncodedFormat); + Assert.Equal(180, codec.Info.Width); + Assert.Equal(270, codec.Info.Height); + } + + [Fact] + public void Renderer_fails_open_for_empty_corrupt_unsupported_or_disposed_input() + { + using var renderer = new PosterTagRenderer(); + using var source = Poster(); + Assert.Null(renderer.Render(source, new([], false, false))); + Assert.Null(renderer.RenderEncoded([1, 2, 3], "image/png", 90, Layout())); + Assert.Null(renderer.RenderEncoded([1, 2, 3], "image/gif", 90, Layout())); + renderer.Dispose(); + renderer.Dispose(); + Assert.Null(renderer.Render(source, Layout())); + } + + [Fact] + public async Task Concurrent_cached_renders_equal_sequential_outputs() + { + using var renderer = new PosterTagRenderer(); + byte[] Render(PosterTagCorner corner) { using var source = Poster(); using var rendered = renderer.Render(source, Layout(corner)); using var image = SKImage.FromBitmap(rendered!); using var encoded = image.Encode(SKEncodedImageFormat.Png, 100); return encoded.ToArray(); } + var corners = Enum.GetValues(); + var expected = corners.ToDictionary(c => c, Render); + await Task.WhenAll(Enumerable.Range(0, 32).Select(i => Task.Run(() => Assert.Equal(expected[corners[i % 4]], Render(corners[i % 4]))))); + } + [Fact] + public void Cache_budget_eviction_and_disabling_preserve_pixels() + { + using var cached = new PosterTagRenderer(1024); + using var uncached = new PosterTagRenderer(0); + using var source = Poster(); + foreach (var corner in Enum.GetValues()) + { + using var first = cached.Render(source, Layout(corner)); + using var second = uncached.Render(source, Layout(corner)); + Assert.Equal(first!.Pixels, second!.Pixels); + Assert.InRange(cached.TileCacheBytes, 0, 1024); + Assert.Equal(0, uncached.TileCacheCount); + } + cached.ClearTileCache(); + Assert.Equal(0, cached.TileCacheCount); + Assert.Equal(0, cached.TileCacheBytes); + } + + [Theory] + [InlineData(PosterTagCorner.TopLeft)] + [InlineData(PosterTagCorner.TopRight)] + [InlineData(PosterTagCorner.BottomLeft)] + [InlineData(PosterTagCorner.BottomRight)] + public void Shared_corner_groups_stack_without_overlap(PosterTagCorner corner) + { + using var renderer = new PosterTagRenderer(); + var layout = new PosterTagLayout([ + new(PosterTagGroup.Quality, corner, [new QualityTag("4K", "resolution")]), + new(PosterTagGroup.AgeRating, corner, [new AgeRatingTag("PG-13", "PG-13")])], false, false); + var plan = renderer.Plan(180, 270, layout); + Assert.Equal(2, plan.Groups.Count); + var first = plan.Groups[0]; var second = plan.Groups[1]; + Assert.True(first.Y + first.Height <= second.Y || second.Y + second.Height <= first.Y); + Assert.All(plan.Groups, group => { + Assert.InRange(group.X, 0, plan.CardWidth - group.Width); + Assert.InRange(group.Y, 0, plan.CardHeight - group.Height); + }); + var offset = renderer.Plan(180, 270, layout with { TopRightOffset = true }); + if (corner == PosterTagCorner.TopRight) Assert.True(offset.Groups[0].Y > first.Y); + else Assert.Equal(first.Y, offset.Groups[0].Y); + } + + [Theory] + [InlineData("en-baku1926-scouse", "English (Baku)")] + [InlineData("en-pinyin-baku1926", "English (Baku)")] + [InlineData("prs-baku1926", "Dari (Unified Turkic Latin Alphabet)")] + [InlineData("sh-baku1926", "Serbian (Latin, Unified Turkic Latin Alphabet)")] + [InlineData("cnr-baku1926-scouse", "Montenegrin (BAKU1926_SCOUSE)")] + public void Language_name_variant_aliases_match_browser_display_names(string code, string expected) + { + Assert.Equal(expected, Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags.Resolution.LanguageNames.Of(code)); + } + +} diff --git a/tests/backend/PosterTags/PosterSettingsProviderTests.cs b/tests/backend/PosterTags/PosterSettingsProviderTests.cs new file mode 100644 index 000000000..0c9a1dcc8 --- /dev/null +++ b/tests/backend/PosterTags/PosterSettingsProviderTests.cs @@ -0,0 +1,50 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags; +using MediaBrowser.Controller.Library; +using Moq; +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class PosterSettingsProviderTests +{ + [Fact] + public void Settings_save_invalidates_immediately_and_keeps_user_values_isolated() + { + using var f=new ApiPluginFixture();var users=new Mock();using var provider=new PosterTagSettingsProvider(f.Core.Manager,new PosterTagUserCache(users.Object)); + var alice=Guid.NewGuid();var bob=Guid.NewGuid(); + f.Core.Manager.SaveUserConfiguration(alice.ToString("N"),"settings.json",new UserSettings{QualityTagsEnabled=true}); + var a=provider.Get(alice);var b=provider.Get(bob);Assert.True(a.QualityTagsEnabled);Assert.False(b.QualityTagsEnabled);Assert.Same(a,provider.Get(alice)); + f.Core.Manager.SaveUserConfiguration(alice.ToString("D").ToUpperInvariant(),"settings.json",new UserSettings{QualityTagsEnabled=false,GenreTagsEnabled=true}); + var changed=provider.Get(alice);Assert.NotSame(a,changed);Assert.False(changed.QualityTagsEnabled);Assert.True(changed.GenreTagsEnabled);Assert.NotEqual(a.Digest,changed.Digest); + Assert.False(provider.Get(bob).GenreTagsEnabled); + } + [Fact] + public void Unrelated_file_save_does_not_invalidate_snapshot_and_explicit_invalidation_reloads_hand_edit() + { + using var f=new ApiPluginFixture();using var provider=new PosterTagSettingsProvider(f.Core.Manager,new PosterTagUserCache(Mock.Of()));var user=Guid.NewGuid(); + f.Core.Manager.SaveUserConfiguration(user.ToString("N"),"settings.json",new UserSettings{QualityTagsEnabled=true});var before=provider.Get(user); + f.Core.Manager.SaveUserConfiguration(user.ToString("N"),"bookmarks.json",new {Bookmarks=new object[0]});Assert.Same(before,provider.Get(user)); + f.Core.Write(user.ToString("N"),"{\"QualityTagsEnabled\":false}");Assert.Same(before,provider.Get(user));provider.Invalidate(user);Assert.False(provider.Get(user).QualityTagsEnabled); + } + [Fact] + public void Plugin_configuration_replacement_invalidates_admin_dependent_values() + { + using var f=new ApiPluginFixture();using var provider=new PosterTagSettingsProvider(f.Core.Manager,new PosterTagUserCache(Mock.Of()));var user=Guid.NewGuid();var before=provider.Get(user); + f.Plugin.UpdateConfiguration(new PluginConfiguration{NativePosterTagsEnabled=true,QualityTagsEnabled=true});var after=provider.Get(user); + Assert.NotSame(before,after);Assert.True(after.NativeEnabled);Assert.True(after.QualityTagsEnabled);Assert.NotEqual(before.Digest,after.Digest); + } + [Fact] + public void Current_user_audio_preference_changes_invalidate_effective_settings() + { + using var f=new ApiPluginFixture();var id=Guid.NewGuid();var user=new User("test","default","default"){Id=id,AudioLanguagePreference="eng"};var users=new Mock();users.Setup(u=>u.GetUserById(id)).Returns(user); + f.Core.Manager.SaveUserConfiguration(id.ToString("N"),"settings.json",new UserSettings{QualityTagsEnabled=true,QualityTagsPreferredAudioLanguage="auto"});using var provider=new PosterTagSettingsProvider(f.Core.Manager,new PosterTagUserCache(users.Object)); + var before=provider.Get(id);Assert.Equal("eng",before.PreferredAudioLanguage);user.AudioLanguagePreference="jpn";var after=provider.Get(id);Assert.Equal("jpn",after.PreferredAudioLanguage);Assert.NotEqual(before.Digest,after.Digest); + } + [Fact] + public void User_cache_caches_missing_users_and_never_queries_empty_identity() + { + var users=new Mock();var cache=new PosterTagUserCache(users.Object);var missing=Guid.NewGuid();Assert.Null(cache.Get(Guid.Empty));Assert.Null(cache.Get(missing));Assert.Null(cache.Get(missing)); + users.Verify(u=>u.GetUserById(missing),Times.Once);users.Verify(u=>u.GetUserById(Guid.Empty),Times.Never); + } +} diff --git a/tests/backend/PosterTags/PosterTokenTests.cs b/tests/backend/PosterTags/PosterTokenTests.cs new file mode 100644 index 000000000..9eefee08d --- /dev/null +++ b/tests/backend/PosterTags/PosterTokenTests.cs @@ -0,0 +1,79 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags; +namespace JE.Tests; + +public class PosterTokenTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "je-poster-tests-" + Guid.NewGuid().ToString("N")); + private readonly Guid user = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private readonly Guid item = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private PosterTagVariantToken Tokens() => PosterTagVariantToken.CreateForPath(Path.Combine(directory, "secret"), _ => { }); + + [Theory] + [InlineData(PosterTagVariantFlags.None)] + [InlineData(PosterTagVariantFlags.TopRightOffset)] + [InlineData(PosterTagVariantFlags.SpoilerScoped)] + [InlineData(PosterTagVariantFlags.TopRightOffset | PosterTagVariantFlags.SpoilerScoped)] + public void Token_binds_user_item_settings_renderer_and_data(PosterTagVariantFlags flags) + { + var service = Tokens(); + var token = service.Mint(user, item, "settings", "renderer", flags, "abcdef"); + Assert.True(service.Verify(token, user, item, "settings", "renderer", out var parsed)); + Assert.Equal(flags, parsed.Flags); + Assert.Equal("abcdef", parsed.DataHash); + Assert.False(parsed.IsWeak); + Assert.False(service.Verify(token, item, item, "settings", "renderer", out _)); + Assert.False(service.Verify(token, user, user, "settings", "renderer", out _)); + Assert.False(service.Verify(token, user, item, "changed", "renderer", out _)); + Assert.False(service.Verify(token, user, item, "settings", "changed", out _)); + for (int i = 0; i < token.Length; i++) + { + var tampered = token.ToCharArray(); + tampered[i] = tampered[i] == '0' ? '1' : '0'; + Assert.False(service.Verify(new string(tampered), user, item, "settings", "renderer", out _)); + } + Assert.True(Tokens().Verify(token, user, item, "settings", "renderer", out _)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("abcdef")] + [InlineData("0000000000000000000000G")] + [InlineData("40000000000000000000000")] + [InlineData("000000000000000000000000")] + public void Invalid_token_shapes_are_rejected(string? token) => Assert.False(PosterTagVariantToken.TryParse(token, out _)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("ABCDEF")] + [InlineData("abcdeg")] + [InlineData("abcdefg")] + public void Invalid_data_hashes_cannot_be_minted(string? hash) => Assert.Throws(() => Tokens().Mint(user, item, "s", "r", PosterTagVariantFlags.None, hash!)); + + [Fact] + public void Weak_token_is_explicit_and_corrupt_secret_recovers() + { + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "secret"), [1, 2]); + var warnings = new List(); + var service = PosterTagVariantToken.CreateForPath(Path.Combine(directory, "secret"), warnings.Add); + var token = service.Mint(user, item, "s", "r", PosterTagVariantFlags.None, PosterTagVariantToken.WeakDataHash); + Assert.True(service.Verify(token, user, item, "s", "r", out var parsed)); + Assert.True(parsed.IsWeak); + Assert.Single(warnings); + Assert.Equal(32, File.ReadAllBytes(Path.Combine(directory, "secret")).Length); + if (!OperatingSystem.IsWindows()) Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Path.Combine(directory, "secret"))); + Assert.Empty(Directory.GetFiles(directory, "*.tmp.*")); + } + + [Fact] + public async Task Concurrent_first_use_shares_one_persisted_secret() + { + var tokens = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => Tokens().Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc")))); + Assert.Single(tokens.Distinct()); + Assert.Empty(Directory.GetFiles(directory, "*.tmp.*")); + } + + public void Dispose() { if (Directory.Exists(directory)) Directory.Delete(directory, true); } +} diff --git a/tests/backend/Privacy/PrivacyAutoEnableTests.cs b/tests/backend/Privacy/PrivacyAutoEnableTests.cs new file mode 100644 index 000000000..8cabe2f5b --- /dev/null +++ b/tests/backend/Privacy/PrivacyAutoEnableTests.cs @@ -0,0 +1,107 @@ +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.EventHandlers; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +[Collection("Plugin singleton")] +public class PrivacyAutoEnableTests +{ + private static int _tmdbId = 1900000; + [Fact] + public void BulkArmingCountsSkipsPreservesExistingAndDryRunDoesNotPersist() + { + using var f = new JE.Tests.CoreFixture(); var user = Guid.NewGuid().ToString("N"); + var candidates = Enumerable.Range(0, 5).Select(i => new SpoilerAutoEnableArmer.Candidate { Id = Guid.NewGuid(), IdN = Guid.NewGuid().ToString("N"), IsSeries = i % 2 == 0, Name = "Title " + i }).ToArray(); + var state = new UserSpoilerBlur(); state.Series[candidates[0].IdN] = new() { EnabledAt = "original", SeriesName = "original name" }; + f.Manager.SaveUserConfiguration(user, SpoilerBlurImageFilter.SpoilerBlurFileName, state); + SpoilerAutoEnableArmer.SkipReason Skip(SpoilerAutoEnableArmer.Candidate c) => c == candidates[3] ? SpoilerAutoEnableArmer.SkipReason.Watched : c == candidates[4] ? SpoilerAutoEnableArmer.SkipReason.Started : SpoilerAutoEnableArmer.SkipReason.None; + var preview = SpoilerAutoEnableArmer.ArmForUser(f.Manager, user, candidates, Skip, true, "new"); + Assert.Equal(2, preview.Armed); Assert.Equal(1, preview.AlreadyArmed); Assert.Equal(1, preview.SkippedWatched); Assert.Equal(1, preview.SkippedStarted); + Assert.Single(f.Manager.GetUserConfiguration(user, SpoilerBlurImageFilter.SpoilerBlurFileName)!.Series); + var applied = SpoilerAutoEnableArmer.ArmForUser(f.Manager, user, candidates, Skip, false, "new"); Assert.Equal(preview, applied); + var saved = f.Manager.GetUserConfiguration(user, SpoilerBlurImageFilter.SpoilerBlurFileName)!; + Assert.Equal("original", saved.Series[candidates[0].IdN].EnabledAt); Assert.Equal("original name", saved.Series[candidates[0].IdN].SeriesName); + Assert.Equal(2, saved.Series.Count); Assert.Single(saved.Movies); + Assert.Equal(0, SpoilerAutoEnableArmer.ArmForUser(f.Manager, user, candidates, Skip, false, "later").Armed); + } + + [Fact] + public void BulkCandidatesHonorTypeAndLibraryScopeAndRestrictedUsers() + { + var cfg = new PluginConfiguration { SpoilerAutoEnableSeries = false, SpoilerAutoEnableMovies = true }; + var library = new Mock(); var movie = new Movie { Id = Guid.NewGuid(), Name = "Movie" }; var folder = Guid.NewGuid(); + library.Setup(x => x.GetCollectionFolders(movie)).Returns(new List { new CollectionFolder { Id = folder } }); + Assert.Null(SpoilerAutoEnableArmer.TryBuildCandidate(library.Object, new Series(), cfg, null, false)); + Assert.Null(SpoilerAutoEnableArmer.TryBuildCandidate(library.Object, new Episode(), cfg, null, false)); + Assert.Null(SpoilerAutoEnableArmer.TryBuildCandidate(library.Object, movie, cfg, [Guid.NewGuid()], true)); + var candidate = SpoilerAutoEnableArmer.TryBuildCandidate(library.Object, movie, cfg, [folder], true)!; + Assert.Equal(movie.Id, candidate.Id); Assert.Contains(folder, candidate.LibraryIds); + var user = new User("test", "default", "default"); user.SetPermission(PermissionKind.EnableAllFolders, false); + Assert.Empty(SpoilerAutoEnableArmer.VisibleTo(user, [candidate])); + user.SetPermission(PermissionKind.EnableAllFolders, true); + Assert.Same(candidate, Assert.Single(SpoilerAutoEnableArmer.VisibleTo(user, [candidate]))); + } + + [Theory] + [InlineData(1, 1, false, true, true)] + [InlineData(1, 1, true, true, false)] + [InlineData(1, 2, false, true, false)] + [InlineData(2, 1, false, true, false)] + [InlineData(1, 1, false, false, false)] + public async Task FirstPlayConsumerOnlyArmsUnseenS1E1(int season, int episodeNumber, bool previouslyWatched, bool enabled, bool expected) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.SpoilerBlurEnabled = true; f.Plugin.Configuration.SpoilerAutoEnableOnFirstPlay = enabled; + var user = new User("test", "default", "default") { Id = Guid.NewGuid() }; var series = new Series { Id = Guid.NewGuid(), Name = "Series" }; + var episode = new Episode { Id = Guid.NewGuid(), SeriesId = series.Id, ParentIndexNumber = season, IndexNumber = episodeNumber }; + var library = new Mock(); library.Setup(x => x.GetItemById(series.Id)).Returns(series); + library.Setup(x => x.GetItemList(It.IsAny())).Returns((InternalItemsQuery query) => { + Assert.True(query.IsPlayed); Assert.Equal(new[] { series.Id }, query.AncestorIds); Assert.Equal(1, query.Limit); + return previouslyWatched ? new BaseItem[] { episode } : Array.Empty(); }); + var users = new Mock(); users.Setup(x => x.GetUserById(user.Id)).Returns(user); + var consumer = new SpoilerAutoEnableOnFirstPlayConsumer(f.Core.Manager, library.Object, users.Object, f.Core.Logger); + var args = new PlaybackStartEventArgs { Item = episode, Session = new SessionInfo(Mock.Of(), NullLogger.Instance) { UserId = user.Id } }; + await consumer.OnEvent(args); await consumer.OnEvent(args); + var saved = f.Core.Manager.GetUserConfiguration(user.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName); + Assert.Equal(expected ? 1 : 0, saved?.Series.Count ?? 0); + if (expected) Assert.Equal("Series", saved!.Series[series.Id.ToString("N")].SeriesName); + } + + [Fact] + public async Task PendingPromoterRestoresFromDiskAndPromotesOnlyAccessibleUserOnLibraryEvent() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.SpoilerBlurEnabled = true; + var alice = new User("alice", "default", "default") { Id = Guid.NewGuid() }; var bob = new User("bob", "default", "default") { Id = Guid.NewGuid() }; + var tmdb = Interlocked.Increment(ref _tmdbId).ToString(); var pendingKey = "tv:" + tmdb; + var series = new Series { Id = Guid.NewGuid(), Name = "Acquired series", ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Tmdb"] = tmdb } }; + foreach (var user in new[] { alice, bob }) { + var state = new UserSpoilerBlur(); state.PendingTmdb[pendingKey] = new() { TmdbId = tmdb, MediaType = "tv" }; + f.Core.Manager.SaveUserConfiguration(user.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName, state); + } + var users = new Mock(); users.Setup(x => x.GetUserById(alice.Id)).Returns(alice); users.Setup(x => x.GetUserById(bob.Id)).Returns(bob); + var library = new Mock(); library.Setup(x => x.GetItemById(series.Id, alice)).Returns(series); library.Setup(x => x.GetItemById(series.Id, bob)).Returns((BaseItem?)null); + var promoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + f.Core.Manager.UserConfigurationSaved += (id, file) => { if (id == alice.Id.ToString("N") && file == SpoilerBlurImageFilter.SpoilerBlurFileName) promoted.TrySetResult(); }; + var service = new SpoilerSeerrPendingPromoter(library.Object, users.Object, f.Core.Manager, f.Core.Paths.Object, f.Core.Logger); + try { + await service.StartAsync(CancellationToken.None); + library.Raise(x => x.ItemUpdated += null, library.Object, new ItemChangeEventArgs { Item = series }); + await promoted.Task.WaitAsync(TimeSpan.FromSeconds(15)); + var savedAlice = f.Core.Manager.GetUserConfiguration(alice.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName)!; + var savedBob = f.Core.Manager.GetUserConfiguration(bob.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName)!; + Assert.Empty(savedAlice.PendingTmdb); Assert.Equal("Acquired series", savedAlice.Series[series.Id.ToString("N")].SeriesName); + Assert.Contains(pendingKey, savedBob.PendingTmdb.Keys); Assert.Empty(savedBob.Series); + } finally { await service.StopAsync(CancellationToken.None); SpoilerSeerrPendingPromoter.UnregisterPending(pendingKey, alice.Id); SpoilerSeerrPendingPromoter.UnregisterPending(pendingKey, bob.Id); } + } +} diff --git a/tests/backend/Privacy/PrivacyCacheTests.cs b/tests/backend/Privacy/PrivacyCacheTests.cs new file mode 100644 index 000000000..deee78a6a --- /dev/null +++ b/tests/backend/Privacy/PrivacyCacheTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Globalization; +using Moq; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +[Collection("Plugin singleton")] +public class PrivacyCacheTests +{ + [Fact] + public void PendingChangesCoalesceLastIntentIgnoreEmptyAndDrainOnce() + { + var pending = new TagCachePendingChanges(); var first = Guid.NewGuid(); var second = Guid.NewGuid(); + pending.Record(Guid.Empty, true); + for (var i = 0; i < 1000; i++) pending.Record(first, false); + pending.Record(first, true); pending.Record(second, true); pending.Record(second, false); + Assert.Equal(2, pending.Count); + var drained = pending.Drain().ToDictionary(x => x.Id, x => x.Removed); + Assert.True(drained[first]); Assert.False(drained[second]); Assert.True(pending.IsEmpty); Assert.Empty(pending.Drain()); + pending.Record(first, false); Assert.Equal((first, false), Assert.Single(pending.Drain())); + } + + [Fact] + public async Task ConcurrentRecordingAndDrainingDoesNotLoseDistinctItems() + { + var pending = new TagCachePendingChanges(); var ids = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid()).ToArray(); + var received = new ConcurrentDictionary(); + var producer = Task.Run(() => Parallel.ForEach(ids, id => pending.Record(id, true))); + while (!producer.IsCompleted) { foreach (var change in pending.Drain()) { Assert.True(change.Removed); Assert.True(received.TryAdd(change.Id, 0)); } await Task.Yield(); } + await producer; + foreach (var change in pending.Drain()) Assert.True(received.TryAdd(change.Id, 0)); + Assert.Equal(ids.Order(), received.Keys.Order()); + } + + [Theory] + [InlineData(0, 2)] [InlineData(8, 2)] [InlineData(9, 1)] [InlineData(10, 0)] [InlineData(20, 0)] + public void ContinuousUpdatesCannotPostponeFlushBeyondDeadline(int elapsed, int delay) + { + var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(TimeSpan.FromSeconds(delay), TagCacheService.ComputeFlushDelay(start.Ticks, start.AddSeconds(elapsed), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10))); + Assert.Equal(TimeSpan.FromSeconds(2), TagCacheService.ComputeFlushDelay(0, start, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10))); + } + + [Fact] + public void BatchFailureDoesNotPreventOtherChangesAndNoopsDoNotClaimMutation() + { + using var f = new JE.Tests.ApiPluginFixture(); using var cache = Create(f, new Mock()); + var broken = Guid.NewGuid(); var updated = Guid.NewGuid(); var deleted = Guid.NewGuid(); var calls = new List(); + Assert.True(cache.ApplyBatch([(broken, false), (updated, false), (deleted, true)], id => { calls.Add(id); if (id == broken) throw new IOException("failed lookup"); return true; }, id => { calls.Add(id); return false; })); + Assert.Equal(new[] { broken, updated, deleted }, calls); + Assert.False(cache.ApplyBatch([(updated, false), (deleted, true)], _ => false, _ => false)); + } + + [Fact] + public void AccessSetsAreUserScopedCachedAndInvalidatedWithRevisionIndependentOfDelta() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.TagCacheServerMode = true; + var alice = new User("alice", "default", "default") { Id = Guid.NewGuid() }; var bob = new User("bob", "default", "default") { Id = Guid.NewGuid() }; + var first = Guid.NewGuid(); var second = Guid.NewGuid(); var accessible = new[] { first }; var calls = 0; + var library = new Mock(); + library.Setup(x => x.GetItemIds(It.IsAny())).Returns((InternalItemsQuery query) => { calls++; return query.User == alice ? accessible : new[] { second }; }); + Seed(f, new() { [first.ToString("N")] = Entry(100), [second.ToString("N")] = Entry(200) }); + using var cache = Create(f, library); cache.LoadFromDisk(); + Assert.Equal(new[] { first.ToString("N") }, cache.GetCacheForUser(alice, out var version, out var stamp, out var revision).Keys); + Assert.Equal(7, version); Assert.Equal(200, stamp); + Assert.Equal(new[] { second.ToString("N") }, cache.GetCacheForUser(bob, out _, out _, out var bobRevision).Keys); Assert.NotEqual(revision, bobRevision); + Assert.Empty(cache.GetCacheForUser(alice, out _, out _, out var deltaRevision, since: 100)); Assert.Equal(revision, deltaRevision); Assert.Equal(2, calls); + accessible = [first, second]; cache.InvalidateUserAccess(); + Assert.Equal(2, cache.GetCacheForUser(alice, out _, out _, out var fullRevision).Count); Assert.Equal("all", fullRevision); Assert.NotEqual(revision, fullRevision); Assert.Equal(3, calls); + Assert.Single(cache.GetCacheForUser(alice, out _, out _, since: 100)); + Assert.Equal(2, cache.GetCacheForUser(alice, out _, out _, since: 200, alsoInclude: (_, _) => true).Count); + } + + [Fact] + public void RestartPreservesSnapshotAndQueuedRemovalChangesVersionAndSurvivesShutdown() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.TagCacheServerMode = true; + var first = Guid.NewGuid(); var second = Guid.NewGuid(); var library = new Mock(); + Seed(f, new() { [first.ToString("N")] = Entry(100), [second.ToString("N")] = Entry(200) }); + using (var cache = Create(f, library)) { cache.LoadFromDisk(); Assert.Equal(2, cache.Count); cache.EnqueueRemoval(first); } + using var restarted = Create(f, library); restarted.LoadFromDisk(); + Assert.Equal(1, restarted.Count); Assert.True(restarted.Version > 7); Assert.False(restarted.TryGetEntry(first, out _)); Assert.True(restarted.TryGetEntry(second, out var entry)); Assert.Equal(200, entry.LastUpdated); + Assert.False(File.Exists(SnapshotPath(f) + ".tmp")); + } + + [Fact] + public void DisableReleasesMemoryWithoutOverwritingPersistedSnapshotAndReloadRestores() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.TagCacheServerMode = true; + var item = Guid.NewGuid(); Seed(f, new() { [item.ToString("N")] = Entry(100) }); var library = new Mock(); + using var cache = Create(f, library); cache.LoadFromDisk(); var original = File.ReadAllText(SnapshotPath(f)); + f.Plugin.Configuration.TagCacheServerMode = false; cache.OnServerModeDisabled(); cache.SaveToDisk(); + Assert.Equal(0, cache.Count); Assert.False(cache.TryGetEntry(item, out _)); Assert.Equal(original, File.ReadAllText(SnapshotPath(f))); + Assert.Empty(cache.GetCacheForUser(new User("test", "default", "default"), out _, out _)); library.Verify(x => x.GetItemIds(It.IsAny()), Times.Never); + f.Plugin.Configuration.TagCacheServerMode = true; cache.LoadFromDisk(); Assert.True(cache.TryGetEntry(item, out _)); + } + + [Theory] + [InlineData("{")] [InlineData("null")] [InlineData("{\"SchemaVersion\":6,\"Items\":{\"old\":{}}}")] + public void CorruptAndOldSnapshotsRemainUnpublishedAndAreNotOverwritten(string content) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.TagCacheServerMode = true; + Directory.CreateDirectory(f.Core.ConfigRoot); File.WriteAllText(SnapshotPath(f), content); + using var cache = Create(f, new Mock()); cache.LoadFromDisk(); cache.SaveToDisk(); + Assert.Equal(0, cache.Count); Assert.Equal(content, File.ReadAllText(SnapshotPath(f))); + } + + [Fact] + public void FailedAndCancelledFullBuildsKeepPublishedSnapshotAndSuccessfulBuildReplacesIt() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.TagCacheServerMode = true; + var originalId = Guid.NewGuid(); Seed(f, new() { [originalId.ToString("N")] = Entry(100) }); + var library = new Mock(); using var cache = Create(f, library); cache.LoadFromDisk(); + var snapshot = File.ReadAllText(SnapshotPath(f)); + using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); + Assert.ThrowsAny(() => cache.BuildFullCache(null, cancelled.Token)); + Assert.True(cache.TryGetEntry(originalId, out _)); Assert.Equal(7, cache.Version); + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(new[] { Guid.NewGuid() }); + library.Setup(x => x.GetItemList(It.IsAny())).Throws(new IOException("hydration failed")); + Assert.Throws(() => cache.BuildFullCache(null, CancellationToken.None)); + Assert.True(cache.TryGetEntry(originalId, out _)); Assert.Equal(snapshot, File.ReadAllText(SnapshotPath(f))); + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(Array.Empty()); + cache.BuildFullCache(null, CancellationToken.None); + Assert.Equal(0, cache.Count); Assert.Equal(8, cache.Version); Assert.True(cache.LastModified > 200); + using var restarted = Create(f, library); restarted.LoadFromDisk(); Assert.Equal(0, restarted.Count); Assert.Equal(8, restarted.Version); + } + + private static TagCacheService Create(JE.Tests.ApiPluginFixture f, Mock library) => new(library.Object, f.Core.Paths.Object, Mock.Of(), f.Core.Logger); + private static TagCacheEntry Entry(long updated) => new() { Type = "Movie", Genres = ["Drama"], LastUpdated = updated }; + private static string SnapshotPath(JE.Tests.ApiPluginFixture f) => Path.Combine(f.Core.ConfigRoot, "tag-cache.json"); + private static void Seed(JE.Tests.ApiPluginFixture f, Dictionary items) + { + Directory.CreateDirectory(f.Core.ConfigRoot); + // Schema 7 fixture exercises the persisted contract consumed across process restarts. + File.WriteAllText(SnapshotPath(f), JsonSerializer.Serialize(new { SchemaVersion = 7, Version = 7, LastModified = 200, LastReconciledUtcTicks = new DateTime(2026, 1, 1).Ticks, Items = items })); + } +} diff --git a/tests/backend/Privacy/PrivacyFieldFilterTests.cs b/tests/backend/Privacy/PrivacyFieldFilterTests.cs new file mode 100644 index 000000000..4818a1125 --- /dev/null +++ b/tests/backend/Privacy/PrivacyFieldFilterTests.cs @@ -0,0 +1,142 @@ +using System.Security.Claims; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class PrivacyFieldFilterTests +{ + private sealed class Fixture : IDisposable + { + public ApiPluginFixture Plugin { get; } = new(); + public Guid User { get; } = Guid.NewGuid(); + public Guid Series { get; } = Guid.NewGuid(); + public Mock Library { get; } = new(); + public Mock Users { get; } = new(); + public Mock Data { get; } = new(); + private readonly SpoilerNextUnwatchedService next; + private readonly SpoilerFieldStripFilter filter; + public Fixture() + { + Plugin.Plugin.Configuration.SpoilerBlurEnabled = true; + var identities = new RequestIdentityService(Mock.Of(), Users.Object, + new SpoilerIdentityService(Users.Object, Plugin.Core.Logger), Plugin.Core.Logger); + var resolver = new SpoilerUserResolver(Plugin.Core.Manager, Library.Object, Plugin.Core.Logger, identities); + next = new SpoilerNextUnwatchedService(Library.Object, Users.Object, Data.Object, Plugin.Core.Logger); + filter = new SpoilerFieldStripFilter(resolver, Library.Object, Users.Object, Data.Object, next); + var state = new UserSpoilerBlur(); + state.Series[Series.ToString("N")] = new(); + Save(state); + } + public void Save(UserSpoilerBlur state) + { + Plugin.Core.Manager.SaveUserConfiguration(User.ToString("N"), "spoilerblur.json", state); + SpoilerUserResolver.InvalidateUser(User.ToString("N")); + } + public BaseItemDto Episode(bool played = false) => new() + { + Id = Guid.NewGuid(), SeriesId = Series, Type = BaseItemKind.Episode, + Name = "Secret ending", Overview = "Secret plot", OriginalTitle = "Secret original", Path = "/Secret.mkv", + ParentIndexNumber = 2, IndexNumber = 3, UserData = new UserItemDataDto { Key = "test", Played = played }, + Tags = ["Secret tag"], CommunityRating = 9, + }; + public async Task Invoke(object value, string controller = "Items", string action = "GetItems", Guid? user = null) + { + var http = new DefaultHttpContext(); + http.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("Jellyfin-UserId", (user ?? User).ToString())], "Test")); + var descriptor = new ActionDescriptor { RouteValues = new Dictionary { ["controller"] = controller, ["action"] = action } }; + var context = new ActionContext(http, new RouteData(), descriptor, new ModelStateDictionary()); + var result = new ObjectResult(value); + var calls = 0; + await filter.OnActionExecutionAsync(new ActionExecutingContext(context, [], new Dictionary(), this), () => + { + calls++; + return Task.FromResult(new ActionExecutedContext(context, [], this) { Result = result }); + }); + Assert.Equal(1, calls); + return result.Value; + } + public void Dispose() { next.Dispose(); SpoilerUserResolver.InvalidateUser(User.ToString("N")); Plugin.Dispose(); } + } + + [Fact] + public async Task LazyLatestMediaIsMaterializedSoSerializationCannotRecreateUnstrippedItems() + { + using var f = new Fixture(); + var enumerations = 0; + var lazy = Enumerable.Range(0, 2).Select(_ => { enumerations++; return f.Episode(); }); + var result = Assert.IsAssignableFrom>(await f.Invoke(lazy, "UserLibrary", "GetLatestMedia")); + Assert.All(result, item => { Assert.Equal("Season 2, Episode 3", item.Name); Assert.NotEqual("Secret plot", item.Overview); Assert.Null(item.Path); }); + Assert.All(result, item => Assert.Empty(item.Tags)); + Assert.Equal(2, enumerations); + } + + [Fact] + public async Task MixedQueryStripsOnlyGuardedUnwatchedItemsAndPreservesPagination() + { + using var f = new Fixture(); + var hidden = f.Episode(); var watched = f.Episode(true); var outside = f.Episode(); outside.SeriesId = Guid.NewGuid(); + var query = new QueryResult { Items = [hidden, watched, outside], TotalRecordCount = 25, StartIndex = 4 }; + Assert.Same(query, await f.Invoke(query)); + Assert.Equal("Season 2, Episode 3", hidden.Name); + Assert.Equal("Secret ending", watched.Name); Assert.Equal("Secret plot", watched.Overview); + Assert.Equal("Secret ending", outside.Name); Assert.Equal("Secret plot", outside.Overview); + Assert.Equal(25, query.TotalRecordCount); Assert.Equal(4, query.StartIndex); + var other = f.Episode(); await f.Invoke(other, user: Guid.NewGuid()); + Assert.Equal("Secret plot", other.Overview); + } + + [Fact] + public async Task SubtitleDeliveryRemainsUsableWhileTitleBearingPathsAreScrubbed() + { + using var f = new Fixture(); + var dto = f.Episode(); + dto.MediaStreams = [new MediaStream { IsExternal = true, Path = "/Secret.srt", Title = "Secret", DeliveryUrl = "/Videos/1/Subtitles/0/Stream.srt" }, + new MediaStream { IsExternal = true, IsExternalUrl = true, Path = "https://provider/Secret.srt", DeliveryUrl = "https://provider/Secret.srt" }]; + dto.MediaSources = [new MediaSourceInfo { Name = "Secret", Path = "/Secret.mkv", MediaStreams = [new MediaStream { Title = "Secret" }] }]; + await f.Invoke(dto, "UserLibrary", "GetItem"); + Assert.Null(dto.MediaStreams[0].Path); Assert.Null(dto.MediaStreams[0].Title); + Assert.Equal("/Videos/1/Subtitles/0/Stream.srt", dto.MediaStreams[0].DeliveryUrl); + Assert.Null(dto.MediaStreams[1].DeliveryUrl); + Assert.Null(dto.MediaSources[0].Path); Assert.Null(dto.MediaSources[0].Name); Assert.Null(dto.MediaSources[0].MediaStreams[0].Title); + } + + [Fact] + public async Task MovieChapterBoundaryPreservesPastChaptersAndVersionName() + { + using var f = new Fixture(); + var id = Guid.NewGuid(); var state = new UserSpoilerBlur(); state.Movies[id.ToString("N")] = new(); f.Save(state); + var movie = new BaseItemDto { Id = id, Type = BaseItemKind.Movie, Name = "Movie name", Overview = "Secret plot", + UserData = new UserItemDataDto { Key = "test", PlaybackPositionTicks = 100 }, + Chapters = [new ChapterInfo { Name = "Past", StartPositionTicks = 99, ImagePath = "/past.jpg" }, new ChapterInfo { Name = "Boundary secret", StartPositionTicks = 100, ImagePath = "/secret.jpg" }], + MediaSources = [new MediaSourceInfo { Name = "Director's Cut", Path = "/Secret.mkv" }] }; + await f.Invoke(movie); + Assert.Equal("Movie name", movie.Name); Assert.Equal("Director's Cut", movie.MediaSources[0].Name); + Assert.Equal("Past", movie.Chapters[0].Name); Assert.Equal("/past.jpg", movie.Chapters[0].ImagePath); + Assert.Equal("Chapter 2", movie.Chapters[1].Name); Assert.Null(movie.Chapters[1].ImagePath); + } + + [Theory] + [InlineData(false, "Items", "GetItems")] + [InlineData(true, "Other", "GetItems")] + public async Task DisabledAndUnrelatedRoutesPassThrough(bool enabled, string controller, string action) + { + using var f = new Fixture(); f.Plugin.Plugin.Configuration.SpoilerBlurEnabled = enabled; + var dto = f.Episode(); await f.Invoke(dto, controller, action); + Assert.Equal("Secret ending", dto.Name); Assert.Equal("Secret plot", dto.Overview); + } +} diff --git a/tests/backend/Privacy/PrivacyHiddenContentTests.cs b/tests/backend/Privacy/PrivacyHiddenContentTests.cs new file mode 100644 index 000000000..5f28f3321 --- /dev/null +++ b/tests/backend/Privacy/PrivacyHiddenContentTests.cs @@ -0,0 +1,132 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Search; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +[Collection("Plugin singleton")] +public class PrivacyHiddenContentTests +{ + private static async Task Run(JE.Tests.ApiPluginFixture f, Guid user, string controller, string action, object body, string query = "", string? section = null) + { + var http = new DefaultHttpContext { User = PrivacyPolicyTests.Principal(user) }; + http.Request.QueryString = new QueryString(query); + var route = new RouteData(); route.Values["controller"] = controller; route.Values["action"] = action; if (section != null) route.Values["sectionType"] = section; + var context = new ActionContext(http, route, new ActionDescriptor()); + var filters = new List(); + var executed = new ActionExecutedContext(context, filters, new object()) { Result = new ObjectResult(body) }; + var calls = 0; + await new HiddenContentResponseFilter(f.Core.Manager, f.Core.Logger).OnActionExecutionAsync( + new ActionExecutingContext(context, filters, new Dictionary(), new object()), + () => { calls++; return Task.FromResult(executed); }); + Assert.Equal(1, calls); + return Assert.IsType(executed.Result).Value; + } + private static UserHiddenContent Hide(Guid item, string scope = "global", string type = "Movie") => new() + { Items = new() { ["entry"] = new HiddenContentItem { ItemId = item.ToString("D").ToUpperInvariant(), Type = type, HideScope = scope } } }; + private static void Save(JE.Tests.ApiPluginFixture f, Guid user, UserHiddenContent data) + { f.Core.Manager.SaveUserConfiguration(user.ToString("N"), "hidden-content.json", data); HiddenContentResponseFilter.InvalidateUser(user.ToString("N")); } + private static QueryResult Rows(params BaseItemDto[] items) => new(5, 20, items); + + [Theory] + [InlineData("Items", "GetItems")] + [InlineData("Items", "GetItemsByUserIdLegacy")] + [InlineData("Items", "GetResumeItems")] + [InlineData("Items", "GetResumeItemsLegacy")] + [InlineData("TvShows", "GetNextUp")] + [InlineData("TvShows", "GetUpcomingEpisodes")] + [InlineData("Suggestions", "GetSuggestions")] + [InlineData("Suggestions", "GetSuggestionsLegacy")] + public async Task GlobalHideRemovesItemsAndAdjustsPaginationAcrossRoutes(string controller, string action) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); var kept = Guid.NewGuid(); Save(f, user, Hide(hidden)); + var source = Rows(new BaseItemDto { Id = hidden }, new() { Id = kept }); + var result = Assert.IsType>(await Run(f, user, controller, action, source)); + Assert.Equal(kept, Assert.Single(result.Items).Id); Assert.Equal(19, result.TotalRecordCount); Assert.Equal(5, result.StartIndex); + Assert.Equal(2, source.Items.Count); // filtering must not alter the shared source collection + var other = Assert.IsType>(await Run(f, Guid.NewGuid(), controller, action, source)); + Assert.Equal(2, other.Items.Count); + } + + [Fact] + public async Task SeriesHideCascadesToEpisodesAndInvalidationRestoresImmediately() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var series = Guid.NewGuid(); Save(f, user, Hide(series, type: "Series")); + var source = Rows(new BaseItemDto { Id = series }, new() { Id = Guid.NewGuid(), SeriesId = series }, new() { Id = Guid.NewGuid(), SeriesId = Guid.NewGuid() }); + Assert.Single(Assert.IsType>(await Run(f, user, "Items", "GetItems", source)).Items); + Save(f, user, new UserHiddenContent()); + Assert.Equal(3, Assert.IsType>(await Run(f, user, "Items", "GetItems", source)).Items.Count); + } + + [Theory] + [InlineData("continuewatching", "GetResumeItems", true)] + [InlineData("continuewatching", "GetItems", false)] + [InlineData("homesections", "GetResumeItems", true)] + [InlineData("nextup", "GetResumeItems", false)] + public async Task SurfaceScopesDoNotOverHideLibrary(string scope, string action, bool filtered) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); Save(f, user, Hide(hidden, scope)); + var result = Assert.IsType>(await Run(f, user, "Items", action, Rows(new BaseItemDto { Id = hidden }))); + Assert.Equal(filtered ? 0 : 1, result.Items.Count); + } + + [Theory] + [InlineData("?Ids=abc", false)] + [InlineData("?Ids=abc&Recursive=true", true)] + [InlineData("?Ids=abc&ParentId=parent", true)] + [InlineData("?SearchTerm=title", false)] + public async Task MetadataResolverBypassAndSearchPreferenceAreHonored(string query, bool filtered) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); Save(f, user, Hide(hidden)); + var result = Assert.IsType>(await Run(f, user, "Items", "GetItems", Rows(new BaseItemDto { Id = hidden }), query)); + Assert.Equal(filtered ? 0 : 1, result.Items.Count); + } + + [Fact] + public async Task SearchHintsAndLazyLatestEnumerableUseTypedHandlers() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); var kept = Guid.NewGuid(); var settings = Hide(hidden); settings.Settings.FilterSearch = true; Save(f, user, settings); + var hints = new SearchHintResult(new[] { new SearchHint { Id = hidden }, new SearchHint { Id = kept } }, 2); + var search = Assert.IsType(await Run(f, user, "Search", "GetSearchHints", hints)); + Assert.Equal(kept, Assert.Single(search.SearchHints).Id); Assert.Equal(1, search.TotalRecordCount); + var latest = (IEnumerable)(await Run(f, user, "UserLibrary", "GetLatestMedia", new[] { hidden, kept }.Select(id => new BaseItemDto { Id = id })))!; + Assert.Equal(kept, Assert.Single(latest).Id); + } + + [Fact] + public async Task RemoveContinueWatchingWorksWithMasterOffButRespectsUserSurfaceSwitch() + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = false; f.Plugin.Configuration.RemoveContinueWatchingEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); var settings = Hide(hidden, "continuewatching"); Save(f, user, settings); + Assert.Empty(Assert.IsType>(await Run(f, user, "Items", "GetResumeItems", Rows(new BaseItemDto { Id = hidden }))).Items); + settings.Settings.FilterContinueWatching = false; Save(f, user, settings); + Assert.Single(Assert.IsType>(await Run(f, user, "Items", "GetResumeItems", Rows(new BaseItemDto { Id = hidden }))).Items); + } + [Theory] + [InlineData("nextup", 0, true)] + [InlineData("nextup", 100, false)] + [InlineData("continuewatching", 0, false)] + [InlineData("continuewatching", 100, true)] + public async Task MixedHomeSectionUsesEachItemsPlaybackState(string scope, long position, bool filtered) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var hidden = Guid.NewGuid(); Save(f, user, Hide(hidden, scope)); + var source = Rows(new BaseItemDto { Id = hidden, UserData = new UserItemDataDto { Key = "fixture", PlaybackPositionTicks = position } }); + var result = Assert.IsType>(await Run(f, user, "HomeScreen", "GetSectionContent", source, section: "ContinueWatchingNextUp")); + Assert.Equal(filtered ? 0 : 1, result.Items.Count); + } + +} diff --git a/tests/backend/Privacy/PrivacyIdentityTests.cs b/tests/backend/Privacy/PrivacyIdentityTests.cs new file mode 100644 index 000000000..33d44bf92 --- /dev/null +++ b/tests/backend/Privacy/PrivacyIdentityTests.cs @@ -0,0 +1,176 @@ +using System.Net; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +public sealed class PrivacyIdentityTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "je-identity-" + Guid.NewGuid()); + private readonly Mock _users = new(); + private readonly Mock _sessions = new(); + private readonly SpoilerIdentityService _markers; + private readonly RequestIdentityService _identity; + private static int _ipCounter; + private readonly IPAddress _ip = IPAddress.Parse("198.18." + (Interlocked.Increment(ref _ipCounter) / 250) + "." + (_ipCounter % 250 + 1)); + private readonly Guid _alice = Guid.NewGuid(); + private readonly Guid _bob = Guid.NewGuid(); + + public PrivacyIdentityTests() + { + Directory.CreateDirectory(_directory); + var paths = new Mock(); + paths.SetupGet(x => x.LogDirectoryPath).Returns(_directory); + var logger = new Logger(paths.Object, NullLoggerFactory.Instance); + SetUsers(_alice, _bob); + _sessions.SetupGet(x => x.Sessions).Returns(Array.Empty()); + _markers = new SpoilerIdentityService(_users.Object, logger); + _identity = new RequestIdentityService(_sessions.Object, _users.Object, _markers, logger); + } + + private void SetUsers(params Guid[] ids) + { +#if NET9_0 + _users.SetupGet(x => x.Users).Returns(ids.Select(User).ToArray()); +#else + _users.Setup(x => x.GetUsers()).Returns(ids.Select(User).ToArray()); +#endif + } + + private static User User(Guid id) => new("test", "default", "default") { Id = id }; + private DefaultHttpContext Context() { var context = new DefaultHttpContext(); context.Connection.RemoteIpAddress = _ip; return context; } + private SessionInfo Session(Guid id, string? endpoint = null) => new(_sessions.Object, NullLogger.Instance) { UserId = id, RemoteEndPoint = endpoint ?? _ip + ":8096" }; + + [Fact] + public void AuthenticatedIdentityOverridesOtherUsersMarkerAndCookie() + { + var context = Context(); + context.User = PrivacyPolicyTests.Principal(_alice); + context.Request.QueryString = new QueryString("?tag=abc-jeu" + _markers.MintMarker(_bob)); + context.Request.Headers.Cookie = "je-spoiler-uid=" + _bob; + var result = _identity.Resolve(context); + Assert.Equal(IdentityConfidence.Authenticated, result.Confidence); + Assert.Equal(new[] { _alice }, result.Candidates); + Assert.Empty(_users.Invocations); + _sessions.VerifyGet(x => x.Sessions, Times.Never); + } + + [Theory] + [InlineData("query")] + [InlineData("route")] + [InlineData("etag")] + [InlineData("weak-etag")] + public void NativeImageMarkersResolveAcrossSupportedCarriersWithoutSessions(string carrier) + { + var context = Context(); + var tag = "abcdef-jeu" + _markers.MintMarker(_bob); + if (carrier == "query") context.Request.QueryString = new QueryString("?tag=" + tag); + if (carrier == "route") context.Request.RouteValues["tag"] = tag; + if (carrier == "etag") context.Request.Headers.IfNoneMatch = "\"" + tag + "\", \"another\""; + if (carrier == "weak-etag") context.Request.Headers.IfNoneMatch = "W/\"" + tag + "\""; + var result = _identity.Resolve(context); + Assert.Equal(IdentityConfidence.Marker, result.Confidence); + Assert.Equal(new[] { _bob }, result.Candidates); + _sessions.VerifyGet(x => x.Sessions, Times.Never); + } + + [Fact] + public void SharedIpRetainsAllDistinctUsersAndCookieOnlySelectsPresentUser() + { + _sessions.SetupGet(x => x.Sessions).Returns(new[] { Session(_alice), Session(_alice), Session(_bob), Session(Guid.Empty), Session(Guid.NewGuid(), "192.0.2.1:8096") }); + var context = Context(); + var result = _identity.Resolve(context); + Assert.Equal(IdentityConfidence.SharedIpCandidates, result.Confidence); + Assert.Equal(new[] { _alice, _bob }.Order(), result.Candidates.Order()); + context.Request.Headers.Cookie = "je-spoiler-uid=" + _bob; + Assert.Equal(new[] { _bob }, _identity.Resolve(context).Candidates); + Assert.Equal(IdentityConfidence.Cookie, _identity.Resolve(context).Confidence); + context.Request.Headers.Cookie = "je-spoiler-uid=" + Guid.NewGuid(); + Assert.Equal(new[] { _alice, _bob }.Order(), _identity.Resolve(context).Candidates.Order()); + } + + [Fact] + public void FreshLoginCookieRefreshesCachedSessionScan() + { + var sessions = new List { Session(_alice) }; + _sessions.SetupGet(x => x.Sessions).Returns(() => sessions); + var context = Context(); + Assert.Equal(new[] { _alice }, _identity.Resolve(context).Candidates); + sessions.Add(Session(_bob)); + context.Request.Headers.Cookie = "je-spoiler-uid=" + _bob; + var result = _identity.Resolve(context); + Assert.Equal(IdentityConfidence.Cookie, result.Confidence); + Assert.Equal(new[] { _bob }, result.Candidates); + _sessions.VerifyGet(x => x.Sessions, Times.Exactly(2)); + } + + [Fact] + public void RepeatedForgedCookieDoesNotCauseSessionScanStorm() + { + _sessions.SetupGet(x => x.Sessions).Returns(new[] { Session(_alice) }); + var context = Context(); + context.Request.Headers.Cookie = "je-spoiler-uid=" + Guid.NewGuid(); + for (var i = 0; i < 20; i++) Assert.Equal(new[] { _alice }, _identity.Resolve(context).Candidates); + _sessions.VerifyGet(x => x.Sessions, Times.Exactly(2)); + } + + [Fact] + public void TopologyInvalidationRemovesSingleUserShortcutImmediately() + { + SetUsers(_alice); + var context = Context(); + Assert.Equal(IdentityConfidence.SingleUserServer, _identity.Resolve(context).Confidence); + SetUsers(_alice, _bob); + _identity.InvalidateUserTopology(); + Assert.Equal(IdentityConfidence.None, _identity.Resolve(context).Confidence); + } + + [Fact] + public void DeletedUserMarkerStopsResolvingAfterTopologyInvalidation() + { + var marker = _markers.MintMarker(_bob); + Assert.True(_markers.TryResolveMarker(marker, out var resolved)); Assert.Equal(_bob, resolved); + SetUsers(_alice); + _markers.InvalidateMap(); + Assert.False(_markers.TryResolveMarker(marker, out _)); + } + + [Fact] + public void SessionFailuresAndMalformedRowsDoNotInventAnIdentity() + { + _sessions.SetupGet(x => x.Sessions).Throws(new InvalidOperationException("concurrent enumeration")); + Assert.Equal(IdentityConfidence.None, _identity.Resolve(Context()).Confidence); + } + + [Fact] + public void MappedIpv6AndInvalidRowsPreserveHealthySessionMatches() + { + _sessions.SetupGet(x => x.Sessions).Returns(new[] { Session(_bob, "invalid endpoint"), Session(_alice, "[::ffff:" + _ip + "]:8096") }); + Assert.Equal(new[] { _alice }, _identity.Resolve(Context()).Candidates); + } + + [Fact] + public void MarkersAreStableAndUserSpecific() + { + Assert.Matches("^[a-f0-9]{12}$", _markers.MintMarker(_alice)); + Assert.Equal(_markers.MintMarker(_alice), _markers.MintMarker(_alice)); + Assert.NotEqual(_markers.MintMarker(_alice), _markers.MintMarker(_bob)); + } + + [Theory] + [InlineData(null)] [InlineData("")] [InlineData("-jeu123456789abc")] + [InlineData("abc-jeu123456789ab")] [InlineData("abc-jeu123456789ABC")] + [InlineData("abc-jeu123456789abc-extra")] + public void MalformedMarkersCannotResolveAsValidSuffix(string? tag) + => Assert.False(SpoilerIdentityService.TryParseMarker(tag, out _, out _)); + + public void Dispose() => Directory.Delete(_directory, true); +} diff --git a/tests/backend/Privacy/PrivacyPolicyTests.cs b/tests/backend/Privacy/PrivacyPolicyTests.cs new file mode 100644 index 000000000..c65442958 --- /dev/null +++ b/tests/backend/Privacy/PrivacyPolicyTests.cs @@ -0,0 +1,117 @@ +using System.Security.Claims; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers.Jellyseerr; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +public class PrivacyPolicyTests +{ + [Theory] + [InlineData(null, null, true, null, null, false)] + [InlineData(null, null, false, 0, 0, true)] + [InlineData(18, 9, true, null, 0, true)] + [InlineData(12, 0, false, 12, 0, true)] + [InlineData(13, 0, false, 12, 0, false)] + [InlineData(6, 1, false, 12, 0, false)] + [InlineData(12, null, false, 12, 0, true)] + [InlineData(12, 99, false, 12, null, true)] + public void RatingCeilingsAndUnratedPolicy(int? score, int? sub, bool blockUnrated, int? max, int? maxSub, bool allowed) + => Assert.Equal(allowed, ParentalRatingDecision.IsAllowed(score, sub, blockUnrated, max, maxSub)); + + [Theory] + [InlineData("family", "horror", "horror", "family", false)] + [InlineData("FAMILY", "", "", "family", true)] + [InlineData("", "family", "", "family", false)] + [InlineData("family", "", "", "family!", false)] + [InlineData("cafe", "", "", "café", false)] + [InlineData("horror comedy", "", "horror", "", true)] + [InlineData("", "", "", "", true)] + public void TagsUseWholeValuesBlockedWinsAndGenresCannotGrantAccess(string keyword, string genre, string blocked, string allowed, bool expected) + => Assert.Equal(expected, ParentalTagDecision.IsAllowed( + ParentalTagDecision.ToTagSet([keyword]), ParentalTagDecision.ToTagSet([genre]), + ParentalTagDecision.ToTagSet([blocked]), ParentalTagDecision.ToTagSet([allowed]))); + + [Fact] + public void TagSetsDropBlanksAndDeduplicateWithoutRemovingPunctuation() + { + var tags = ParentalTagDecision.ToTagSet([null, "", " ", " FAMILY! ", "family!", "family"]); + Assert.Equal(2, tags.Count); + Assert.Contains("family!", tags); + Assert.Contains("family", tags); + Assert.Empty(ParentalTagDecision.ToTagSet(null)); + } + + [Fact] + public void ExplicitUserSelectionRequiresSelfOrAdministrator() + { + var self = Guid.NewGuid(); + var other = Guid.NewGuid(); + var principal = Principal(self); + Assert.Equal(self, UserHelper.GetUserId(principal, null)); + Assert.Equal(self, UserHelper.GetUserId(principal, Guid.Empty)); + Assert.Equal(self, UserHelper.GetUserId(principal, self)); + Assert.Null(UserHelper.GetUserId(principal, other)); + Assert.Equal(other, UserHelper.GetUserId(Principal(self, true), other)); + Assert.Null(UserHelper.GetUserId(new ClaimsPrincipal(), other)); + Assert.Null(UserHelper.GetCurrentUserId(new ClaimsPrincipal(new ClaimsIdentity([new Claim("Jellyfin-UserId", "invalid")])))); + } + + internal static ClaimsPrincipal Principal(Guid id, bool admin = false) => new(new ClaimsIdentity( + new[] { new Claim("Jellyfin-UserId", id.ToString()), new Claim(ClaimTypes.Role, admin ? "Administrator" : "User") }, "Test")); + + [Fact] + public void PoliciesRequireEnabledFeatureAndNonemptyScope() + { + var config = new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = true }; + Assert.Null(SpoilerTagDataStripper.CreatePolicy(null, new UserSpoilerBlur())); + Assert.Null(SpoilerTagDataStripper.CreatePolicy(config, null)); + Assert.Null(SpoilerTagDataStripper.CreatePolicy(config, new UserSpoilerBlur())); + var state = new UserSpoilerBlur(); + state.Movies[Guid.NewGuid().ToString("N")] = new(); + Assert.NotNull(SpoilerTagDataStripper.CreatePolicy(config, state)); + config.SpoilerBlurEnabled = false; + Assert.Null(SpoilerTagDataStripper.CreatePolicy(config, state)); + } + + [Fact] + public void EveryAdminAndUserPreferenceCombinationRespectsAdminCapAndUserOptOut() + { + // Four independent categories: enumerate all admin masks and nullable user choices. + for (var admin = 0; admin < 16; admin++) + for (var prefs = 0; prefs < 81; prefs++) + { + bool?[] choices = new bool?[4]; + var remaining = prefs; + for (var i = 0; i < 4; i++) { choices[i] = (remaining % 3) switch { 0 => null, 1 => false, _ => true }; remaining /= 3; } + var config = new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = (admin & 1) != 0, + SpoilerStripRatings = (admin & 2) != 0, SpoilerReplaceTitle = (admin & 4) != 0, SpoilerStripOverview = (admin & 8) != 0 }; + var state = new UserSpoilerBlur { Prefs = new SpoilerBlurUserPrefs { HideTags = choices[0], HideRatings = choices[1], + ReplaceEpisodeTitles = choices[2], HideEpisodeDescriptions = choices[3] } }; + state.Collections[Guid.NewGuid().ToString("N")] = new(); + var expected = Enumerable.Range(0, 4).Select(i => (admin & (1 << i)) != 0 && choices[i] != false).ToArray(); + var policy = SpoilerTagDataStripper.CreatePolicy(config, state); + if (!expected.Any(x => x)) Assert.Null(policy); + else { Assert.NotNull(policy); Assert.Equal(expected, new[] { policy.StripGenres, policy.StripRatings, policy.ReplaceTitle, policy.StripOverview }); + Assert.Equal(expected[2] || expected[3], policy.SanitizeTitleStreams); } + } + } + + [Theory] + [InlineData("{}")] // pre-movie/collection versions + [InlineData("{\"Series\":null,\"Movies\":null,\"Collections\":null}")] + public void LegacyAndNullSpoilerScopesDeserializeSafely(string json) + { + var state = System.Text.Json.JsonSerializer.Deserialize(json)!; + Assert.Empty(state.Series); Assert.Empty(state.Movies); Assert.Empty(state.Collections); + } + + [Fact] + public void DeserializedSpoilerScopesRemainCaseInsensitive() + { + var state = System.Text.Json.JsonSerializer.Deserialize("{\"Series\":{\"ABCDEF\":{}},\"Movies\":{\"ABCDEF\":{}},\"Collections\":{\"ABCDEF\":{}}}")!; + Assert.True(state.Series.ContainsKey("abcdef")); Assert.True(state.Movies.ContainsKey("abcdef")); Assert.True(state.Collections.ContainsKey("abcdef")); + } +} diff --git a/tests/backend/Privacy/PrivacySpoilerStripTests.cs b/tests/backend/Privacy/PrivacySpoilerStripTests.cs new file mode 100644 index 000000000..c07a37b65 --- /dev/null +++ b/tests/backend/Privacy/PrivacySpoilerStripTests.cs @@ -0,0 +1,97 @@ +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Moq; +using Xunit; + +namespace JellyfinEnhanced.Tests; + +public class PrivacySpoilerStripTests +{ + [Theory] + [InlineData(false)] [InlineData(true)] + public void GuardedEpisodeStripsOnlyUnwatchedAndDoesNotMutateSharedEntry(bool watched) + { + var library = new Mock(); var userData = new Mock(); + var user = new User("test", "default", "default") { Id = Guid.NewGuid() }; + var episode = new Episode { Id = Guid.NewGuid(), SeriesId = Guid.NewGuid() }; + userData.Setup(x => x.GetUserData(user, episode)).Returns(new UserItemData { Key = "test", Played = watched }); + var stripper = new SpoilerTagDataStripper(library.Object, userData.Object, null!); + var state = new UserSpoilerBlur(); state.Series[episode.SeriesId.ToString("N")] = new(); + var policy = SpoilerTagDataStripper.CreatePolicy(new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = true, SpoilerStripRatings = true }, state)!; + var source = Entry(episode.SeriesId); + var result = stripper.StripEntry(policy, user, episode, source, out var stripped); + Assert.Equal(!watched, stripped); + Assert.Equal(watched ? SpoilerTagDataStub.None : SpoilerTagDataStub.Episode, stripper.GetTagDataStub(policy, user, episode)); + if (watched) Assert.Same(source, result); + else { Assert.NotSame(source, result); Assert.Empty(result.Genres!); Assert.Null(result.AudioLanguages); Assert.Null(result.StreamData); Assert.Null(result.CommunityRating); Assert.Null(result.CriticRating); } + Assert.Equal(new[] { "Mystery" }, source.Genres); Assert.Equal(8f, source.CommunityRating); Assert.Equal("The killer", source.StreamData!.ItemName); + var unguarded = new UserSpoilerBlur(); unguarded.Series[Guid.NewGuid().ToString("N")] = new(); + var otherPolicy = SpoilerTagDataStripper.CreatePolicy(new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = true }, unguarded)!; + Assert.Same(source, stripper.StripEntry(otherPolicy, user, episode, source, out var otherStripped)); + Assert.False(otherStripped); + } + + [Fact] + public void TitleOnlyPolicyDeepClonesAndSanitizesStreamTitlesPathsWithoutDroppingQuality() + { + var user = new User("test", "default", "default") { Id = Guid.NewGuid() }; + var series = new Series { Id = Guid.NewGuid() }; + var state = new UserSpoilerBlur(); state.Series[series.Id.ToString("N")] = new(); + var policy = SpoilerTagDataStripper.CreatePolicy(new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = false, + SpoilerStripRatings = false, SpoilerReplaceTitle = true, SpoilerStripOverview = false }, state)!; + var stripper = new SpoilerTagDataStripper(Mock.Of(), Mock.Of(), null!); + var source = Entry(series.Id); source.Type = "Series"; + var result = stripper.StripEntry(policy, user, series, source, out var stripped); + Assert.True(stripped); Assert.NotSame(source.StreamData, result.StreamData); + Assert.Null(result.StreamData!.ItemName); Assert.Null(result.StreamData.ItemPath); + var stream = Assert.Single(result.StreamData.Streams!); Assert.Null(stream.DisplayTitle); Assert.Equal("hevc", stream.Codec); Assert.Equal(2160, stream.Height); + var sourceInfo = Assert.Single(result.StreamData.Sources!); Assert.Null(sourceInfo.Name); Assert.Null(sourceInfo.Path); + Assert.Equal("Final reveal", source.StreamData!.Streams![0].DisplayTitle); + Assert.Equal("secret.mkv", source.StreamData.Sources![0].Path); + Assert.Equal(source.Genres, result.Genres); Assert.Equal(source.CommunityRating, result.CommunityRating); + } + + [Fact] + public void FailedPlayedLookupFailsClosedAndPreservesOtherUsersSource() + { + using var fixture = new JE.Tests.CoreFixture(); + var library = new Mock(); var userData = new Mock(); + var user = new User("test", "default", "default") { Id = Guid.NewGuid() }; + var episode = new Episode { Id = Guid.NewGuid(), SeriesId = Guid.NewGuid() }; + userData.Setup(x => x.GetUserData(user, episode)).Throws(new IOException("fixture failure")); + var resolver = new SpoilerUserResolver(fixture.Manager, library.Object, fixture.Logger, null!); + var stripper = new SpoilerTagDataStripper(library.Object, userData.Object, resolver); + var state = new UserSpoilerBlur(); state.Series[episode.SeriesId.ToString("N")] = new(); + var policy = SpoilerTagDataStripper.CreatePolicy(new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = true }, state)!; + var source = Entry(episode.SeriesId); + Assert.Empty(stripper.StripEntry(policy, user, episode, source, out var stripped).Genres!); + Assert.True(stripped); Assert.NotEmpty(source.Genres!); + } + + [Theory] + [InlineData(0)] [InlineData(1)] + public void SpecialsAndFirstSeasonRetainTagsButRemoveFallbackRatings(int number) + { + var library = new Mock(); + var season = new Season { Id = Guid.NewGuid(), SeriesId = Guid.NewGuid(), IndexNumber = number }; + library.Setup(x => x.GetItemById(season.Id)).Returns(season); + var state = new UserSpoilerBlur(); state.Series[season.SeriesId.ToString("N")] = new(); + var policy = SpoilerTagDataStripper.CreatePolicy(new PluginConfiguration { SpoilerBlurEnabled = true, SpoilerStripTags = true, SpoilerStripRatings = true }, state)!; + var stripper = new SpoilerTagDataStripper(library.Object, Mock.Of(), null!); + var source = Entry(season.SeriesId); source.Type = "Season"; + var user = new User("test", "default", "default"); + var result = stripper.StripEntry(policy, user, season, source, out var stripped); + Assert.True(stripped); Assert.Equal(source.Genres, result.Genres); Assert.Same(source.StreamData, result.StreamData); + Assert.Null(result.CommunityRating); Assert.Null(result.CriticRating); Assert.Equal(8f, source.CommunityRating); + Assert.Equal(SpoilerTagDataStub.None, stripper.GetTagDataStub(policy, user, season)); + } + + private static TagCacheEntry Entry(Guid seriesId) => new() { Type = "Episode", SeriesId = seriesId.ToString("N"), Genres = ["Mystery"], + CommunityRating = 8, CriticRating = 90, AudioLanguages = ["en"], StreamData = new TagStreamData { ItemName = "The killer", ItemPath = "secret.mkv", + Streams = [new TagMediaStream { Codec = "hevc", Height = 2160, DisplayTitle = "Final reveal" }], Sources = [new TagMediaSource { Name = "The killer", Path = "secret.mkv" }] } }; +} diff --git a/tests/backend/Privacy/ReviewActivityVisibilityTests.cs b/tests/backend/Privacy/ReviewActivityVisibilityTests.cs new file mode 100644 index 000000000..954a5cfd9 --- /dev/null +++ b/tests/backend/Privacy/ReviewActivityVisibilityTests.cs @@ -0,0 +1,99 @@ +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Moq; + +namespace JE.Tests; + +public class ReviewActivityVisibilityTests +{ + private static User User(Guid? id = null) => new("author", "default", "default") { Id = id ?? Guid.NewGuid() }; + + [Fact] + public void AllModerationCombinationsPreserveAdministratorSelfAndOrphanRules() + { + foreach (var exists in new[] {false,true}) + foreach (var hidden in new[] {false,true}) + foreach (var disabled in new[] {false,true}) + foreach (var hideHidden in new[] {false,true}) + foreach (var hideDisabled in new[] {false,true}) + foreach (var admin in new[] {false,true}) + foreach (var self in new[] {false,true}) + { + var author = User(); author.SetPermission(PermissionKind.IsHidden, hidden); author.SetPermission(PermissionKind.IsDisabled, disabled); + var review = new UserReview { UserId = author.Id.ToString("N") }; + var expected = admin || (exists && self) || (exists ? !(hidden && hideHidden || disabled && hideDisabled) : !(hideHidden || hideDisabled)); + var actual = ReviewVisibility.IsVisible(review, admin, self ? review.UserId.ToUpperInvariant() : Guid.NewGuid().ToString("N"), hideHidden, hideDisabled, + _ => exists ? author : null, out var resolved, out var orphan); + Assert.Equal(expected, actual); Assert.Equal(exists ? author : null, resolved); + Assert.Equal(!exists && !admin && (hideHidden || hideDisabled), orphan); + } + } + + [Fact] + public void MalformedAuthorNeverTriggersLookupAndCannotClaimSelfVisibility() + { + var review = new UserReview { UserId = "malformed" }; + Assert.False(ReviewVisibility.IsVisible(review, false, "malformed", true, true, _ => throw new Exception("unexpected lookup"), out var author, out var orphan)); + Assert.Null(author); Assert.True(orphan); + } + + [Fact] + public void AuthorCacheSeparatesUsersCachesDeletionAndRejectsLookupRacingInvalidation() + { + var first = User(); var second = User(); var calls = 0; + User? Lookup(Guid id) { calls++; return id == first.Id ? first : null; } + Assert.Same(first, ReviewAuthorCache.Resolve(first.Id, Lookup)); + Assert.Same(first, ReviewAuthorCache.Resolve(first.Id, Lookup)); + Assert.Null(ReviewAuthorCache.Resolve(second.Id, Lookup)); Assert.Null(ReviewAuthorCache.Resolve(second.Id, Lookup)); + Assert.Equal(2,calls); + var race = Guid.NewGuid(); + Assert.Same(first, ReviewAuthorCache.Resolve(race, _ => { ReviewAuthorCache.Invalidate(); return first; })); + Assert.Same(second, ReviewAuthorCache.Resolve(race, _ => second)); + Assert.Same(second, ReviewAuthorCache.Resolve(race, _ => throw new Exception("expected fresh cache"))); + } + + [Fact] + public void FeedFiltersBeforeLimitAndDoesNotReusePermissionsOrAuthorsAcrossRequests() + { + using var env = new CoreFixture(); + var viewer = User(); var author = User(); var blocked = Guid.NewGuid(); var allowed = Guid.NewGuid(); + var library = new Mock(); var users = new Mock(); var repository = new Mock(); + var allowedIds = new[] { allowed }; + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(() => allowedIds); + library.Setup(x => x.GetItemById(allowed)).Returns(new Movie { Id = allowed, Name = "Visible movie" }); + users.Setup(x => x.GetUserById(author.Id)).Returns(author); + var builder = new ActivityFeedBuilder(library.Object,users.Object,repository.Object,env.Logger); + var config = new PluginConfiguration { ActivityFeedShowWatched = true, HideReviewsFromHiddenUsers = true }; + var entries = new[] {new ActivityEntry { UserId = author.Id.ToString("N"), ItemId = blocked.ToString("N"), ActivityType = "Watched", OccurredAt = "2025-01-02T00:00:00Z" }, + new ActivityEntry { UserId = author.Id.ToString("N"), ItemId = allowed.ToString("N"), ActivityType = "Watched", OccurredAt = "2025-01-01T00:00:00Z", Completed = true, Progress = 1 }}; + var row = Assert.Single(builder.Build(entries, [], viewer, false, config, 1)); + Assert.Equal("author",row.UserName); Assert.True(row.Completed); Assert.Equal(1,row.Progress); + library.Verify(x => x.GetItemById(blocked), Times.Never); + allowedIds = []; + Assert.Empty(builder.Build(entries, [], User(), false, config, 1)); + allowedIds = [allowed]; author.SetPermission(PermissionKind.IsHidden,true); + Assert.Empty(builder.Build(entries, [], viewer, false, config, 1)); + Assert.Single(builder.Build(entries, [], author, false, config, 1)); + Assert.Single(builder.Build(entries, [], viewer, true, config, 1)); + } + + [Fact] + public void DisabledFeedCategoriesAndCancellationNeverQueryLibrary() + { + using var env = new CoreFixture(); + var library = new Mock(MockBehavior.Strict); + var builder = new ActivityFeedBuilder(library.Object,Mock.Of(),Mock.Of(),env.Logger); + var entry = new ActivityEntry { UserId = Guid.NewGuid().ToString("N"), ItemId = Guid.NewGuid().ToString("N"), ActivityType = "Watched", OccurredAt = "2025-01-01T00:00:00Z" }; + Assert.Empty(builder.Build([entry], [], User(), false, new PluginConfiguration { ActivityFeedShowWatched = false }, 1)); + using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); + Assert.Throws(() => builder.Build([entry], [], User(), false, new PluginConfiguration(), 1, cancelled.Token)); + } +} diff --git a/tests/backend/Privacy/UrlPolicyTests.cs b/tests/backend/Privacy/UrlPolicyTests.cs new file mode 100644 index 000000000..ddc2e3959 --- /dev/null +++ b/tests/backend/Privacy/UrlPolicyTests.cs @@ -0,0 +1,56 @@ +using System.Net; +using System.Reflection; +using Jellyfin.Plugin.JellyfinEnhanced.Helpers; + +namespace JE.Tests; + +public class UrlPolicyTests +{ + // Supply resolved addresses directly to exercise the real policy without + // changing machine DNS or relying on external resolver responses. + private static readonly Func ResolvedPolicy = + typeof(ArrUrlGuard).GetMethod("AreResolvedAddressesAllowed", BindingFlags.Static | BindingFlags.NonPublic)! + .CreateDelegate>(); + + [Theory] + [InlineData("host.containers.internal", "169.254.1.2", true)] + [InlineData("host.docker.internal", "169.254.1.2", true)] + [InlineData("HOST.CONTAINERS.INTERNAL", "169.254.1.2", true)] + [InlineData("ordinary.internal", "169.254.1.2", false)] + [InlineData("host.containers.internal.attacker.invalid", "169.254.1.2", false)] + [InlineData("host.containers.internal", "169.254.169.254", false)] + [InlineData("host.containers.internal", "169.254.169.1", false)] + [InlineData("host.docker.internal", "169.254.170.9", false)] + [InlineData("host.containers.internal", "::ffff:169.254.169.254", false)] + [InlineData("host.containers.internal", "fd00:ec2::254", false)] + [InlineData("ordinary.internal", "10.0.0.2", true)] + [InlineData("ordinary.internal", "127.0.0.1", true)] + public void ContainerAliasesPermitPodmanLinkLocalWithoutPermittingCloudMetadata(string host, string address, bool expected) + { + Assert.Equal(expected, ResolvedPolicy(host, [IPAddress.Parse(address)])); + } + + [Fact] + public void EveryResolvedAddressMustBeSafeRegardlessOfOrdering() + { + var safe = IPAddress.Parse("10.0.0.2"); + var unsafeAddress = IPAddress.Parse("100.100.100.200"); + Assert.False(ResolvedPolicy("host.docker.internal", [safe, unsafeAddress])); + Assert.False(ResolvedPolicy("host.docker.internal", [unsafeAddress, safe])); + } + + [Theory] + [InlineData("http://[::ffff:169.254.169.254]/latest", false)] + [InlineData("http://[fd00:ec2::254]/", false)] + [InlineData("http://metadata.google.internal./latest", false)] + [InlineData("http://METADATA.GOOG/latest", false)] + [InlineData("http://0.0.0.0", false)] + [InlineData("http://[::]", false)] + [InlineData("http://[::1]:8989/base", true)] + [InlineData("https://192.168.1.2/base", true)] + public async Task LiteralAndKnownHostRulesMatchInSyncAndAsyncEntryPoints(string url, bool expected) + { + Assert.Equal(expected, ArrUrlGuard.IsAllowedUrl(url)); + Assert.Equal(expected, await ArrUrlGuard.IsAllowedUrlAsync(url)); + } +} diff --git a/tests/backend/README.md b/tests/backend/README.md new file mode 100644 index 000000000..14657228e --- /dev/null +++ b/tests/backend/README.md @@ -0,0 +1,16 @@ +# Backend tests + +`JE.Tests.csproj` is the single xUnit project for both Jellyfin compatibility targets. Feature directories organize cases; the SDK automatically includes nested C# files, so adding a test does not require editing a file list. + +```sh +python3 tests/run.py backend +dotnet test tests/backend/JE.Tests.csproj -p:JellyfinTarget=jf12 --filter FullyQualifiedName~Privacy +``` + +Run from the repository root with the runtimes described in the [setup guide](../docs/README.md). Use distinct `--artifacts-path` values when building targets concurrently. + +`Api/` covers controller and request-pipeline contracts; `Core/` covers configuration, persistence and core services; `Integrations/` covers external providers using synthetic transports; `PosterTags/` covers native rendering and tag caches; `Privacy/` covers identity, policy and content isolation; `ScheduledTasks/` covers background jobs. + +Reuse fixtures in `Support/` for disposable storage, the plugin singleton and controlled HTTP. Tests changing the singleton must use `[Collection("Plugin singleton")]`. Keep single-feature fakes with their tests. Assert public responses, persistence and observable side effects; do not turn a production implementation into its own expected-value oracle. + +Real Jellyfin installations belong to `../host/`, not this project. History replay manifests select these same tests rather than maintaining duplicate implementations. diff --git a/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs b/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs new file mode 100644 index 000000000..8d764feae --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class TaskAutoRequestTests +{ + [Theory] + [InlineData(2, false, false, 0)] + [InlineData(3, false, false, 0)] + [InlineData(5, false, false, 0)] + [InlineData(1, true, false, 0)] + [InlineData(1, false, false, 1)] + [InlineData(1, false, true, 2)] + public async Task MovieCollectionDecisionsDeduplicateSuccessAndRetryFailure(int status, bool futureRelease, bool failFirst, int expectedPosts) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.AutoMovieRequestEnabled = true; + config.JellyseerrEnabled = true; + config.TMDB_API_KEY = "test-tmdb"; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "test-seerr"; + config.AutoMovieRequestCheckReleaseDate = true; + config.AutoMovieRequestQualityMode = "custom"; + config.AutoMovieRequestCustomServerId = 0; + config.AutoMovieRequestCustomProfileId = 7; + config.AutoMovieRequestCustomRootFolder = "/movies"; + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var users = new Mock(); + users.Setup(u => u.GetUserById(user.Id)).Returns(user); + var movie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }; + var posts = new List<(string? Identity, string Body)>(); + var lookups = 0; + using var transport = new IntegrationTransport(async (request, ct) => + { + switch (request.RequestUri!.AbsolutePath) + { + case "/3/movie/123": return IntegrationTransport.Response("{\"belongs_to_collection\":{\"id\":9,\"name\":\"Saga\"}}"); + case "/api/v1/collection/9": return IntegrationTransport.Response(JsonSerializer.Serialize(new { parts = new[] { new { id = 123, title = "First", releaseDate = "2000-01-01", mediaInfo = new { status = 5 } }, new { id = 124, title = "Second", releaseDate = futureRelease ? "2999-01-01" : "2001-01-01", mediaInfo = new { status } } } })); + case "/api/v1/user": + lookups++; + return IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 22, jellyfinUserId = user.Id.ToString("N").ToUpperInvariant() } } })); + case "/api/v1/request": + posts.Add((request.Headers.GetValues("X-Api-User").Single(), await request.Content!.ReadAsStringAsync(ct))); + return IntegrationTransport.Response("{}", failFirst && posts.Count == 1 ? 500 : 201); + default: throw new InvalidOperationException("Unexpected URL " + request.RequestUri); + } + }); + var service = new AutoMovieRequestService(transport, f.Core.Logger, users.Object, null!); + await service.CheckMovieForCollectionRequestAsync(movie, user.Id); + await service.CheckMovieForCollectionRequestAsync(movie, user.Id); + Assert.Equal(expectedPosts, posts.Count); + Assert.Equal(expectedPosts > 0 ? 1 : 0, lookups); + foreach (var post in posts) + { + Assert.Equal("22", post.Identity); + using var payload = JsonDocument.Parse(post.Body); + Assert.Equal("movie", payload.RootElement.GetProperty("mediaType").GetString()); + Assert.Equal(124, payload.RootElement.GetProperty("mediaId").GetInt32()); + Assert.Equal(0, payload.RootElement.GetProperty("serverId").GetInt32()); + Assert.Equal(7, payload.RootElement.GetProperty("profileId").GetInt32()); + Assert.Equal("/movies", payload.RootElement.GetProperty("rootFolder").GetString()); + } + if (expectedPosts == 1) + { + service.ClearRequestCache(); + await service.CheckMovieForCollectionRequestAsync(movie, user.Id); + Assert.Equal(2, posts.Count); + Assert.Equal(1, lookups); + } + } + + [Theory] + [InlineData(false, true)][InlineData(true, false)] + public async Task DisabledAutomaticRequestsDoNotConsultUsersOrNetwork(bool feature, bool seerr) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AutoMovieRequestEnabled = feature; + f.Plugin.Configuration.AutoSeasonRequestEnabled = feature; + f.Plugin.Configuration.JellyseerrEnabled = seerr; + await new AutoMovieRequestService(null!, f.Core.Logger, null!, null!).CheckMovieForCollectionRequestAsync(new Movie(), Guid.NewGuid()); + await new AutoSeasonRequestService(null!, f.Core.Logger, null!, null!, null!).CheckEpisodeCompletionAsync(new Episode(), Guid.NewGuid()); + } + [Theory] + [InlineData(7, 10, 1, false, 0)] + [InlineData(8, 10, 1, false, 1)] + [InlineData(10, 0, 1, false, 0)] + [InlineData(10, 10, 5, false, 0)] + [InlineData(10, 10, 1, true, 0)] + [InlineData(8, 10, 1, false, 0, true, false)] + [InlineData(8, 10, 1, false, 1, true, true)] + [InlineData(8, 10, 1, false, 2, false, false, true)] + public async Task SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest(int episodeNumber, int nextEpisodeCount, int nextStatus, bool existingRequest, int expectedPosts, bool requireWatched = false, bool priorPlayed = false, bool failFirst = false) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.AutoSeasonRequestEnabled = true; + config.JellyseerrEnabled = true; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "test"; + config.AutoSeasonRequestThresholdValue = 2; + config.AutoSeasonRequestRequireAllWatched = requireWatched; + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var users = new Mock(); + users.Setup(u => u.GetUserById(user.Id)).Returns(user); + var series = new Series { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "42" } }; + var episode = new Episode { Id = Guid.NewGuid(), SeriesId = series.Id, ParentIndexNumber = 1, IndexNumber = episodeNumber }; + var library = new Mock(); + library.Setup(l => l.GetItemById(series.Id)).Returns(series); + var priorEpisode = new Episode { Id = Guid.NewGuid(), SeriesId = series.Id, ParentIndexNumber = 1, IndexNumber = 1 }; + library.Setup(l => l.GetItemsResult(It.IsAny())).Returns(new MediaBrowser.Model.Querying.QueryResult { Items = new BaseItem[] { priorEpisode, episode } }); + var data = new Mock(); + data.Setup(d => d.GetUserData(user, priorEpisode)).Returns(new UserItemData { Key = "prior", Played = priorPlayed }); + var previousLibrary = BaseItem.LibraryManager; + BaseItem.LibraryManager = library.Object; + try + { + var posts = new List(); + using var transport = new IntegrationTransport(async (request, ct) => + { + if (request.RequestUri!.AbsolutePath == "/api/v1/tv/42") + return IntegrationTransport.Response(JsonSerializer.Serialize(new + { + numberOfSeasons = 2, + seasons = new[] { new { seasonNumber = 1, episodeCount = 10, status = 5 }, new { seasonNumber = 2, episodeCount = nextEpisodeCount, status = nextStatus } }, + mediaInfo = new { requests = existingRequest ? new[] { new { seasons = new[] { new { seasonNumber = 2 } } } } : Array.Empty() } + })); + if (request.RequestUri.AbsolutePath == "/api/v1/user") + return IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 22, jellyfinUserId = user.Id.ToString() } } })); + if (request.Method == HttpMethod.Post) + { + posts.Add(await request.Content!.ReadAsStringAsync(ct)); + return IntegrationTransport.Response("{}", failFirst && posts.Count == 1 ? 500 : 201); + } + throw new InvalidOperationException("Unexpected URL " + request.RequestUri); + }); + var service = new AutoSeasonRequestService(transport, f.Core.Logger, users.Object, data.Object, library.Object); + await service.CheckEpisodeCompletionAsync(episode, user.Id); + await service.CheckEpisodeCompletionAsync(episode, user.Id); + Assert.Equal(expectedPosts, posts.Count); + foreach (var post in posts) + { + using var payload = JsonDocument.Parse(post); + Assert.Equal("tv", payload.RootElement.GetProperty("mediaType").GetString()); + Assert.Equal(42, payload.RootElement.GetProperty("mediaId").GetInt32()); + Assert.Equal(2, Assert.Single(payload.RootElement.GetProperty("seasons").EnumerateArray()).GetInt32()); + } + } + finally { BaseItem.LibraryManager = previousLibrary; } + } + +} diff --git a/tests/backend/ScheduledTasks/TaskMetadataTests.cs b/tests/backend/ScheduledTasks/TaskMetadataTests.cs new file mode 100644 index 000000000..fbecb32c0 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskMetadataTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.ScheduledTasks; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class TaskMetadataTests +{ + [Theory] + [InlineData(false)][InlineData(true)] + public async Task RatingsSyncFillsMissingPreservesOrOverwritesExistingAndIsIdempotent(bool overwrite) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.MdblistRatingsEnabled = true; + f.Plugin.Configuration.MdblistRatingsAutoSyncEnabled = true; + f.Plugin.Configuration.MdblistRatingsOverwriteExisting = overwrite; + var movie = new Movie { Id = Guid.NewGuid(), CommunityRating = 4, ProviderIds = new() { ["Tmdb"] = "123" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie }); + var writes = 0; + library.Setup(l => l.UpdateItemAsync(movie, It.IsAny(), ItemUpdateType.MetadataEdit, It.IsAny())).Callback(() => writes++).Returns(Task.CompletedTask); + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("Sync must be offline")); + using var service = new MdblistService(transport, f.Core.Paths.Object, f.Core.Logger); + service.MergeMediaBatchIntoCache("movie", ["123"], new Dictionary { ["123"] = new() { Found = true, Confirmed = true, FetchedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Ratings = [new() { Source = "tmdb", Score = 82 }, new() { Source = "tomatoes", Score = 91 }] } }); + var previous = BaseItem.LibraryManager; + BaseItem.LibraryManager = library.Object; + try + { + var task = new MdblistRatingsSyncTask(library.Object, service, f.Core.Logger); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(overwrite ? 8.2f : 4f, movie.CommunityRating); + Assert.Equal(91f, movie.CriticRating); + Assert.Equal(1, writes); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(1, writes); + Assert.Equal(0, transport.Calls); + } + finally { BaseItem.LibraryManager = previous; } + } + + [Theory] + [InlineData(400, 0)][InlineData(1000, 1)] + [InlineData(401, 1, 151)][InlineData(1000, 2, 151)] + public async Task RatingsFetchHonorsReserveDeduplicatesAndSkipsWarmCache(int remaining, int expectedBatches, int itemCount = 1) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.MdblistRatingsEnabled = true; + config.MdblistRatingsFetchEnabled = true; + config.MdblistApiKey = "test"; + config.MdblistFetchReserve = 400; + var library = new Mock(); + var movies = Enumerable.Range(123, itemCount).Select(id => (BaseItem)new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = id.ToString() } }) + .Concat(new BaseItem[] { new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }, new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "invalid" } } }).ToArray(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(movies); + var batches = new List(); + using var transport = new IntegrationTransport(async (request, ct) => + { + if (request.RequestUri!.AbsolutePath == "/user") + return IntegrationTransport.Response(JsonSerializer.Serialize(new { rate_limit = 1000, rate_limit_remaining = remaining, rate_limit_reset = 1800000000 })); + batches.Add(await request.Content!.ReadAsStringAsync(ct)); + return IntegrationTransport.Response("[]"); + }); + using var service = new MdblistService(transport, f.Core.Paths.Object, f.Core.Logger); + var task = new MdblistRatingsFetchTask(library.Object, service, f.Core.Logger); + await task.ExecuteAsync(new Progress(), default); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(expectedBatches, batches.Count); + if (expectedBatches > 0) + { + var requestedIds = new List(); + foreach (var batch in batches) + { + using var body = JsonDocument.Parse(batch); + var ids = body.RootElement.GetProperty("ids").EnumerateArray().Select(id => id.GetInt32()).ToArray(); + Assert.InRange(ids.Length, 1, 150); + requestedIds.AddRange(ids); + } + Assert.Equal(Math.Min(itemCount, expectedBatches * 150), requestedIds.Count); + Assert.Equal(requestedIds.Count, requestedIds.Distinct().Count()); + Assert.Equal(Math.Min(itemCount, expectedBatches * 150), Enumerable.Range(123, itemCount).Count(id => !service.NeedsFetch("movie", id.ToString()))); + Assert.False(service.NeedsFetch("movie", "123")); + Assert.True(service.GetCachedEntry("movie", "123")!.Confirmed); + } + else Assert.True(service.NeedsFetch("movie", "123")); + library.Verify(l => l.UpdateItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ArrSyncPreservesUnrelatedTagsFiltersLabelsAndDoesNotRewriteIdenticalResults() + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.ArrTagsSyncEnabled = true; + config.ArrTagsClearOldTags = true; + config.ArrTagsPrefix = "Arr: "; + config.ArrTagsSyncFilter = "alice"; + config.RadarrInstances = "[{\"Name\":\"Test\",\"Url\":\"http://radarr.test\",\"ApiKey\":\"test\",\"Enabled\":true}]"; + var movie = new Movie { Id = Guid.NewGuid(), Tags = ["Keep", "Arr: stale"], ProviderIds = new() { ["Tmdb"] = "123" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie }); + var writes = 0; + library.Setup(l => l.UpdateItemAsync(movie, It.IsAny(), ItemUpdateType.MetadataEdit, It.IsAny())).Callback(() => writes++).Returns(Task.CompletedTask); + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response( + request.RequestUri!.AbsolutePath.EndsWith("/tag") ? "[{\"id\":1,\"label\":\"alice\"},{\"id\":2,\"label\":\"bob\"}]" : "[{\"tmdbId\":123,\"tags\":[1,2]}]"))); + var previous = BaseItem.LibraryManager; + BaseItem.LibraryManager = library.Object; + try + { + var task = new ArrTagsSyncTask(library.Object, transport, f.Core.Logger); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(new[] { "Keep", "Arr: alice" }, movie.Tags); + Assert.Equal(1, writes); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(1, writes); + } + finally { BaseItem.LibraryManager = previous; } + } + [Theory] + [InlineData(false)][InlineData(true)] + public async Task RatingsTasksCancelBeforeWritingOrFetchingMedia(bool fetch) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.MdblistRatingsEnabled = true; + config.MdblistRatingsFetchEnabled = true; + config.MdblistRatingsAutoSyncEnabled = true; + config.MdblistApiKey = "test"; + using var cts = new CancellationTokenSource(); + var movie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Callback(() => cts.Cancel()).Returns(new BaseItem[] { movie }); + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response("{\"rate_limit\":1000,\"rate_limit_remaining\":1000,\"rate_limit_reset\":1800000000}"))); + using var service = new MdblistService(transport, f.Core.Paths.Object, f.Core.Logger); + MediaBrowser.Model.Tasks.IScheduledTask task = fetch + ? new MdblistRatingsFetchTask(library.Object, service, f.Core.Logger) + : new MdblistRatingsSyncTask(library.Object, service, f.Core.Logger); + await Assert.ThrowsAnyAsync(() => task.ExecuteAsync(new Progress(), cts.Token)); + Assert.Equal(fetch ? 1 : 0, transport.Calls); + Assert.Null(movie.CommunityRating); + Assert.Null(movie.CriticRating); + library.Verify(l => l.UpdateItemAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AudioLanguageSyncReplacesStalePrefixedTagsAndIsIdempotent() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AudioLanguageTagSyncEnabled = true; + var movie = new AudioItems.Movie { Id = Guid.NewGuid(), Tags = ["Keep", "Audio: Stale"] }; + f.Plugin.Configuration.AudioLanguageTagPrefix = "Audio: "; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie }); + var writes = 0; + library.Setup(l => l.UpdateItemAsync(movie, It.IsAny(), ItemUpdateType.MetadataEdit, It.IsAny())).Callback(() => writes++).Returns(Task.CompletedTask); + var localization = new Mock(); + using var cache = new TagCacheService(library.Object, f.Core.Paths.Object, localization.Object, f.Core.Logger); + var previous = BaseItem.LibraryManager; + BaseItem.LibraryManager = library.Object; + try + { + var task = new AudioLanguageTagsSyncTask(library.Object, localization.Object, cache, f.Core.Logger); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(new[] { "Keep", "Audio: ENG" }, movie.Tags); + Assert.Equal(1, writes); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(1, writes); + } + finally { BaseItem.LibraryManager = previous; } + } + + private static class AudioItems + { + public sealed class Movie : MediaBrowser.Controller.Entities.Movies.Movie + { + public override List GetMediaSources(bool enablePathSubstitution) + => [new() { MediaStreams = new List { new() { Type = MediaBrowser.Model.Entities.MediaStreamType.Audio, Language = "eng" } } }]; + } + } + +} diff --git a/tests/backend/ScheduledTasks/TaskRegressionTests.cs b/tests/backend/ScheduledTasks/TaskRegressionTests.cs new file mode 100644 index 000000000..99e6d3989 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskRegressionTests.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.ScheduledTasks; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class TaskRegressionTests +{ + private sealed class ProgressLog : IProgress + { + public List Values { get; } = new(); + public void Report(double value) => Values.Add(value); + } + + [Fact] + public async Task DisabledTasksCompleteWithoutTouchingDependencies() + { + using var f = new ApiPluginFixture(); + var c = f.Plugin.Configuration; + c.ArrTagsSyncEnabled = false; + c.AudioLanguageTagSyncEnabled = false; + c.MdblistRatingsEnabled = false; + c.JellyseerrEnabled = false; + c.TagCacheServerMode = false; + IScheduledTask[] tasks = [ + new ArrTagsSyncTask(null!, null!, f.Core.Logger), + new AudioLanguageTagsSyncTask(null!, null!, null!, f.Core.Logger), + new MdblistRatingsFetchTask(null!, null!, f.Core.Logger), + new MdblistRatingsSyncTask(null!, null!, f.Core.Logger), + new JellyseerrUserImportTask(null!, null!, f.Core.Logger), + new JellyfinToSeerrWatchlistSyncTask(null!, null!, null!, null!, null!, f.Core.Logger), + new JellyseerrWatchlistSyncTask(null!, null!, null!, null!, null!, f.Core.Logger), + new BuildTagCacheTask(null!, null!, f.Core.Logger) + ]; + Assert.Equal(tasks.Length, tasks.Select(t => t.Key).Distinct().Count()); + foreach (var task in tasks) + { + var progress = new ProgressLog(); + await task.ExecuteAsync(progress, CancellationToken.None); + Assert.Equal(100, Assert.Single(progress.Values)); + } + } + + [Fact] + public void DestructiveOrOptInTasksHaveNoAutomaticTriggers() + { + using var f = new CoreFixture(); + IScheduledTask[] tasks = [new ArrTagsSyncTask(null!, null!, f.Logger), + new AudioLanguageTagsSyncTask(null!, null!, null!, f.Logger), + new MdblistRatingsFetchTask(null!, null!, f.Logger), + new MdblistRatingsSyncTask(null!, null!, f.Logger), + new SpoilerApplyExistingTitlesTask(null!)]; + foreach (var task in tasks) Assert.Empty(task.GetDefaultTriggers()); + Assert.Equal(TaskTriggerInfoType.StartupTrigger, Assert.Single(new ClearTranslationCacheTask(f.Logger).GetDefaultTriggers()).Type); + } + + [Fact] + public async Task TranslationRefreshSetsClientInvalidationSignal() + { + using var f = new ApiPluginFixture(); + var before = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var progress = new ProgressLog(); + await new ClearTranslationCacheTask(f.Core.Logger).ExecuteAsync(progress, default); + Assert.InRange(f.Plugin.Configuration.ClearTranslationCacheTimestamp, before, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + Assert.Equal(100, Assert.Single(progress.Values)); + } + + [Theory] + [InlineData(false)][InlineData(true)] + public async Task WatchlistExportUsesLinkedIdentityAndDeduplicatesLibraryItems(bool alreadyPresent) + { + using var f = new ApiPluginFixture(); + var user = new User("alice", "default", "default") { Id = Guid.NewGuid() }; + var blocked = new User("blocked", "default", "default") { Id = Guid.NewGuid() }; + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.SyncJellyfinWatchlistToSeerr = true; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "test-key"; + config.JellyseerrImportBlockedUsers = blocked.Id.ToString(); + var movies = new[] { "+123", " 123 ", "00123", "not-an-id", "0", "-1", "2147483648", "123", "123" } + .Select((id, index) => (BaseItem)new Movie + { + Id = Guid.NewGuid(), Name = "Edition " + index, + ProviderIds = new() { ["Tmdb"] = id } + }).ToArray(); + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns((InternalItemsQuery q) => q.IncludeItemTypes.Contains(Jellyfin.Data.Enums.BaseItemKind.Movie) ? movies : Array.Empty()); + var users = new Mock(); + var parameter = System.Linq.Expressions.Expression.Parameter(typeof(IUserManager), "u"); + var method = typeof(IUserManager).GetMethod("GetUsers", Type.EmptyTypes); + System.Linq.Expressions.Expression body = method != null + ? System.Linq.Expressions.Expression.Call(parameter, method) + : System.Linq.Expressions.Expression.Property(parameter, "Users"); + users.Setup(System.Linq.Expressions.Expression.Lambda>>(body, parameter)).Returns(new[] { user, blocked }); + var data = new Mock(MockBehavior.Strict); + data.Setup(d => d.GetUserData(user, It.IsAny())).Returns(new UserItemData { Key = "test", Likes = true }); + var posts = 0; + using var transport = new IntegrationTransport(async (request, ct) => + { + Assert.Equal("test-key", Assert.Single(request.Headers.GetValues("X-Api-Key"))); + if (request.RequestUri!.AbsolutePath == "/api/v1/user") + return IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 27, jellyfinUserId = user.Id.ToString().ToUpperInvariant() }, new { id = 28, jellyfinUserId = blocked.Id.ToString() } } })); + Assert.Equal("27", Assert.Single(request.Headers.GetValues("X-Api-User"))); + if (request.Method == HttpMethod.Get) + return IntegrationTransport.Response(alreadyPresent ? "{\"results\":[{\"tmdbId\":123,\"mediaType\":\"movie\"}]}" : "{\"results\":[]}"); + posts++; + Assert.Equal("/api/v1/watchlist", request.RequestUri.AbsolutePath); + using var payload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct)); + Assert.Equal(123, payload.RootElement.GetProperty("tmdbId").GetInt32()); + Assert.Equal("movie", payload.RootElement.GetProperty("mediaType").GetString()); + return IntegrationTransport.Response(); + }); + var progress = new ProgressLog(); + await new JellyfinToSeerrWatchlistSyncTask(library.Object, users.Object, data.Object, transport, f.Core.Manager, f.Core.Logger).ExecuteAsync(progress, default); + Assert.Equal(alreadyPresent ? 0 : 1, posts); + Assert.Equal(alreadyPresent ? 2 : 3, transport.Calls); + Assert.Equal(100, progress.Values.Last()); + data.Verify(d => d.GetUserData(blocked, It.IsAny()), Times.Never); + } + + [Fact] + public void DisabledWatchlistMonitorDoesNotSubscribeAndDisposalUnsubscribes() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AddRequestedMediaToWatchlist = false; + var library = new Mock(); + using (var monitor = new WatchlistMonitor(library.Object, null!, null!, null!, f.Core.Manager, f.Core.Logger)) monitor.Initialize(); + library.VerifyAdd(l => l.ItemAdded += It.IsAny>(), Times.Never); + library.VerifyRemove(l => l.ItemAdded -= It.IsAny>(), Times.Once); + library.VerifyRemove(l => l.ItemUpdated -= It.IsAny>(), Times.Once); + } + [Fact] + public void ReinitializingWatchlistMonitorDoesNotLeakEventSubscriptions() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.AddRequestedMediaToWatchlist = true; + f.Plugin.Configuration.JellyseerrEnabled = true; + var library = new Mock(); + using (var monitor = new WatchlistMonitor(library.Object, null!, null!, null!, f.Core.Manager, f.Core.Logger)) + { + monitor.Initialize(); + monitor.Initialize(); + } + library.VerifyAdd(l => l.ItemAdded += It.IsAny>(), Times.Once); + library.VerifyAdd(l => l.ItemUpdated += It.IsAny>(), Times.Once); + library.VerifyRemove(l => l.ItemAdded -= It.IsAny>(), Times.Once); + library.VerifyRemove(l => l.ItemUpdated -= It.IsAny>(), Times.Once); + } + + [Theory] + [InlineData(false)][InlineData(true)] + public async Task AnalyticsTaskHonorsConsentAndReportingCadence(bool consent) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.AnalyticsEnabled = consent; + config.AnalyticsLastReportedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + config.AnalyticsLastReportedPluginVersion = f.Plugin.Version.ToString(); + config.AnalyticsLastReportedJellyfinTarget = HostCompatibilityService.BuiltFor; + config.AnalyticsLastReportedJellyfinVersion = "test-host"; + var host = new Mock(); + host.SetupGet(h => h.ApplicationVersionString).Returns("test-host"); + var http = new Mock(MockBehavior.Strict); + var service = new AnalyticsReportingService(http.Object, f.Core.Paths.Object, host.Object, null!, f.Core.Manager, f.Core.Logger); + var progress = new ProgressLog(); + await new AnalyticsReportTask(service).ExecuteAsync(progress, default); + Assert.Equal(new double[] { 0, 100 }, progress.Values); + http.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ArrSyncCancellationStopsBeforeLibraryMutations() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.ArrTagsSyncEnabled = true; + var library = new Mock(MockBehavior.Strict); + var http = new Mock(MockBehavior.Strict); + var progress = new ProgressLog(); + await Assert.ThrowsAnyAsync(() => + new ArrTagsSyncTask(library.Object, http.Object, f.Core.Logger).ExecuteAsync(progress, new CancellationToken(true))); + library.VerifyNoOtherCalls(); + http.VerifyNoOtherCalls(); + Assert.DoesNotContain(100, progress.Values); + } + + [Theory] + [InlineData("", "key")][InlineData("http://seerr.test", "")] + public async Task EnabledSeerrTasksWithoutCredentialsDoNotTouchLibraryOrNetwork(string url, string apiKey) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.JellyseerrAutoImportUsers = true; + config.SyncJellyfinWatchlistToSeerr = true; + config.AddRequestedMediaToWatchlist = true; + config.JellyseerrUrls = url; + config.JellyseerrApiKey = apiKey; + IScheduledTask[] tasks = [new JellyseerrUserImportTask(null!, null!, f.Core.Logger), + new JellyfinToSeerrWatchlistSyncTask(null!, null!, null!, null!, null!, f.Core.Logger), + new JellyseerrWatchlistSyncTask(null!, null!, null!, null!, null!, f.Core.Logger)]; + foreach (var task in tasks) + { + var progress = new ProgressLog(); + await task.ExecuteAsync(progress, default); + Assert.Equal(100, Assert.Single(progress.Values)); + } + } + +} diff --git a/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs b/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs new file mode 100644 index 000000000..02990317a --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs @@ -0,0 +1,148 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.ScheduledTasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +public class TaskSeerrSyncTests +{ + private sealed class ProgressLog : IProgress + { + public List Values { get; } = new(); + public void Report(double value) => Values.Add(value); + } + + private static Mock Users(params User[] users) + { + var mock = new Mock(); + var p = Expression.Parameter(typeof(IUserManager), "u"); + var method = typeof(IUserManager).GetMethod("GetUsers", Type.EmptyTypes); + Expression body = method != null ? Expression.Call(p, method) : Expression.Property(p, "Users"); + mock.Setup(Expression.Lambda>>(body, p)).Returns(users); + return mock; + } + + [Theory] + [InlineData(false)][InlineData(true)] + public async Task UserImportNormalizesIdsExcludesBlockedUsersAndFailsOver(bool allFail) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.JellyseerrAutoImportUsers = true; + config.JellyseerrUrls = "http://first.test\nhttp://second.test"; + config.JellyseerrApiKey = "test"; + var allowed = new User("allowed", "default", "default") { Id = Guid.NewGuid() }; + var blocked = new User("blocked", "default", "default") { Id = Guid.NewGuid() }; + config.JellyseerrImportBlockedUsers = blocked.Id.ToString("D").ToUpperInvariant(); + var requests = new List<(string Host, string Body, string Key)>(); + using var transport = new IntegrationTransport(async (request, ct) => + { + requests.Add((request.RequestUri!.Host, await request.Content!.ReadAsStringAsync(ct), request.Headers.GetValues("X-Api-Key").Single())); + return IntegrationTransport.Response(requests.Count <= 2 ? "[{\"id\":27}]" : "[]", allFail || request.RequestUri.Host == "first.test" ? 500 : 200); + }); + var task = new JellyseerrUserImportTask(Users(allowed, blocked).Object, transport, f.Core.Logger); + var progress = new ProgressLog(); + await task.ExecuteAsync(progress, default); + await task.ExecuteAsync(progress, default); + Assert.Equal(new[] { "first.test", "second.test", "first.test", "second.test" }, requests.Select(r => r.Host)); + foreach (var request in requests) + { + Assert.Equal("test", request.Key); + using var body = JsonDocument.Parse(request.Body); + Assert.Equal(allowed.Id.ToString("N"), Assert.Single(body.RootElement.GetProperty("jellyfinUserIds").EnumerateArray()).GetString()); + } + Assert.Equal(100, progress.Values.Last()); + } + + [Fact] + public async Task UserImportCancellationPropagatesWithoutContactingProvider() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.JellyseerrEnabled = true; + f.Plugin.Configuration.JellyseerrAutoImportUsers = true; + f.Plugin.Configuration.JellyseerrUrls = "http://seerr.test"; + f.Plugin.Configuration.JellyseerrApiKey = "test"; + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("Must not call provider")); + var progress = new ProgressLog(); + await Assert.ThrowsAnyAsync(() => new JellyseerrUserImportTask(Users().Object, transport, f.Core.Logger).ExecuteAsync(progress, new CancellationToken(true))); + Assert.Equal(0, transport.Calls); + Assert.DoesNotContain(100, progress.Values); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + public async Task InboundWatchlistIsUserScopedIdempotentAndDoesNotReaddRemovedItems(bool upstreamFails, bool invalidJson, bool cancelDuringFetch) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.SyncJellyseerrWatchlist = true; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "test"; + config.PreventWatchlistReAddition = true; + config.WatchlistMemoryRetentionDays = 30; + config.AddRequestedMediaToWatchlist = true; + var allowed = new User("allowed", "default", "default") { Id = Guid.NewGuid() }; + var blocked = new User("blocked", "default", "default") { Id = Guid.NewGuid() }; + config.JellyseerrImportBlockedUsers = blocked.Id.ToString(); + var movie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }; + var otherUsersMovie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "456" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie, otherUsersMovie }); + var userData = new UserItemData { Key = "movie", Likes = false }; + var data = new Mock(MockBehavior.Strict); + data.Setup(d => d.GetUserData(allowed, movie)).Returns(userData); + var writes = 0; + data.Setup(d => d.SaveUserData(allowed, movie, userData, MediaBrowser.Model.Entities.UserDataSaveReason.UpdateUserRating, It.IsAny())).Callback(() => writes++); + var identities = new List(); + using var cts = new CancellationTokenSource(); + using var transport = new IntegrationTransport((request, _) => + { + if (request.RequestUri!.AbsolutePath == "/api/v1/user") + return Task.FromResult(IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 27, jellyfinUserId = allowed.Id.ToString("N").ToUpperInvariant() }, new { id = 28, jellyfinUserId = blocked.Id.ToString() } } }))); + identities.Add(request.Headers.GetValues("X-Api-User").Single()); + if (cancelDuringFetch) cts.Cancel(); + if (upstreamFails) return Task.FromResult(IntegrationTransport.Response("{}", 500)); + if (invalidJson) return Task.FromResult(IntegrationTransport.Response("{")); + return Task.FromResult(IntegrationTransport.Response(request.RequestUri.AbsolutePath.EndsWith("watchlist") + ? "{\"results\":[{\"tmdbId\":123,\"mediaType\":\"movie\"},{\"tmdbId\":123,\"mediaType\":\"movie\"}]}" + : "{\"results\":[{\"requestedBy\":{\"id\":27},\"media\":{\"tmdbId\":123,\"mediaType\":\"movie\"}},{\"requestedBy\":{\"id\":28},\"media\":{\"tmdbId\":456,\"mediaType\":\"movie\"}}]}")); + }); + var task = new JellyseerrWatchlistSyncTask(library.Object, Users(allowed, blocked).Object, data.Object, transport, f.Core.Manager, f.Core.Logger); + var progress = new ProgressLog(); + if (cancelDuringFetch) + { + await Assert.ThrowsAnyAsync(() => task.ExecuteAsync(progress, cts.Token)); + Assert.Equal(0, writes); + Assert.DoesNotContain(100, progress.Values); + return; + } + await task.ExecuteAsync(progress, default); + Assert.Equal(upstreamFails || invalidJson ? 0 : 1, writes); + if (!upstreamFails && !invalidJson) + { + Assert.True(userData.Likes); + var processed = Assert.Single(f.Core.Manager.GetProcessedWatchlistItems(allowed.Id).Items); + Assert.Equal(123, processed.TmdbId); + Assert.Equal("movie", processed.MediaType); + userData.Likes = false; // User deliberately removed the item after the first sync. + } + await task.ExecuteAsync(progress, default); + Assert.Equal(upstreamFails || invalidJson ? 0 : 1, writes); + Assert.False(userData.Likes); + Assert.NotEmpty(identities); + Assert.All(identities, id => Assert.Equal("27", id)); + Assert.Empty(f.Core.Manager.GetProcessedWatchlistItems(blocked.Id).Items); + Assert.Equal(100, progress.Values.Last()); + } +} diff --git a/tests/backend/Support/ApiPluginFixture.cs b/tests/backend/Support/ApiPluginFixture.cs new file mode 100644 index 000000000..6f740794b --- /dev/null +++ b/tests/backend/Support/ApiPluginFixture.cs @@ -0,0 +1,35 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Serialization; +using Moq; +using Plugin = Jellyfin.Plugin.JellyfinEnhanced.JellyfinEnhanced; + +namespace JE.Tests; + +[CollectionDefinition("Plugin singleton", DisableParallelization = true)] +public class ApiPluginCollection { } + +internal sealed class ApiPluginFixture : IDisposable +{ + public CoreFixture Core { get; } = new(); + public Plugin Plugin { get; } + private readonly Plugin? previous = Plugin.Instance; + public ApiPluginFixture() + { + Core.Paths.SetupGet(p => p.PluginConfigurationsPath).Returns(Path.Combine(Core.Root, "config")); + Core.Paths.SetupGet(p => p.WebPath).Returns(Path.Combine(Core.Root, "web")); + var host = new Mock(); + host.SetupGet(h => h.ApplicationVersion).Returns(new Version(HostCompatibilityService.BuiltFor == "jf12" ? "12.0.0" : "10.11.0")); + host.SetupGet(h => h.ApplicationVersionString).Returns("test-host"); + var xml = new Mock(); + xml.Setup(s => s.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny())).Returns(() => new PluginConfiguration()); + Plugin = new Plugin(Core.Paths.Object, Mock.Of(), xml.Object, Core.Logger, null!, new HostCompatibilityService(host.Object)); + } + public void Dispose() + { + typeof(Plugin).GetProperty(nameof(Plugin.Instance))!.SetValue(null, previous); + Core.Dispose(); + } +} diff --git a/tests/backend/Support/CoreFixture.cs b/tests/backend/Support/CoreFixture.cs new file mode 100644 index 000000000..519166251 --- /dev/null +++ b/tests/backend/Support/CoreFixture.cs @@ -0,0 +1,33 @@ +using Jellyfin.Plugin.JellyfinEnhanced; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace JE.Tests; + +public sealed class CoreFixture : IDisposable +{ + public string Root { get; } = Path.Combine(Path.GetTempPath(), "je-regression-" + Guid.NewGuid().ToString("N")); + public string ConfigRoot => Path.Combine(Root, "configurations", "Jellyfin.Plugin.JellyfinEnhanced"); + public Mock Paths { get; } = new(); + public Logger Logger { get; } + public UserConfigurationManager Manager { get; } + public CoreFixture() + { + Directory.CreateDirectory(Root); + Paths.SetupGet(p => p.PluginsPath).Returns(Root); + Paths.SetupGet(p => p.LogDirectoryPath).Returns(Root); + Logger = new Logger(Paths.Object, NullLoggerFactory.Instance); + Manager = new UserConfigurationManager(Paths.Object, Logger); + } + public string Write(string user, string text) + { + var dir = Path.Combine(ConfigRoot, user); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "settings.json"); + File.WriteAllText(path, text); + return path; + } + public void Dispose() => Directory.Delete(Root, true); +} diff --git a/tests/backend/Support/IntegrationEnvironment.cs b/tests/backend/Support/IntegrationEnvironment.cs new file mode 100644 index 000000000..2420be743 --- /dev/null +++ b/tests/backend/Support/IntegrationEnvironment.cs @@ -0,0 +1,20 @@ +using MediaBrowser.Common.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace JE.Tests; + +internal sealed class IntegrationEnvironment : IDisposable +{ + public string Root { get; } = Path.Combine(Path.GetTempPath(), "je-integrations-" + Guid.NewGuid()); + public Mock Paths { get; } = new(); + public Jellyfin.Plugin.JellyfinEnhanced.Logger Logger { get; } + public IntegrationEnvironment() + { + Directory.CreateDirectory(Root); + Paths.SetupGet(p => p.PluginsPath).Returns(Root); + Paths.SetupGet(p => p.LogDirectoryPath).Returns(Root); + Logger = new(Paths.Object, NullLoggerFactory.Instance); + } + public void Dispose() => Directory.Delete(Root, true); +} diff --git a/tests/backend/Support/IntegrationTransport.cs b/tests/backend/Support/IntegrationTransport.cs new file mode 100644 index 000000000..437a63943 --- /dev/null +++ b/tests/backend/Support/IntegrationTransport.cs @@ -0,0 +1,17 @@ +using System.Net; +using System.Text; + +namespace JE.Tests; + +internal sealed class IntegrationTransport(Func> send) : HttpMessageHandler, IHttpClientFactory +{ + public int Calls; + public HttpClient CreateClient(string name) => new(this, false); + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Interlocked.Increment(ref Calls); + return send(request, ct); + } + public static HttpResponseMessage Response(string body = "{}", int status = 200, string type = "application/json") => + new((HttpStatusCode)status) { Content = new StringContent(body, Encoding.UTF8, type) }; +} diff --git a/tests/backend/packages.jf10.lock.json b/tests/backend/packages.jf10.lock.json new file mode 100644 index 000000000..d571b964e --- /dev/null +++ b/tests/backend/packages.jf10.lock.json @@ -0,0 +1,504 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Direct", + "requested": "[8.3.1.1, )", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "T5t8Qac05kJtFzsBxo+B3p0UcLNTRoWQf/1EbpaVBw9d7w2xL6RKYh0mqG+rPn2rulJDKeU3VfAd+r/YHdaKBg==" + }, + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[9.0.10, )", + "resolved": "9.0.10", + "contentHash": "fRnQ7qcHNINRoErantws9k3CdjcYwvbx+fLB3ZDN/EDC7kt93J3fPB15aEbnum2JiFPbw3fgr9iOGWyxaRXD9Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.10", + "Microsoft.Extensions.Caching.Memory": "9.0.10", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.72, )", + "resolved": "4.20.72", + "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "SkiaSharp": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "DNDwbRjP+aMo27dV2h/uHCVTcWubWWxHnPLiePNyl24f4Pv43mQ8AQQeseOrKR+J3AOCEs6t0sUjo0aa3j3RWQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.116.1", + "SkiaSharp.NativeAssets.macOS": "3.116.1" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "ibDG1+quN86vBd9ztjDAC9wnvS1nRZ6ydTUOSod4NsRHWdLLGzWYn1IOF4Cg9iJh5cQHdpzhUZBQE0JMKznrow==", + "dependencies": { + "HarfBuzzSharp": "8.3.0.1", + "SkiaSharp": "3.116.1" + } + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "f4OSsrMwe+Ei9KpNc3EMUiMcZkQSYepHCaDuvMyLBaHVQd+9Ywq/rlbmapqztsU6k2iPEVLD8ZuBJm9RDJsAow==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.0.2, )", + "resolved": "3.0.2", + "contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow==" + }, + "BitFaster.Caching": { + "type": "Transitive", + "resolved": "2.5.4", + "contentHash": "1QroTY1PVCZOSG9FnkkCrmCKk/+bZCgI/YXq376HnYwUDJ4Ho0EaV4YaA/5v5WYLnwIwIO7RZkdWbg9pxIpueQ==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "Diacritics": { + "type": "Transitive", + "resolved": "4.0.17", + "contentHash": "FmMvVQRsfon+x5P+dxz4mvV8wt45xr25EAOCkuo/Cjtc7lVYV5cZUSsNXwmKQpwO+TokIHpzxb8ENpqrm4yBlQ==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "rwLpl+W6uqu0DuvzqNhTMuFcXfy1Vc0uq0YXgPEmtTSfeUSAye1FcARrm2YIPOSiCBwBOGu3cLvMX5Fp6OKe2g==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.0.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.0.1" + } + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "2o6U05LAmK+rwX7TvmJ2X0anXJG2hSE7kHVmCshhHy0tKfByJ5ykBacvhmmooHchlOwq15KBZeROGafCT8nN+g==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "ow0DtGEUjo65qhiI22of7qiVbN1xDFsZ5P5xJljRmGZ5WSxNy+1batLNJFGxahqhB1MTHYV8kAXf0GqC8WaevQ==" + }, + "ICU4N": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "YMZtDnjcqWzziOKiE7w6Ma7Rl5vuFDxzOsUlHh1QyfghbNEIZQOLRs9MMfwCWAjX6n9UitrF6vLXy55Z5q+4Fg==", + "dependencies": { + "J2N": "2.0.0", + "Microsoft.Extensions.Caching.Memory": "2.0.0" + } + }, + "ICU4N.Transliterator": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "lFOSO6bbEtB6HkWMNDJAq+rFwVyi9g6xVc5O/2xHa6iZnV7wLVDqCbaQ4W4vIeBSQZAafqhxciaEkmAvSdzlCg==", + "dependencies": { + "ICU4N": "60.1.0-alpha.356" + } + }, + "J2N": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "M5bwDajAARZiyqupU+rHQJnsVLxNBOHJ8vKYHd8LcLIb1FgLfzzcJvc31Qo5Xz/GEHFjDF9ScjKL/ks/zRTXuA==" + }, + "Jellyfin.Common": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "TitN7+qWFt2l0V5b+KTRt7ABDCvfZdvSC6qBG1uHS18Y80xrbrSCJ9O6BH/of310h6a4lxKlQjUtTPHCzeG2AA==", + "dependencies": { + "Jellyfin.Model": "10.11.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Jellyfin.Controller": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "WV+PQy9AHdZLvYqUsNq6ZyQoxaiaEWLz0EwZGOiu8xSrepQLFope2U1VFHVCNbARwesg7s/B+9uB03eXDsQw9w==", + "dependencies": { + "BitFaster.Caching": "2.5.4", + "Jellyfin.Common": "10.11.0", + "Jellyfin.MediaEncoding.Keyframes": "10.11.0", + "Jellyfin.Model": "10.11.0", + "Jellyfin.Naming": "10.11.0", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.Configuration.Binder": "9.0.10", + "System.Threading.Tasks.Dataflow": "9.0.10" + } + }, + "Jellyfin.Data": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "YEz7/85b98Rj14IJJIVqmzJsi69LDOKo4Ox+VHbh1vj3tkWomSPayzvG3kyU8I0yFMrd6+Ta55C20kZ2XC7vQg==", + "dependencies": { + "Jellyfin.Database.Implementations": "10.11.0", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Jellyfin.Database.Implementations": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "oLblVZzqF9zuLMdfqp8pbusSVQq6b40/RcHjGF1hxYozVNEi+UhiDX8aJipYBOrh33FFAofoQq468BvZixpPcw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "9.0.10", + "Polly": "8.6.4" + } + }, + "Jellyfin.Extensions": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "1ufj+Rm0Bn6C990i2wwiT5UHPZfD65GOtJK6NcDU//DDMbuoGX1LQZxuCx+rhhRg1XdHPWzYASARYyNlFQa6cg==", + "dependencies": { + "Diacritics": "4.0.17", + "ICU4N.Transliterator": "60.1.0-alpha.356" + } + }, + "Jellyfin.MediaEncoding.Keyframes": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "/OBcg4Qj825elOGNj5bNRfABKzfAf4qNQj0/d/DwhG/+V/wsKuxS0Pc/xOEagVVjXOnqGPZz/+k8D4UvnvMoHw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "NEbml": "1.1.0.5" + } + }, + "Jellyfin.Model": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "h+61RSXn4sk8fS6Zx9RkDyVnI5VnNbrsR2p8WcvybtNSW2pgU2uZ9pwEv2awD3ifX69weqYpQLMh91f6aidW2A==", + "dependencies": { + "Jellyfin.Data": "10.11.0", + "Jellyfin.Extensions": "10.11.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "System.Globalization": "4.3.0", + "System.Text.Json": "9.0.10" + } + }, + "Jellyfin.Naming": { + "type": "Transitive", + "resolved": "10.11.0", + "contentHash": "2++xSbhdFSb1J3XySjC6UU+uII6OdKc0DfkYx/E1oN7mSjoftyZR8eU045kVWBwsAxr+UcMI6t2DYfES2tJkRA==", + "dependencies": { + "Jellyfin.Common": "10.11.0", + "Jellyfin.Model": "10.11.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "WjjxVyOTVs85V7SUe+lZjtGOEeVzF4RO8amrqL4adgbyThNq7vGCFzPw8buZj44gHeQYD5V/uZ/6XuqG9Jq+kA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.10", + "Microsoft.Extensions.Caching.Memory": "9.0.10", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "I3TWAs5Lbzmzu8S0T6qXhzBiO3CznYLrfE59W0npkqNHfWGH8CgA66LrHMWxWOXVTD4145QwYqiWNCdLwpJ1Ew==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "mXNl0Gg3l3zGrClLCHepB+b7rYVuFfB9qQJwya0dUSHFuR1T0jMD8KxuNVyhQSfoWIepanhzjcG8TUNGXOcU0Q==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "IJNrG5vdmFUvHR8FLLFg9AWpuE8qW1DTEN+fNLGbNTu6cnpZzzqU6+aknAGtTSAEVWosJ3BZ3TOO9wpifUvv3A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.10", + "Microsoft.Extensions.Caching.Memory": "9.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10", + "Microsoft.Extensions.Logging": "9.0.10" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "cL6iTxgJ4u5zP3eFOdBiDAtmE/B2WKTRhyJfEne7n6qvHpsMwwIDxljs210mWSO1ucBy7lR1Lo7/7kjeZeLcqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "2iuzwIoCoqZJfH2VLk1xvlQS4PQDEuhj4dWiGb+Qpt1vHFHyffp497cTO6ucsV54W/h4JmM1vzDBv8pVAFazZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "Microsoft.Extensions.Options": "9.0.10", + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ad3JxmFj0uxuFa1CT6oxTCC1lQ0xeRuOvzBRFT/I/ofIXVOnNsH/v2GZkAJWhlpZqKUvSexQZzp3EEAB2CdtJg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "D6Kng+9I+w1SQPxJybc6wzw9nnnyUQPutycjtI0svv1RHaWOpUk9PPlwIRfhhoQZ3yihejkEI2wNv/7VnVtkGA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "iEtXCkNd5XhjNJAOb/wO4IhDRdLIE2CsPxZggZQWJ/q2+sa8dmEPC393nnsiqdH8/4KV8Xn25IzgKPR1UEQ0og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "r9waLiOPe9ZF1PvzUT+RDoHvpMmY8MW+lb4lqjYGObwKpnyPMLI3odVvlmshwuZcdoHynsGWOrCPA0hxZ63lIA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UBXHqE9vyptVhaFnT1R7YJKCve7TqVI10yjjUZBNGMlW2lZ4c031Slt9hxsOzWCzlpPxxIFyf1Yk4a6Iubxx7w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.10", + "Microsoft.Extensions.Logging.Abstractions": "9.0.10", + "Microsoft.Extensions.Options": "9.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "MFUPv/nN1rAQ19w43smm6bbf0JDYN/1HEPHoiMYY50pvDMFpglzWAuoTavByDmZq7UuhjaxwrET3joU69ZHoHQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "zMNABt8eBv0B0XrWjFy9nZNgddavaOeq3ZdaD5IlHhRH65MrU7HM+Hd8GjWE3e2VDGFPZFfSAc6XVXC17f9fOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.10", + "Microsoft.Extensions.Primitives": "9.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "3pl8D1O5ZwMpDkZAT2uXrhQ6NipkwEgDLMFuURiHTf72TvkoMP61QYH3Vk1yrzVHnHBdNZk3cQACz8Zc7YGNhQ==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "NEbml": { + "type": "Transitive", + "resolved": "1.1.0.5", + "contentHash": "svtqDc+hue9kbnqNN2KkK4om/hDrc7K127cNb5FIYfgKgzo+JNDPXNLp8NioCchHhBO3lxWd4Cp/iiZZ3aoUqg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Polly": { + "type": "Transitive", + "resolved": "8.6.4", + "contentHash": "uuBsDoBw0oYrMe3uTWRjkT2sIkKh+ZZnnDrLb4Z+QANfeA4+7FJacx6E8CY5GAxXRoSgFrvUADEAQ7DPF6fGiw==", + "dependencies": { + "Polly.Core": "8.6.4" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.4", + "contentHash": "4AWqYnQ2TME0E+Mzovt1Uu+VyvpR84ymUldMcPw7Mbj799Phaag14CKrMtlJGx5jsvYP+S3oR1QmysgmXoD5cw==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "3KPvpKysDmEMt0NnAZPX5U6KFk0LmG/72/IjAIJemIksIZ0Tjs9pGpr3L+zboVCv1MLVoJLKl3nJDXUG6Jda6A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "dRQ75MCI8oz6zAs2Y1w6pq6ARs4MhdNG+gf3doOxOxdnueDXffQLGQIxON54GDoxc0WjKOoHMKBR4DhaduwwQw==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "XM02ZBnzxk7Ti6l9YRy8Bp639wANqJzJzw4W4VYiCh+HXY9hBOWkGB4k89OLP/s/RxgM02P4a/mWcJTgFiLf1Q==" + }, + "System.Threading.Tasks.Dataflow": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "k1o6v6V3+4mznSnPnO0FBaRjiAPL1ouKPfPQH7hO9Z2SwJHt8E45F4wX5yQh1aeja1JHPYEungQedXibng654Q==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "Jellyfin.Plugin.JellyfinEnhanced": { + "type": "Project", + "dependencies": { + "Jellyfin.Controller": "[10.11.0, )", + "Jellyfin.Model": "[10.11.0, )", + "Newtonsoft.Json": "[13.0.4, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/backend/packages.jf12.lock.json b/tests/backend/packages.jf12.lock.json new file mode 100644 index 000000000..6b1392a56 --- /dev/null +++ b/tests/backend/packages.jf12.lock.json @@ -0,0 +1,453 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.4, )", + "resolved": "6.0.4", + "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Direct", + "requested": "[8.3.1.1, )", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "Q3ia+k+wYM3Iv/Qq5IETOdpz/R0xizs3WNAXz699vEQx5TMVAfG715fBSq9Thzopvx8dYZkxQ/mumTn6AJ/vGQ==" + }, + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "QBMaEmLIAzUUnH7+1Vbui+9Ui0dSsglkKxjW4sofOHycB3218UrnrUzq+Y75syrR4krUCtY7loWKaVBOeU3qKw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.72, )", + "resolved": "4.20.72", + "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "SkiaSharp": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "DNDwbRjP+aMo27dV2h/uHCVTcWubWWxHnPLiePNyl24f4Pv43mQ8AQQeseOrKR+J3AOCEs6t0sUjo0aa3j3RWQ==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.116.1", + "SkiaSharp.NativeAssets.macOS": "3.116.1" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "ibDG1+quN86vBd9ztjDAC9wnvS1nRZ6ydTUOSod4NsRHWdLLGzWYn1IOF4Cg9iJh5cQHdpzhUZBQE0JMKznrow==", + "dependencies": { + "HarfBuzzSharp": "8.3.0.1", + "SkiaSharp": "3.116.1" + } + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Direct", + "requested": "[3.116.1, )", + "resolved": "3.116.1", + "contentHash": "f4OSsrMwe+Ei9KpNc3EMUiMcZkQSYepHCaDuvMyLBaHVQd+9Ywq/rlbmapqztsU6k2iPEVLD8ZuBJm9RDJsAow==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.0.2, )", + "resolved": "3.0.2", + "contentHash": "oXbusR6iPq0xlqoikjdLvzh+wQDkMv9If58myz9MEzldS4nIcp442Btgs2sWbYWV+caEluMe2pQCZ0hUZgPiow==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "Diacritics": { + "type": "Transitive", + "resolved": "4.1.8", + "contentHash": "6+I0/ui1GvFS3SFSs+92b7yNqq6t32iaBQcO9RnepCiEViD2XmvsSIh4r1VTJe94gDxtkDo9fOSbRK1FjSSpGg==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "rwLpl+W6uqu0DuvzqNhTMuFcXfy1Vc0uq0YXgPEmtTSfeUSAye1FcARrm2YIPOSiCBwBOGu3cLvMX5Fp6OKe2g==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.0.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.0.1" + } + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "2o6U05LAmK+rwX7TvmJ2X0anXJG2hSE7kHVmCshhHy0tKfByJ5ykBacvhmmooHchlOwq15KBZeROGafCT8nN+g==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.0.1", + "contentHash": "ow0DtGEUjo65qhiI22of7qiVbN1xDFsZ5P5xJljRmGZ5WSxNy+1batLNJFGxahqhB1MTHYV8kAXf0GqC8WaevQ==" + }, + "ICU4N": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "YMZtDnjcqWzziOKiE7w6Ma7Rl5vuFDxzOsUlHh1QyfghbNEIZQOLRs9MMfwCWAjX6n9UitrF6vLXy55Z5q+4Fg==", + "dependencies": { + "J2N": "2.0.0", + "Microsoft.Extensions.Caching.Memory": "2.0.0" + } + }, + "ICU4N.Transliterator": { + "type": "Transitive", + "resolved": "60.1.0-alpha.356", + "contentHash": "lFOSO6bbEtB6HkWMNDJAq+rFwVyi9g6xVc5O/2xHa6iZnV7wLVDqCbaQ4W4vIeBSQZAafqhxciaEkmAvSdzlCg==", + "dependencies": { + "ICU4N": "60.1.0-alpha.356" + } + }, + "J2N": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "M5bwDajAARZiyqupU+rHQJnsVLxNBOHJ8vKYHd8LcLIb1FgLfzzcJvc31Qo5Xz/GEHFjDF9ScjKL/ks/zRTXuA==" + }, + "Jellyfin.Common": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "U0XY7mKv1FSc0Dv10GOB+XDoiafTKnPe8zR/e8N/Cy1CMGKpbrRoAGUv7CwkJWBzk8vridlE5rP2L2Xow3QUrA==", + "dependencies": { + "Jellyfin.Model": "12.0.0" + } + }, + "Jellyfin.Controller": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "CtqEPr2+oXjtHnlk2ZcavhTLLwaSLPa4DNu1qK8tv9S+qrz4PABYqEElO7NWkfPwGVcfaA+XD5mziHmZokAFHw==", + "dependencies": { + "Jellyfin.Common": "12.0.0", + "Jellyfin.MediaEncoding.Keyframes": "12.0.0", + "Jellyfin.Model": "12.0.0", + "Jellyfin.Naming": "12.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.11" + } + }, + "Jellyfin.Data": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "ogSgr4rjssk6vwAPgSTWrQOb8vj1XWQmROf64FKqq19AWUykGTHcaJiQWRkYiZFruOCXJQeNGOWv8IwCtfagBA==", + "dependencies": { + "Jellyfin.Database.Implementations": "12.0.0", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Jellyfin.Database.Implementations": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "9MPKdD1MymXICBOjnt96YooZz1oI4lsL34sV+4iaG0kx4Jmm2qfA2/9uZbb3Y7+ZSWSA1Fa+21SJtyQ3fakboQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Polly": "8.7.0" + } + }, + "Jellyfin.Extensions": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "p6XEGIE46Fn2SqZlPAlkeaP45SpPPtJ3fJPeNHXoGwOx83d09QENGrs4Ie8rTrWuoPoGvn/7g40d8eBrDzgADA==", + "dependencies": { + "Diacritics": "4.1.8", + "ICU4N.Transliterator": "60.1.0-alpha.356" + } + }, + "Jellyfin.MediaEncoding.Keyframes": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "RRyxFLr6JNDTatPPsn1xvQ1mhIUvwm7iWjEyh2Lt8FhLbEETsNaWSZKvfQxY2Gm5pMPRjlv0dH3tNUguyk6ZtQ==", + "dependencies": { + "NEbml": "1.1.0.5" + } + }, + "Jellyfin.Model": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "tunpZL+hOVh2CveVwBmGqMLC48ozlDsVbCLK/Xf3HBV0F1iIyeVMsxHahin7AMgYwCjbURfsov/ZA8ZO6CgYTQ==", + "dependencies": { + "Jellyfin.Data": "12.0.0", + "Jellyfin.Extensions": "12.0.0" + } + }, + "Jellyfin.Naming": { + "type": "Transitive", + "resolved": "12.0.0", + "contentHash": "lHf27aSFmzYQoQ8ggY/7gst2LnjywW7xN6mRkScMzBmH5STvD+kfepGT38Ee4xDM1Fe17yxCiRWJKsKaFwVIcg==", + "dependencies": { + "Jellyfin.Common": "12.0.0", + "Jellyfin.Model": "12.0.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "NEbml": { + "type": "Transitive", + "resolved": "1.1.0.5", + "contentHash": "svtqDc+hue9kbnqNN2KkK4om/hDrc7K127cNb5FIYfgKgzo+JNDPXNLp8NioCchHhBO3lxWd4Cp/iiZZ3aoUqg==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Polly": { + "type": "Transitive", + "resolved": "8.7.0", + "contentHash": "0qR4f0OR8FeCAfLWcfwzAM7w6EmpUgwa22PgxKjcL25dEAto7sKpQQXbtxp38vVvK+V3RFkh/TeI7g/iUdoYIQ==", + "dependencies": { + "Polly.Core": "8.7.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.7.0", + "contentHash": "BS2t+nsBer16PIebCEPNBK5fgMisADQyRCd7K+BgkMWpFmSaiYE+rVVNpFhGRqUkJmNSLmw0uCNzHHWfgml28Q==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "3KPvpKysDmEMt0NnAZPX5U6KFk0LmG/72/IjAIJemIksIZ0Tjs9pGpr3L+zboVCv1MLVoJLKl3nJDXUG6Jda6A==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.116.1", + "contentHash": "dRQ75MCI8oz6zAs2Y1w6pq6ARs4MhdNG+gf3doOxOxdnueDXffQLGQIxON54GDoxc0WjKOoHMKBR4DhaduwwQw==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "Jellyfin.Plugin.JellyfinEnhanced": { + "type": "Project", + "dependencies": { + "Jellyfin.Controller": "[12.0.0, )", + "Jellyfin.Model": "[12.0.0, )", + "Newtonsoft.Json": "[13.0.4, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/browser/README.md b/tests/browser/README.md new file mode 100644 index 000000000..67b8a3e6c --- /dev/null +++ b/tests/browser/README.md @@ -0,0 +1,33 @@ +# Browser regressions + +These tests execute production JE scripts in a small deterministic **mock host page**, not in Jellyfin. The real Jellyfin-host suite is separate. API surfaces in the language settings tests are local fixtures; modal save callbacks record actions without contacting Seerr. Passing these tests does not establish complete Jellyfin UI compatibility, successful media requests, or native-client support. + +```sh +npm ci +npx playwright install --with-deps chromium firefox +npm run test:browser +npm run test:visual +``` + +The suite runs desktop Chromium, mobile Chromium (Pixel 7 viewport/touch emulation, not Android), and desktop Firefox. It starts and stops its own loopback fixture server on port 4179. External requests are blocked except explicitly intercepted locale fixtures. Unexpected console errors and page errors fail tests; retries are disabled. Traces and screenshots are retained for failures under `artifacts/browser`, alongside HTML and JUnit reports. `JE_CHROMIUM_PATH` is an optional local diagnostic override; use Playwright's pinned browser for baseline checks. + +Covered behavior: + +- Production Seerr modal: primary callback receives the selected fixture season, cancel/back/Escape, repeated open/close cleanup, keyboard focus wrapping, text injection protection, responsive layout. +- Production advanced request forms: regular/4K default server matching, alphabetical server order, quality/root-folder defaults, free-space labels, dependent dropdown replacement and reset. +- Production navigation: push/replace URL transitions, redundant events, unsubscribe. +- Production UI primitives: replacement/removal of injected CSS, Seerr stylesheet deduplication, escaped toast content and controlled expiry. +- Production language settings: persisted regional locale normalization, per-user storage isolation, automatic language selection, translation-cache clearing without deleting unrelated storage, failed locale discovery. + +The test fixture bundles Liberation Sans Regular/Bold under the SIL Open Font License (see `fonts/LICENSE`), eliminating system-font dependence for the visual fixture. Other browser tests include non-Latin text; the visual fixture uses Latin text to avoid platform-dependent CJK fallback fonts. Screenshots use pinned viewports, locale/timezone, dark color scheme, disabled transitions/animations, and loaded fixture fonts. One modal baseline per browser project captures actual production CSS. + +Review visual changes before updating snapshots. Inspect the retained actual/expected/diff images and determine whether production behavior changed intentionally. To introduce or intentionally update baselines: + +```sh +npx playwright test --config tests/browser/playwright.config.cjs --grep @visual --update-snapshots +npm run test:visual +``` + +Commit the reviewed PNG changes with their reason. Do not update them merely to clear a failure. Existing baselines were initially created on Linux; use Linux for visual comparisons. Missing browsers, missing snapshots, and unavailable servers are failures, not skips. + +Remaining scope: full Enhanced panel wiring, poster-tag interactions, bookmarks, hidden-content/Spoiler Guard browser journeys, playback, real touch hardware, and complete native Jellyfin navigation are not established by this fixture suite. Consult the coverage matrix for their other test layers and gaps. diff --git a/tests/browser/fixture.html b/tests/browser/fixture.html new file mode 100644 index 000000000..b6440865c --- /dev/null +++ b/tests/browser/fixture.html @@ -0,0 +1,18 @@ +JE browser regression fixture + +

JE regression fixture

+ + + + + + diff --git a/tests/browser/flows.spec.cjs b/tests/browser/flows.spec.cjs new file mode 100644 index 000000000..dec2254f4 --- /dev/null +++ b/tests/browser/flows.spec.cjs @@ -0,0 +1,159 @@ +const { test: base, expect } = require('@playwright/test'); +const test = base.extend({ + page: async ({ page }, use) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1' ? route.continue() : route.abort()); + await page.goto('/'); + await use(page); + expect(errors, 'unexpected browser errors').toEqual([]); + } +}); +test('modal primary button invokes callback with selected fixture season once', async ({ page }) => { + await page.getByRole('button', { name: 'Open request' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByLabel('Season', { exact: true }).selectOption('2'); + await page.getByRole('button', { name: 'Request', exact: true }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.locator('#output')).toHaveText('Requested season 2'); + expect(await page.evaluate(() => [saved, closeCount])).toEqual([1, 1]); +}); +test('keyboard focus stays within modal and Escape cleans up across repeated opens', async ({ page }) => { + for (let i = 1; i <= 3; i++) { + await page.locator('#open').click(); + await expect(page.locator('#season')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('button', { name: 'Request', exact: true })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator('#season')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await page.evaluate(() => closeCount)).toBe(i); + await expect(page.locator('body')).not.toHaveClass(/jellyseerr-modal-is-open/); + } +}); +test('cancel and browser Back close without submitting', async ({ page }) => { + await page.locator('#open').click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.locator('#open').click(); + await page.goBack(); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await page.evaluate(() => [saved, closeCount])).toEqual([0, 2]); +}); +test('untrusted modal subtitles render as text', async ({ page }) => { + const hostile = ' & 日本語'; + await page.evaluate(text => openRequest(text), hostile); + await expect(page.locator('.jellyseerr-season-subtitle')).toHaveText(hostile); + await expect(page.locator('.jellyseerr-season-subtitle img')).toHaveCount(0); + expect(await page.evaluate(() => window.compromised)).toBeUndefined(); +}); +test('modal fits viewport and has labelled controls @visual', async ({ page }) => { + await page.evaluate(() => openRequest('Synthetic Series')); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('#season')).toBeFocused(); + const bounds = await page.locator('.jellyseerr-season-content').boundingBox(); + const viewport = page.viewportSize(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width + 1); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height + 1); + await expect(page).toHaveScreenshot('request-modal.png', { animations: 'disabled', maxDiffPixelRatio: 0.002 }); +}); +test('SPA navigation deduplicates push/replace/hash events and unsubscribes', async ({ page }) => { + const result = await page.evaluate(() => { + const urls=[]; const unsubscribe=JellyfinEnhanced.core.navigation.onNavigate(()=>urls.push(location.hash)); + history.pushState({}, '', '#/home'); + history.pushState({}, '', '#/home'); + history.replaceState({}, '', '#/details?id=one'); + window.dispatchEvent(new PopStateEvent('popstate')); + window.dispatchEvent(new HashChangeEvent('hashchange')); + unsubscribe(); history.pushState({}, '', '#/search'); + return urls; + }); + expect(result).toEqual(['#/home', '#/details?id=one']); +}); +test('style injection replaces stale rules without duplicates and removes cleanly', async ({ page }) => { + await page.evaluate(() => { + JellyfinEnhanced.core.ui.injectCss('regression-style', '#output { color: red }'); + JellyfinEnhanced.core.ui.injectCss('regression-style', '#output { color: rgb(0, 128, 0) }'); + JellyfinEnhanced.jellyseerrUI.addMainStyles(); + JellyfinEnhanced.jellyseerrUI.addSeasonModalStyles(); + }); + await expect(page.locator('#regression-style')).toHaveCount(1); + await expect(page.locator('#jellyseerr-styles')).toHaveCount(1); + await expect(page.locator('#output')).toHaveCSS('color', 'rgb(0, 128, 0)'); + expect(await page.evaluate(() => [JellyfinEnhanced.core.ui.removeCss('regression-style'), JellyfinEnhanced.core.ui.removeCss('regression-style')])).toEqual([true,false]); +}); +test('toast displays escaped content and expires under controlled time', async ({ page }) => { + await page.clock.install(); + await page.evaluate(() => JellyfinEnhanced.toast(JellyfinEnhanced.escapeHtml(''), 1000)); + await page.clock.runFor(20); + await expect(page.locator('.jellyfin-enhanced-toast')).toHaveText(''); + await expect(page.locator('.jellyfin-enhanced-toast script')).toHaveCount(0); + await page.clock.runFor(1500); + await expect(page.locator('.jellyfin-enhanced-toast')).toHaveCount(0); +}); + +async function languagePanel(page, { saved = 'zh-hk', user = 'user-a', fail = false } = {}) { + await page.route('https://api.github.com/**', route => route.fulfill({ json: [] })); + await page.route('**/JellyfinEnhanced/locales', route => route.fulfill({ json: ['en-US','zh-HK','de','pr'] })); + await page.route('**/Localization/Cultures', route => route.fulfill({ json: [{ TwoLetterISOLanguageName: 'de', DisplayName: 'Deutsch' }] })); + await page.evaluate(({saved,user,fail}) => { + document.querySelector('main').innerHTML = ''; + JellyfinEnhanced.currentSettings = { displayLanguage: saved }; + window.ApiClient = { getCurrentUserId:()=>user, getUrl:path=>path, ajax: ({url}) => fail ? Promise.reject(new Error('fixture unavailable')) : fetch(url).then(r=>r.json()) }; + window.savedSettings=[]; + JellyfinEnhanced.saveUserSettings = async (file,settings)=>savedSettings.push({file,settings:{...settings}}); + }, {saved,user,fail}); + await page.addScriptTag({url:'/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js'}); + await page.evaluate(() => JellyfinEnhanced.internals.enhancedUi.wireLanguageControls({resetAutoCloseTimer:()=>{}})); +} +test('language selector normalizes persisted region and saves only current user', async ({ page }) => { + await languagePanel(page); + await expect(page.getByLabel('Display language')).toHaveValue('zh-HK'); + await expect(page.getByRole('option', {name:'Chinese (Hong Kong)'})).toHaveCount(1); + await page.clock.install(); + await page.evaluate(()=>localStorage.setItem('user-b-language','pr')); + await page.getByLabel('Display language').selectOption('de'); + expect(await page.evaluate(()=>({settings:savedSettings, a:localStorage.getItem('user-a-language'),b:localStorage.getItem('user-b-language')}))).toEqual({settings:[{file:'settings.json',settings:{displayLanguage:'de'}}],a:'de',b:'pr'}); +}); +test('automatic language clears override and cache clearing preserves unrelated settings', async ({ page }) => { + await languagePanel(page); + await expect(page.getByLabel('Display language')).toHaveValue('zh-HK'); + await page.clock.install(); + await page.getByLabel('Display language').selectOption(''); + await page.evaluate(()=>{localStorage.setItem('JE_translation_de','{}');localStorage.setItem('JE_translation_ts_de','123');localStorage.setItem('bookmarks','keep');}); + await page.getByRole('button',{name:'Clear translations'}).click(); + expect(await page.evaluate(()=>({language:localStorage.getItem('user-a-language'),translations:localStorage.getItem('JE_translation_de'),timestamp:localStorage.getItem('JE_translation_ts_de'),bookmarks:localStorage.getItem('bookmarks')}))).toEqual({language:'',translations:null,timestamp:null,bookmarks:'keep'}); +}); +test('locale enumeration failure leaves automatic language usable', async ({ page }) => { + await languagePanel(page,{saved:'',fail:true}); + await expect(page.getByLabel('Display language')).toHaveValue(''); + await expect(page.getByRole('option')).toHaveCount(1); + await expect(page.getByLabel('Display language')).toBeEnabled(); +}); + +for (const is4k of [false, true]) { + test(`advanced request options select matching ${is4k ? '4K' : 'regular'} default and refresh dependent fields`, async ({ page }) => { + await page.evaluate(is4k => { + const api = JellyfinEnhanced.jellyseerrModal; + const request = api.create({ title: 'Advanced request', subtitle: 'Fixture API data', bodyHtml: api.createAdvancedOptionsHTML('movie'), onSave:()=>{} }); + request.show(); + api.populateAdvancedOptions(request.modalElement, {servers:[ + {id:2,name:'Zulu 4K',isDefault:true,is4k:true,activeProfileId:20,activeDirectory:'/4k',qualityProfiles:[{id:20,name:'Ultra HD'}],rootFolders:[{path:'/4k',freeSpace:1073741824}]}, + {id:1,name:'Alpha regular',isDefault:true,is4k:false,activeProfileId:10,activeDirectory:'/movies',qualityProfiles:[{id:10,name:'HD'}],rootFolders:[{path:'/movies',freeSpace:0}]} + ]}, 'movie', is4k); + }, is4k); + await expect(page.locator('#movie-server')).toHaveValue(is4k ? '2' : '1'); + await expect(page.locator('#movie-quality')).toHaveValue(is4k ? '20' : '10'); + await expect(page.locator('#movie-folder')).toHaveValue(is4k ? '/4k' : '/movies'); + await expect(page.locator('#movie-server option')).toHaveText(['Select Server...', 'Alpha regular', 'Zulu 4K']); + await page.locator('#movie-server').selectOption(is4k ? '1' : '2'); + await expect(page.locator('#movie-quality option')).toHaveText(['Select Quality...', is4k ? 'HD' : 'Ultra HD']); + await expect(page.locator('#movie-folder option')).toHaveText(['Select Folder...', is4k ? '/movies (0 Bytes)' : '/4k (1 GB)']); + await page.locator('#movie-server').selectOption(''); + await expect(page.locator('#movie-quality option')).toHaveCount(1); + await expect(page.locator('#movie-folder option')).toHaveCount(1); + }); +} diff --git a/tests/browser/flows.spec.cjs-snapshots/request-modal-chromium-linux.png b/tests/browser/flows.spec.cjs-snapshots/request-modal-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..7c0a78f050141a992cd45dfcc6d1ac9c6ff4f72d GIT binary patch literal 127980 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
  • QC|{oxE8&2vt!DyjpxZDNSlo(Fyy{m-VLbF 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

    qW^OlP? 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 literal 0 HcmV?d00001 diff --git a/tests/browser/flows.spec.cjs-snapshots/request-modal-mobile-chromium-linux.png b/tests/browser/flows.spec.cjs-snapshots/request-modal-mobile-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..c6bd1257630e33844a7108c4b36a2d45dfd637e9 GIT binary patch literal 85531 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⪻tz}H{i2#^>Han%U|4!i^?BQ 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 Bc~JlW literal 0 HcmV?d00001 diff --git a/tests/browser/fonts/LICENSE b/tests/browser/fonts/LICENSE new file mode 100644 index 000000000..465cf9c56 --- /dev/null +++ b/tests/browser/fonts/LICENSE @@ -0,0 +1,101 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE The goals of the Open Font License (OFL) are to stimulate +worldwide development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to provide +a free and open framework in which fonts may be shared and improved in +partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. +The fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + + + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. +This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting ? in part or in whole ? +any of the components of the Original Version, by changing formats or +by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + + + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/browser/fonts/LiberationSans-Bold.ttf b/tests/browser/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c6f2ce3a63eddc184f72a4e1ca2eff9562d77689 GIT binary patch literal 414568 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 z%0A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 literal 0 HcmV?d00001 diff --git a/tests/browser/fonts/LiberationSans-Regular.ttf b/tests/browser/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..adb89475fd7766ace28a4b224b94b55f3d7ff9c2 GIT binary patch literal 410820 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 Y%Z`1^)%W0k595D(6%+sB_gu>V4Ym~YZ~y=R literal 0 HcmV?d00001 diff --git a/tests/browser/playwright.config.cjs b/tests/browser/playwright.config.cjs new file mode 100644 index 000000000..aa8bfcfb0 --- /dev/null +++ b/tests/browser/playwright.config.cjs @@ -0,0 +1,16 @@ +const { defineConfig, devices } = require('@playwright/test'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +module.exports = defineConfig({ + testDir: __dirname, fullyParallel: true, forbidOnly: !!process.env.CI, + retries: 0, workers: process.env.CI ? 2 : 3, timeout: 15000, + reporter: [['list'], ['html', { outputFolder: path.join(root, 'artifacts/browser/report'), open: 'never' }], ['junit', { outputFile: path.join(root, 'artifacts/browser/results.xml') }]], + outputDir: path.join(root, 'artifacts/browser/results'), + use: { baseURL: 'http://127.0.0.1:4179', locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: process.env.JE_CHROMIUM_PATH ? { executablePath: process.env.JE_CHROMIUM_PATH } : {} } }, + { name: 'mobile-chromium', use: { ...devices['Pixel 7'], launchOptions: process.env.JE_CHROMIUM_PATH ? { executablePath: process.env.JE_CHROMIUM_PATH } : {} } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } } + ], + webServer: { command: 'node tests/browser/server.cjs', cwd: root, url: 'http://127.0.0.1:4179', reuseExistingServer: false }, +}); diff --git a/tests/browser/server.cjs b/tests/browser/server.cjs new file mode 100644 index 000000000..4928002fe --- /dev/null +++ b/tests/browser/server.cjs @@ -0,0 +1,15 @@ +// Local static fixture server. No Jellyfin host or external service is impersonated. +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +http.createServer((req, res) => { + const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); + const file = pathname === '/' ? path.join(__dirname, 'fixture.html') : path.resolve(root, '.' + pathname); + if (!file.startsWith(root + path.sep)) { res.writeHead(403).end(); return; } + fs.readFile(file, (error, data) => { + if (error) { res.writeHead(404).end(); return; } + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : file.endsWith('.ttf') ? 'font/ttf' : file.endsWith('.woff2') ? 'font/woff2' : 'text/html'); + res.end(data); + }); +}).listen(Number(process.env.JE_BROWSER_PORT || 4179), '127.0.0.1'); diff --git a/tests/docs/README.md b/tests/docs/README.md new file mode 100644 index 000000000..f566e487a --- /dev/null +++ b/tests/docs/README.md @@ -0,0 +1,75 @@ +# Regression testing + +JE has separate .NET builds for Jellyfin 10.11 (`jf10`, .NET 9) and Jellyfin 12 (`jf12`, .NET 10). Tests exercise production code, browser modules, and disposable Jellyfin servers. See [the coverage matrix](coverage-matrix.md) for tested behavior and remaining gaps; a green run does not mean every feature or combination is covered. + +## Setup + +The complete suite is validated on native Linux. The native renderer test packages and direct container-IP access are Linux-specific; Windows, macOS, and Docker Desktop runs are not validated. + +Use Python 3.10+, Node.js 26.2.0 for the full suite (22.14+ suffices for frontend/browser tests), npm, a .NET 10 SDK, and both .NET 9 and .NET 10 ASP.NET Core runtimes. Install current servicing patches: an older runtime patch can fail to launch the test host even when compilation succeeds. `dotnet --list-sdks` and `dotnet --list-runtimes` show what the selected executable uses. If installing .NET in a separate directory, put its `dotnet` executable first on `PATH` and set `DOTNET_ROOT` to that directory. + +```sh +npm ci +npx playwright install --with-deps chromium firefox +python3 tests/run.py fast +``` + +The real-host suite additionally requires a running Linux Docker engine accessible to your user. It creates an internal network, temporary configuration/media, and disposable users, builds the plugin, and installs it in digest-pinned official Jellyfin images. It never needs your Jellyfin server, personal library, or provider credentials. Container IP access currently requires native Linux Docker; Docker Desktop is not a validated environment. + +The strict poster language-parity checks use Node 26.2.0 / ICU 78.3, matching the generated production language tables; a different ICU version can legitimately produce different names. Initial package/browser/container downloads require network access. Provider integration tests use deterministic local or in-process responses; they do not call live provider accounts. NuGet dependencies have separate `packages.jf10.lock.json` and `packages.jf12.lock.json` files for both projects, and the backend runner restores in locked mode. `package-lock.json` records the npm dependency graph. After an intentional NuGet dependency change, regenerate both targets with `dotnet restore tests/backend/JE.Tests.csproj -p:JellyfinTarget=jf10 --force-evaluate` and the corresponding `jf12` command, then review the lockfile changes. + +## Commands + +Run commands from the repository root: + +| Command | Scope | +| --- | --- | +| `python3 tests/run.py fast` | Inventory freshness, backend on both targets, frontend with coverage | +| `python3 tests/run.py backend --target jf12` | Backend behavior and HTTP pipeline, one build target | +| `python3 tests/run.py frontend` | JavaScript behavioral tests and full-source coverage | +| `python3 tests/run.py browser` | Mock-host browser component tests, including visual assertions | +| `python3 tests/run.py visual` | Visual subset only | +| `python3 tests/run.py host --target all` | Actual Jellyfin host API and Chromium UI journeys on both compatibility lines | +| `python3 tests/runner/poster.py` | Existing native pipeline, offline JS/C# parity, embedded poster assets | +| `python3 tests/runner/mutations.py --target jf12` | Isolated representative fault injection | +| `python3 tests/run.py history` | Replay selected historical issue/PR faults in disposable copies, both backend targets | +| `python3 tests/run.py all` | Complete regression entry point | +| `python3 tests/inventory/generate.py` | Regenerate production inventory after source changes | +| `python3 tests/runner/clean.py` | Install dependencies and run the full suite in a fresh copy of tracked and unignored local files | + +`--target jf10`, `--target jf12`, or `--target all` selects backend/host compatibility coverage. A single-target run is not a complete compatibility run. Direct test commands support focused iteration: + +```sh +dotnet test tests/backend/JE.Tests.csproj -p:JellyfinTarget=jf12 --artifacts-path artifacts/focused-jf12 --filter 'FullyQualifiedName~JE.Tests.Core' +node --test tests/frontend/core/*.test.mjs +npx playwright test --config tests/browser/playwright.config.cjs --project=firefox +``` + +Use distinct `--artifacts-path` directories for concurrent .NET builds, especially when switching targets. Normal regression orchestration does this automatically. + +## Test layers and evidence + +- Backend xUnit tests load the actual plugin and use isolated files, controlled HTTP responses, and host interfaces. ASP.NET TestServer checks routing, authorization, and middleware; these are not a substitute for real-host tests. +- Frontend tests evaluate actual production scripts in jsdom. They cover state, requests, rendering, user changes, lifecycle cleanup, and failure handling. Expected errors are declared explicitly; unexpected requests/errors fail tests. +- Playwright runs Chromium desktop/mobile and Firefox against a minimal mock-host page loading production modules. Its component fixtures do not represent a complete Jellyfin browser session or actual native-client validation. See [browser details](../../tests/browser/README.md). +- Host tests run the actual built plugin in official Jellyfin containers. The main runner enables real Chromium login, injected bootstrap, settings-panel save, and reload checks; `python3 tests/host/run.py --target all` runs the API-only subset. Their reports list each verified scenario; host smoke coverage is deliberately distinguished from full application E2E coverage. +- Poster tests compare production JavaScript and C# outputs using synthetic fixtures and validate native image pipeline/assets without a media server or live metadata. +- Mutation checks copy sources into temporary workspaces and verify selected injected faults fail their target tests. They do not alter the working copy. + +The runner writes commands, exit status, durations, and log locations to `artifacts/regression/summary.json`. Backend TRX/Cobertura reports are under `artifacts/regression/backend/`; frontend HTML/LCOV coverage is under `artifacts/frontend-coverage/`; browser reports/traces/screenshots are under `artifacts/browser/`. Host and poster reports include the exact scenarios exercised. Generated artifacts are ignored by Git. `--artifacts` relocates runner/backend/host/poster/mutation reports; frontend and browser tools keep the fixed paths above. The clean-tree command copies those fixed reports back before removing its temporary source tree and records source hashes for the exact local snapshot tested. + +Coverage includes unexecuted production modules; thresholds prevent measured coverage from silently dropping but do not certify feature completeness. Backend floors are 38% lines and 27% branches, below the initial integrated 41%/29% baseline; frontend global floors are 20% statements, 15% branches, 21% functions and 21% lines (below the measured 22%/17%/23%/23% baseline); the frontend runner declares its global and core-module floors alongside report generation. No production code is excluded merely to raise these values. Review the behavioral matrix alongside reports. The generated inventory is a lexical list of production files, routes, settings, tasks, and storage references—not a behavioral test. + +The [historical issue/PR audit](history-audit.md) records proven existing catches, newly repaired blind spots, and unproven reports. Historical replay also runs in scheduled/manual extended CI and the full `all` suite. + +## Visual baseline changes + +Run visual tests in the pinned Playwright Linux environment. Review the failed screenshot and diff first. If the change is intended, update only the relevant baseline with `npx playwright test --config tests/browser/playwright.config.cjs --grep @visual --update-snapshots`, inspect each changed PNG, then run the tests normally. Never automatically approve baseline updates in CI. Test fixtures use bundled licensed fonts and fixed locale, timezone, viewport, and color scheme. See the browser README for baseline-specific instructions. + +## CI and troubleshooting + +`regression.yml` runs backend checks for both targets, JavaScript coverage, and browser checks on pull requests. Scheduled/manual runs additionally execute real-host compatibility and the broader checks. Existing security, translation, and static-analysis workflows remain in place. Failed test commands fail their jobs; artifacts are uploaded even on failure. + +Missing runtimes, dependencies, browser executables, or Docker access are failures, not skips. Check `dotnet --list-runtimes`, run `npm ci`, install the pinned Playwright browsers, or check `docker info` as appropriate. A host-startup failure should be investigated using the saved build/server logs. The host runner removes resources it created; it must never prune unrelated containers or networks. + +The test infrastructure uses synthetic data only. Do not substitute production credentials or a personal media library into fixtures. diff --git a/tests/docs/coverage-matrix.md b/tests/docs/coverage-matrix.md new file mode 100644 index 000000000..c8d43478c --- /dev/null +++ b/tests/docs/coverage-matrix.md @@ -0,0 +1,138 @@ +# JE regression coverage matrix + +Historical issue/PR sensitivity, newly found blind spots, and remaining report-specific gaps are tracked in [history-audit.md](history-audit.md). Passing the existing suite is not completion of comprehensive coverage. + +This document separates production discovery from verified behavioral coverage. A file appearing in the inventory, a test compiling, or a line being executed does **not** establish that a feature is tested. The [machine-readable inventory](production-inventory.json) enumerates every production C#, JavaScript, HTML, CSS, and JSON file outside build output, every literal HTTP route declaration, configuration/model auto-property, scheduled task, locale, and candidate storage literal. Regenerate it with `python3 tests/inventory/generate.py`; `--check` detects drift. Extraction is lexical, so computed routes/properties and inherited host behavior require review. + +The project actually supports two separate artifacts: `jf10` targets .NET 9 and Jellyfin 10.11.0; `jf12` targets .NET 10 and Jellyfin 12.0.0. These are reference versions, not a claim that every patch release was executed. Contributor guidance naming only 10.11 is narrower than the project file. + +## Status and evidence + +- **Tested**: the named behavior was asserted and its suite passed in a recorded environment. It does not mean all combinations are covered. +- **Partial**: some behaviors have implemented tests; remaining cases are listed. Execution evidence must be recorded separately. +- **Unverified**: discovered and feasible, but no passing behavioral evidence has been recorded yet. +- **Blocked**: a specific unavailable prerequisite prevents the stated check. +- **Manual**: an inherently human assessment such as visual acceptability; automation can still cover neighboring behavior. + +Initial discovery found 337 source/configuration/style/locale files, 167 HTTP verb/route declarations, 501 configuration and persisted-model properties, 12 scheduled tasks, and 33 locales. Counts are descriptive, not a coverage denominator. The generated inventory is authoritative if these numbers change. + +## Behavior inventory + +Paths below are relative to `Jellyfin.Plugin.JellyfinEnhanced/`. Every configuration property and endpoint is individually listed with a source line in the JSON inventory. Feature rows group related surfaces so the remaining behavioral work remains readable. + +| Area / production surfaces | Risk and required regression cases | Test references / status / remaining gaps | +|---|---|---| +| Build targets, `JellyfinEnhanced.csproj`, `Helpers/BuildAttestation.cs`, `Services/HostCompatibilityService.cs` | Compile each target; load actual plugin; distinguish build/runtime mismatch; report assembly metadata; embed every served resource | Partial — both targets compile/test and `tests/host/run.py --target all` passed 45/45 installed-plugin scenarios on each digest-pinned Jellyfin 10.11.11 and 12.0. Actual assets/runtime compatibility are exercised. Remaining: intentional wrong-artifact/mismatched-host diagnostics. | +| Plugin startup and DI, `JellyfinEnhanced.cs`, `PluginServiceRegistrator.cs`, `Services/StartupService.cs` | Correct registration, startup/shutdown, idempotent subscriptions, cancellation, enabled/disabled feature startup | Partial — real-host suite starts/restarts the built plugin on both lines; `IntegrationLifecycleTests.cs`, `TaskRegressionTests.cs`, `PrivacyAutoEnableTests.cs` exercise selected subscription idempotence and promoter startup recovery. Remaining: every hosted monitor enabled together and shutdown during active work. | +| HTTP controller: 167 route declarations in `Controllers/JellyfinEnhancedController.cs` | Anonymous/user/admin permissions, route binding, serialization, validation, response codes, cancellation and error sanitization for every route | Partial — `ApiRoutingTests.cs` covers actual ASP.NET public/challenge/404/405 behavior; `ApiSeerrTests.cs`, `ApiBrandingTests.cs`, `IntegrationCalendarTests.cs` invoke real controller business logic. Host suite tests six document routes across regular/restricted users and three administrator denials. Remaining: review moderation/mirroring, hidden-content administration and item-stat authenticated response paths. | +| Request identity, `Services/Identity/RequestIdentityService.cs`, `Helpers/UserHelper.cs`, extensions | Missing/invalid identity, token identity versus supplied user ID, admin override boundaries, user deletion/disablement, alternate GUID forms | Partial — `PrivacyIdentityTests.cs`, `PrivacyPolicyTests.cs` cover identity/marker/session resolution and self/admin selection; real-host GET/POST tests reject cross-user document access. Remaining: token revocation/deletion races during active requests. | +| Configuration, `Configuration/PluginConfiguration.cs`, `UserConfiguration.cs`, `UserConfigurationManager.cs`, `js/enhanced/config.js` | Defaults, all option round-trips, nullable preferences, casing, server/user precedence, missing versus explicit false, migration, invalid data, atomic writes, concurrent updates | Partial — Core configuration/persistence/server tests cover defaults, serialization, legacy provider migration, nullable settings and poster precedence. Bootstrap/panel tests cover settings-before-init, quality enable/disable saves and keyboard event isolation. Real host verifies persistence/restart. Remaining: control-by-control administrator configuration and shortcut editor flows. | +| User storage and migrations, `Configuration/UserConfigurationManager.cs` | GUID normalization, traversal rejection, old directories, corrupt files quarantine, shared reviews/activity locking, restore/restart, IO failure without destructive overwrite | Partial — `CorePersistenceTests.cs`, `CoreSharedStoresTests.cs`: canonical IDs, traversal, restart, corruption/quarantine, migration, concurrent RMW, review/activity integrity. Remaining: each store-specific recovery policy, filesystem permission/interrupted-write injection. Core suite passed 125 cases on both jf10 and jf12, including regression cases for malformed review data loss. | +| Script injection and branding, `Services/ScriptInjectionStartupFilter.cs`, `BrandingAssetStartupFilter.cs`, `ClientScriptBundle.cs`, `CdnAssetService.cs`, `js/plugin.js` | Base URL, content type/encoding, double injection, disabled middleware, bundle ordering, development fallback, MIME/cache headers, font delivery, invalid resource paths, upload/delete permissions | Partial — `ApiMiddlewareTests.cs`, `ApiAssetTests.cs`, `ApiBrandingTests.cs`, `ApiCdnRefreshTests.cs` cover shell/base URL/idempotence, assets, upload/read/delete validation/admin denial, timeout continuation, cancellation and persisted fallback. Real host verifies exactly one injected loader. Chromium now executes the injected bundle against both real hosts, logs in admin/regular users, saves Auto Pause through the Enhanced panel and reloads persisted state. Remaining: other full-host browser feature journeys. | +| Public/private/bootstrap configuration routes | Never expose provider keys to anonymous/user bootstrap; authenticated settings identity; user precedence; all sensitive fields excluded | Partial — `ApiConfigurationTests.cs` verifies topology/key redaction; bootstrap component tests reject wrong/stale identity and clear private admin state. Both real hosts passed fake-secret non-disclosure and principal identity checks. Remaining: configuration changes during concurrent bootstrap requests. | +| Spoiler identity and user resolution, `Services/SpoilerGuard/SpoilerIdentityService.cs`, `SpoilerUserResolver.cs`, `SpoilerIdentityTagFilter.cs`, `ImageTagDecoration.cs` | Alternate identities, stale/invalid image tokens, explicit user precedence, anonymous handling, cache partitioning | Partial — `PrivacyIdentityTests.cs`: authenticated precedence, image carriers, shared-IP ambiguity, cookie refresh, forged-cookie scan bounds, topology invalidation. Privacy cases passed on both target frameworks. Remaining: actual image endpoint user/cache separation. | +| Spoiler metadata and tags, `SpoilerFieldStripFilter.cs`, `SpoilerTagDataStripper.cs` | Watched/unwatched movies, episodes, series, seasons, collections; names/overview/reviews/rating/chapters/trickplay; unrelated fields preserved | Partial — `PrivacySpoilerStripTests.cs`: episode watched/unwatched, fail-closed play-state lookup, deep cloning/title/path sanitization, special/season tag policy. `PrivacyFieldFilterTests.cs` adds actual field filter cases; privacy cases passed on both target frameworks. Remaining: complete chapters/trickplay and real-host response combinations. | +| Spoiler image rendering, `SpoilerBlurImageFilter.cs`, `ImageBlurService.cs` | Protected image types, watched bypass, corrupt/missing images, cancellation, source changes, dimensions, render-cache isolation | Partial — `PosterBlurTests.cs` and `PosterBlurFilterTests.cs` add 35 dual-target cases: actual Skia contrast reduction, sigma/dimension bounds, corrupt input, stock/resize/cache/concurrency; actual MVC byte/file/stream/physical/virtual results, movie image types, chapter resume boundary, watched/keep-art bypass, HEAD/no-store/cache scrubbing, viewer separation, output dimensions and stream disposal. Remaining: complete episode/season/collection/trickplay orchestration on a real host. | +| Spoiler enablement, `SpoilerAutoEnableArmer.cs`, `SpoilerAutoEnableFilter.cs`, `SpoilerExistingTitlesApplier.cs`, `SpoilerLibraryAddAutoEnabler.cs`, `SpoilerSeerrPendingPromoter.cs`, event handlers | Existing and newly added titles, scoped libraries/types, pending TMDB promotion, next unwatched, repeated events, task cancellation, preference boundaries | Partial — `PrivacyAutoEnableTests.cs` covers actual bulk arming with dry-run/no-write/skips/idempotence/timestamp preservation, type/library/user-access scope, PlaybackStart first-unwatched-S1E1 policy, and hosted pending-promoter disk startup plus library events. Remaining: `SpoilerLibraryAddAutoEnabler` enabled add/update events and next-unwatched navigation. | +| Spoiler browser UI, `js/enhanced/spoilerguard/*` | Toggle series/movie/collection/pending, snooze, refresh after watched state, image identity, dialog focus, failed save, settings tab | Partial — `tests/frontend/features/features-spoiler.test.mjs`: normalized IDs, coalesced load, session invalidation, failed mutations, pending promotion, identity cookie. `features-spoiler-ui.test.mjs` adds eight UI/guard tests. These are component tests, within the expanded feature suite. Remaining: complete dialog/snooze/watched-refresh and real-host journey. | +| Hidden content, `Services/HiddenContentResponseFilter.cs`, `js/enhanced/hiddencontent/*` | Hidden movies/series, search/home/library/next-up scopes, result count, per-user isolation, admin management, restore, double initialization | Partial — `features-hidden.test.mjs`: scope merge, card/request/calendar filtering, session cleanup, stale refresh/parent lookup. `PrivacyHiddenContentTests.cs` adds actual MVC filtering across eight route shapes, pagination, series cascade/cache invalidation, typed hints/latest lists and user isolation; covered by the expanded privacy suite. Remaining: admin management and full host page journey. | +| Continue watching/home removal, `js/enhanced/homeremoval/*`, `EventHandlers/ContinueWatchingPlaybackEvents.cs` | Hide/unhide, next-up versus resume semantics, multiselect close, playback reappearance, per-user isolation | Partial — actual hidden-content filter tests cover four mixed HSS row cases proving next-up and continue-watching scopes remain independent. Remaining: hide/unhide endpoint, multiselect UI, playback events and reappearance. | +| Bookmarks, user storage and `js/enhanced/bookmarks/*` | Add/update/delete, timestamp boundaries, duplicate versions/provider matching, unavailable items, replacement picker, import shapes, XSS, race-free add | Partial — `features-bookmarks.test.mjs`, `CoreConfigurationTests.cs`: identity matching, provider/episode fallback, update/delete/sync rollback, Unicode persistence. Expanded account-switch/logout success/failure cases now pass, including stale lookup and owner-scoped metadata cache, within the expanded feature suite. Remaining: full library replacement/import/timestamp journeys. | +| Reviews, `Helpers/ReviewVisibility.cs`, `Services/ReviewAuthorCache.cs`, `js/elsewhere/reviews.js` | Hidden/disabled authors, own/admin edits, rating bounds, shared-store integrity, Jellyfin mirror, spoofed author, aggregate invalidation, Spoiler Guard | Partial — stores, browser review flows and `ReviewActivityVisibilityTests.cs` cover CRUD/corruption/concurrency, escaped forms/success/error/ratings, exhaustive moderation visibility combinations, malformed authors, deleted-author cache and invalidation racing lookup. Remaining: actual controller moderation authorization and Jellyfin rating mirror mutation/rollback. | +| Activity, `Services/ActivityService.cs`, `ActivityFeedBuilder.cs`, `ActivityFavoriteMonitor.cs`, `EventHandlers/ActivityPlaybackConsumer.cs`, `js/extras/activity*` | Visibility, history retention, concurrent events, missing avatar, hidden/disabled users, active-stream polling stops on inactive tabs | Partial — shared-store and page tests cover retained history, corruption, escaped rendering, refresh coalescing and polling teardown. `ReviewActivityVisibilityTests.cs` covers actual feed filtering before limits, user-access/author isolation, hidden/self/admin behavior, disabled categories and cancellation without library reads. Remaining: playback/favorite event-to-feed integration and review-rating mirror side effects. | +| Maintenance, `Services/MaintenanceModeService.cs`, `MaintenanceScheduleService.cs`, `EventHandlers/MaintenancePlaybackReminder.cs` | Daily/overnight/equal windows, timezone/DST, admin exclusion, selected/all users, restore original disabled state, restart, countdown and reminder | Partial — `CoreMaintenanceTests.cs` covers action selection/admin exclusion/original restrictions, manual/scheduled priority, overnight boundaries, skipped occurrence restart, expiry/restoration, failed update/restore retry and reconciliation intent. Remaining: `MaintenancePlaybackReminder` session delivery and timezone/DST transitions beyond deterministic local schedule boundaries. | +| Seerr permissions/identity, `Helpers/Jellyseerr/*`, `Services/SeerrParentalFilter.cs` | Every permission bit, REQUEST_ADVANCED visibility, requester identity, quotas, parental ratings/blocked tags, unknown ratings, malformed provider payloads | Partial — privacy helpers, real `ApiSeerrTests.cs` request/service/issue permission checks and browser advanced-option gating. Principal identity wins, secret-bearing service settings require Jellyfin admin, quota reset uses current user. Remaining: user-import permission assignment and parental policy integration across all discovery routes. | +| Seerr requests and issue reporting, controller, `js/jellyseerr/api.js`, `request-manager.js`, `issue-reporter.js`, `ui/*` | Movie/TV season selection, partial/already requested, approval/decline, submit failure/retry without duplicates, quota exhaustion, service/profile/root-folder validation, issues | Partial — request-submission components and real `ApiSeerrTests.cs` cover season/TVDB preservation, permissions, no write retry, safe upstream errors, per-user detail cache/invalidation, quota history, independent issue read/write permissions, and disconnected reads versus completing writes. Remaining: administrator approve/decline controller behavior and issue-report modal form lifecycle. | +| Automatic requests and watchlists, `Services/AutoMovieRequestService.cs`, `AutoSeasonRequestService.cs`, monitors and sync/import tasks | Both sync directions, duplicate prevention, invalid mappings, opt-out, account import, cancellation, retry/rate limit, persistence across restart | Partial — `TaskRegressionTests.cs`, `TaskAutoRequestTests.cs`, shared-store tests cover linked watchlist identity/blocked users/dedup, movie collection and next-season decision thresholds/availability/existing requests, retry after failure and disabled-feature short circuits. Remaining: enabled reverse watchlist sync and user import with partial provider failure. | +| Seerr scans, `Services/SeerrScanTriggerService.cs` | Permission, coalescing, disabled integration, upstream error/timeout and task completion | Partial — `IntegrationLifecycleTests.cs`: repeated initialize/dispose subscription behavior passed on both targets. Remaining: actual scan invocation, permissions, coalescing and upstream failures. | +| Seerr discovery/recommendations, `js/jellyseerr/discovery/*`, `recommendations/*`, `hss-discovery-handler.js`, `seamless-scroll.js` | People/genre/tag/collection/network/studio, movie/TV filters, empty/sparse pagination, deduplication, stale navigation, parental filtering, infinite-loop prevention | Partial — recommendations tests cover category isolation/cache keys and standalone mount/leave lifecycle. `pages-scroll.test.mjs` covers historical sparse non-growing results, empty-page budgets, zero-progress/exhausted termination, hidden sections and teardown while pending. Remaining: discovery-filter media/sort persistence, managed request cache identity and individual person/network/studio pages. | +| More-info/detail links, `js/jellyseerr/moreinfo/*`, `item-details.js`, `modal.js`, `seerr-detail-link.js`, `seerr-status.js` | Modal open/close/focus, deferred season loads, safe text, requests, missing identifiers, TVDB manual matching, unavailable service | Partial — `pages-moreinfo.test.mjs` covers replacement close timeout, late details/ratings/action/TV request results, same-item reopen, listener cleanup, navigation dismissal, visible errors, closing while pending and actual session changes that cancel pending opens, close existing modals and invalidate old ratings. Remaining: complete season-selector keyboard/focus journey. | +| Sonarr/Radarr and URL guard, `Services/SonarrService.cs`, `RadarrService.cs`, `Helpers/ArrUrlGuard.cs`, `js/arr/arr-links.js`, `arr-tag-links.js` | Multi-instance matching, URL validation/private deployment support, credentials never leaked, malformed API data, timeout/rate limits, IDs, tag sync | Partial — real service calls through fake HTTP cover IDs/tags/rejected URLs/error/cancellation; calendar controller tests aggregate multiple providers. `TaskMetadataTests.cs` runs enabled tag sync preserving unrelated labels/filtering/idempotence. `UrlPolicyTests.cs` adds 21 cases for literal/cloud/mapped-IP destinations, mixed resolved addresses and Docker/Podman link-local exceptions through the actual address-policy delegate. Remaining: multi-instance link resolution UI and partial sync failure recovery. | +| Calendar, controller and `js/arr/calendar/*` | Sonarr/Radarr/Shoko merge, source isolation, timezone/day boundaries, missing media, restricted anime removed, episode-type filters, source failure does not remove others | Partial — `tests/frontend/pages/pages-calendar.test.mjs`: user preference isolation, filters, error escaping, stale account responses, request pagination, Shoko ID fallback/cards and leap-day ranges. `IntegrationCalendarTests.cs` adds actual controller Shoko episode-type, partial configuration, broken-provider isolation and Shokofin mapping without cross-source dedup; 15 controller calendar cases passed on both targets, including access allow/deny, malformed dates and provider failures preserving other sources. Remaining: full backend permutations and real-host page. | +| Downloads/requests page, controller and `js/arr/requests/*` | Queue/history aggregation, source filters, empty/error state, permission-based actions, refresh lifecycle, ordering and progress | Partial — `tests/frontend/features/features-requests.test.mjs`: packs/grouping/status/search/hidden filtering and remaining-time formatting. `pages-downloads.test.mjs` adds paginated source loads, stale-user permission/data rejection, sticky issue denials, scoped metadata cache, disabled sources and non-retried approval refresh. Remaining: backend queue/history aggregation and full-page accessibility. | +| TMDB/Elsewhere, `Services/TmdbResponseCache.cs`, `TmdbCompanyTvDiscover.cs`, `WatchProvidersBatch.cs`, `js/elsewhere/elsewhere.js` | Region/provider filters, locale cache keys, pagination, batch limits, cancellation, invalid response, cache expiration, failure recovery | Partial — `IntegrationTmdbTests.cs` covers cache isolation/TTL/dedup/cancellation/retry/bundle/validators/corrupt restart including mixed valid/corrupt entries. `IntegrationWatchProvidersTests.cs` covers strict region/logo validation, malformed IDs/dedup, 100-ID/raw-length limits, provider filtering and absent-versus-failed results. `IntegrationCompanyDiscoverTests.cs` adds pagination/TV shaping, visible-library-only media information, invalid detail/JSON and sort allowlist cases (included in final dual-target validation). Remaining: actual Elsewhere UI and full discovery route cancellation. | +| MDBList, `Services/MdblistService.cs`, fetch runner/tasks, `js/others/mdblist-ratings.js` | Account quota reserve, rating scales/sources, overwrite policy, missing IDs, sync/fetch disabled, cancellation/rate limit, persisted cache | Partial — service account/cache tests plus `TaskMetadataTests.cs` exercise actual enabled rating sync fill/preserve/overwrite/idempotence, fetch quota reserve/dedup/warm-cache suppression and cancellation before mutation/network. Remaining: partial provider failure during a multi-batch fetch and browser rating-detail rendering. | +| Awards, `Services/WikidataAwardsService.cs`, `js/awards/awards.js` | Missing/malformed awards, cache/persistence, timeout, media-type validation, safe rendering | Partial — `IntegrationAwardsTests.cs` (15), `CoreAwardsTests.cs` (7) exercise actual service query/recipient/work grouping, QID exclusion, errors versus negative cache, concurrency, persistence/media isolation, TTL boundaries and corrupt disk recovery. `pages-awards.test.mjs` covers safe rendered text, banner replacement, namespaces, native anchors and disabled/empty/error cases. Remaining: late awards response after navigation/account change and cancellation during persistence. | +| Poster data and cache, `Services/TagCacheService.cs`, `TagCacheMonitor.cs`, `TagCachePendingChanges.cs`, `PosterTags/PosterTagDataProvider.cs`, `js/tags/tag-cache-store.js`, `tag-pipeline.js` | Incremental invalidation, watch/review changes, pending coalescing, users, stale response target, restart, repeated requests, missing streams | Partial — `PrivacyCacheTests.cs` (15 dual-target) covers actual schema-7 disk restart/release/corrupt/old schema, access-cache isolation/invalidation/revision deltas, queued removal flushed at shutdown, batch failures, pending last-write/concurrent drain and debounce caps; full-build cancellation/hydration failure preserves prior published/disk snapshot. Browser tag-pipeline/renderer tests cover stale targets, dedup and owner/TTL/corrupt cache. Remaining: actual metadata-changed incremental rebuild and timer/save/server-mode transition races. | +| Poster resolution, `Services/PosterTags/Resolution/*`, `PosterTagResolver.cs`, `PosterTagSettings*.cs`, language helpers, `js/tags/*` | Quality/HDR/source/audio/codec/genre/age/rating/people/language; nullable preferences, scope, order, IETF languages/no dialogue, stream-bearing episode selection | Partial — 56 poster cases passed both targets, including actual native resolution/rendering. Strict parity: 40 profiles × 101 metadata fixtures, 115,374 language-name comparisons and 31,676 casing strings pass. Previously nonfatal parity mismatches now fail; Baku language alias regression fixed with five cases. `PosterEpisodeSelectorTests.cs` adds actual stream-bearing selection across paginated regular/special episodes and user-scoped queries; `PosterSettingsProviderTests.cs` adds save/config/user/audio preference invalidation (15 cases; final integrated dual-target run includes them). Remaining: complete user option interactions and actual library-source traversal beyond those fixtures. | +| Native poster delivery, `PosterTagImageFilter.cs`, `NativeClientPolicy.cs`, `NativePosterTagStamper.cs`, `PosterTagUserCache.cs`, `PosterTagVariantToken.cs`, `CompositeImageCache.cs` | Web/native exclusions including Jellium, per-user variants, spoiler interaction, cache invalidation, concurrent generation, original-image fallback | Partial — token integrity/secret persistence tests and 140 pipeline harness checks cover client policy, image decoration, bounded/coalesced/cancellable caches, disk trim and source-file settling. 56 unit cases passed each target. `PosterSettingsProviderTests.cs` adds per-user/save/config/audio preference invalidation. Remaining: real-host `PosterTagImageFilter` headers/body with protected versus unprotected users. | +| Native poster rendering, `PosterTags/Rendering/*`, `PosterTagComposer.cs`, assets | Scaling/layout/overflow/font shaping/flags/colors, deterministic output, missing fonts fallback, cache bounds, dimensions | Partial — actual Skia unit tests cover source preservation, layout bounds/four corners, formats/dimensions, corrupt input, concurrent outputs and tile cache bounds. Assets decode all 253 flags and shape stacked marks on Skia 3.116.1/3.119.4. Remaining: browser/native screenshot pixel comparison and missing-HarfBuzz fallback. | +| Shared browser runtime, `js/core/*`, `js/enhanced/helpers.js`, `events.js` | API priority/cancellation/dedup, lifecycle teardown, observer batching, navigation, session change, language fallback, UI primitives and tag targets | Partial — core/session/API/navigation/observer/bootstrap tests plus `core-tag-renderer.test.mjs` cover DOM target/position, cache owner/TTL/legacy/server/corrupt input, exclusions and corner stacking. Browser suite exercises modal/toast/CSS primitives. Real-host Chromium validates login/bootstrap/panel/save/reload on both lines. Remaining: platform-specific event behavior and additional host feature journeys. | +| Settings panel, `js/enhanced/settingspanel/*`, `Configuration/configPage.html` | Every control save/load/default, feature gating, shortcut editor/reserved keys, panel reopen, focus, mobile, admin config, release notes | Partial — language component/browser tests plus `bootstrap-settings-panel.test.mjs` cover quality enable/disable initialization/removal/persistence and pause-delay key propagation. `bootstrap-shortcuts.test.mjs` additionally tests conflicts/modifier-only/reserved input, delete/reset, capture suppression, repeat handling and combined-modifier execution. Remaining: hidden-content settings tab, full administrator page save/load and release-note dismissal UI. | +| Native/custom tabs and header, `js/enhanced/native-tabs.js`, `header-actions.js`, `ui-styles.js`, `icons.js`, custom-tab modules | Coexistence, stable links, index collisions, foreign header buttons, narrow screen, private fonts/classes, live reconciliation | Partial — `bootstrap-native-tabs.test.mjs` covers repeated registration, escaped labels, Custom Tabs reserved indices/late collision, placeholders, deep-link index rewriting, selection stability, unregister/re-register and provider failure. `bootstrap-header.test.mjs` additionally tests foreign-button preservation, narrow overflow forwarding, expansion/restoration, identity reset and private/full icon font separation. Remaining: actual Jellyfin theme/browser layout combinations. | +| Player, `js/enhanced/player/*`, shortcut events | Auto pause/resume/PiP, long press speed restore, intro/outro skip, native control coexistence, pause screen/back, rating badge, disabled subtitle styles and presets | Partial — subtitle, autoskip, pause and playback tests cover style restore/observer teardown, HLS/progressive/static offsets/boundaries/cancellation, pause overlay focus/cleanup/escaping/stale identity, speed clamp/reset/no-video feedback and position seeking. Remaining: auto pause/resume/PiP host events, track cycling/frame-step, rating overlays and actual playback. | +| Details/random, `js/enhanced/itemdetails/*`, `features-random-button.js`, `Services/ItemStatsService.cs` | Selected media version, dates/display order, sizes/watch progress, random playlist/collection/current library, empty eligible results | Partial — frontend random/release/media-info tests cover navigation, pinned/unwatched fallback, cache/region/errors/escaping, stats coalescing and selected-source language/user isolation. `IntegrationItemStatsTests.cs` (7 dual-target) exercises played/resume/progress bounds/zero runtime, selected-source size, user-scoped recursive folder traversal/dedup/cycle protection and nonplayable exclusion. Remaining: host series/season/channel and alternate-video source traversal plus full details dispatcher. | +| Translations, `js/enhanced/translations.js`, `js/locales/*.json` | Fallback/interpolation/cache version, malformed/unavailable locale, non-Latin/long text, locale endpoint validation | Partial — `tests/frontend/core/core-translations.test.mjs`, `ApiAssetTests.cs`: language normalization/cache/Unicode/local fallback/unavailable sources and regional server fallback. Remaining: full interpolation/long-text UI and human translation quality. | +| Extras/theme/links, `js/extras/*`, `js/enhanced/themer.js`, `js/others/letterboxd-links.js`, `splashscreen.js` | Disabled modules, remount, theme selection, plugin revision dedup, custom icons, login/splash image failures, late Letterboxd metadata | Partial — `core-theme.test.mjs` covers repeated mount/observer replacement/user CSS/local CDN/default/daily toggle. `core-letterboxd.test.mjs` covers movie/person safe links, icon/text/disabled modes, same-item remount and stale route responses. Remaining: plugin revision dedup, login/splash error lifecycle and custom dashboard icons. | +| Analytics/usage/Whats New, `Services/AnalyticsReportingService.cs`, `UsageEventCounterService.cs`, `WhatsNewService.cs`, tasks | Opt-in/out, aggregate privacy, build/host change refresh, bounded counters, cancellation, schema snapshot/seed fallback, per-user dismissal | Partial — `IntegrationAnalyticsTests.cs`, `CoreUsageTests.cs`, `CoreWhatsNewTests.cs`, task tests cover independent consent categories, no secrets/private URLs/user IDs, registration identity/idempotence, concurrent counter snapshot subtraction/restart, malformed disk state, Gregorian periods and schema baseline/ack/corrupt state. All HTTP is stubbed. `IntegrationAnalyticsTests.cs` also adds changed host/target/plugin cadence, interval clamps and acknowledged-versus-failed checkpoint/counter retention including in-flight increments (included in final dual-target validation). Remaining: actual scheduled delivery under host shutdown. | +| Localized styles/accessibility/visuals, all HTML/CSS pages | Desktop/mobile layouts, keyboard/focus, semantic roles, readable long text, baseline changes reviewed | Partial — `tests/browser/flows.spec.cjs`: 39 passing component-browser cases across Chromium desktop/mobile and Firefox, including keyboard focus/Escape, responsive modal and three reviewed visual baselines. Remaining: other JE pages and full accessibility audit; aesthetic approval remains Manual. | +| Deployment extensions/native clients | Reverse proxy base path, supported host/browser combinations, disabled injection, alternate Jellyfin clients | Partial — both actual server lines pass 45 host scenarios; reverse-proxy base-path injection is covered at middleware level, and browser components run Chromium/mobile Chromium/Firefox. Real-host Chromium additionally passes admin/regular login and actual Enhanced panel persistence. Remaining: physical native-client behavior and host playback/image journeys. | + +## Scheduled task checklist + +All 12 task implementations must be assessed independently. Testing the worker helper does not prove scheduler registration or cancellation propagation. + +| Task | Required cases | Status | +|---|---|---| +| AnalyticsReportTask | disabled/enabled, cancellation, provider failure, no live reporting | Partial — `TaskRegressionTests.cs`: consent/cadence suppress outbound reporting; enabled-due/report failures remain. | +| ArrTagsSyncTask | each provider, merge/replace policy, unavailable instance, cancellation | Partial — disabled/trigger/cancellation plus enabled tag merge/filter/idempotence in TaskMetadataTests; partial provider failure recovery remains. | +| AudioLanguageTagsSyncTask | missing/alternate language codes, stream fallback, repeat run | Partial — enabled stale-prefixed-tag replacement and idempotence in TaskMetadataTests plus disabled/trigger checks; Matroska fallback/multiple stream variants remain. | +| BuildTagCacheTask | empty/full library, cancellation, concurrent incremental updates | Partial — disabled wrapper plus actual cache full-build cancellation/failure preserves snapshot, successful replacement and restart; scheduled trigger/progress remains. | +| ClearTranslationCacheTask | monotonically updated invalidation, client reload behavior | Partial — task sets invalidation timestamp/progress; bootstrap tests exercise client invalidation. Host persisted setting restart remains. | +| JellyfinToSeerrWatchlistSyncTask | mapping, identity, repeat run, opt-out, upstream failure | Partial — linked requester, blocked user, duplicate editions, already-present and malformed IDs, disabled/missing credentials; cancellation/error recovery remains. | +| JellyseerrUserImportTask | existing/new/disabled accounts, permissions, cancellation | Partial — disabled/missing credentials; real import and permission policy remain. | +| JellyseerrWatchlistSyncTask | processed-state restart, duplicates, missing content | Partial — disabled/missing credentials and processed-state persistence helper; enabled synchronization remains. | +| MdblistRatingsFetchTask | quota reserve, empty targets, cancellation, errors | Partial — enabled reserve/dedup/warm-cache and cancellation in TaskMetadataTests plus disabled/trigger checks; partial multi-batch failures remain. | +| MdblistRatingsSyncTask | overwrite policy, ratings scale, invalid cache | Partial — enabled fill/preserve/overwrite/idempotence and cancellation in TaskMetadataTests plus disabled/trigger checks; host metadata update failure remains. | +| RefreshCdnAssetsTask | individual timeout does not abort remaining assets | Partial — `ApiCdnRefreshTests.cs` exercises full known asset refresh, continuation after timeout, caller cancellation and persisted asset fallback. Task wrapper registration remains. | +| SpoilerApplyExistingTitlesTask | library/media scope, progress, cancellation, duplicate run | Partial — no automatic default trigger and real bulk worker scope/dry-run/idempotence are covered; scheduler-triggered cancellation/progress remains. | + +## Persistence checklist + +User-scoped files include `settings.json`, `shortcuts.json`, `elsewhere.json`, `bookmark.json`, `hidden-content.json`, and `spoilerblur.json`. Shared stores/caches include `reviews.json`, `activity.json`, `maintenance-state.json`, `processed-watchlist-items.json`, `tag-cache.json`, `tmdb-cache.json`, `mdblist-ratings.json`, `awards.json`, `usage-counters.json`, `whats-new.json`, and `config-schema-snapshot.json`. Plugin configuration also uses Jellyfin's host persistence. Browser storage and native image cache metadata have separate lifecycle/isolation requirements. The inventory includes candidate literals that are resources/log messages, so it must not be interpreted as an exact disk-write list. + +For each writable store assess missing/empty/corrupt/older content, serialization round-trip, restart, interrupted write, simultaneous updates, user isolation, and retention/cleanup. A successful generic JSON round-trip does not establish each store's merge policy. + +## Local regression history used for case discovery + +| Commit | Regression scenario | Coverage status | +|---|---|---| +| `f14d45ca`, `50947198` | JE private Material Symbols classes/fonts must not shadow themes | Tested — `bootstrap-header.test.mjs` asserts private/full font separation and theme class behavior | +| `e2fd2837`, `597a9b52` | Native/custom tab indices, stable links, coexistence | Tested — `bootstrap-native-tabs.test.mjs` collision and stable deep-link cases | +| `8b921e90` | Users without REQUEST_ADVANCED cannot access advanced request UI | Tested — `request-submission.test.mjs` current permission/admin setting and stale-user status cases | +| `5420cd72` | Jellium Desktop excluded from native poster tags | Partial — pipeline verifies every configured built-in exclusion; independent fixed-name inclusion assertion remains | +| `80eb3d02` | Server defaults casing mismatch | Unverified | +| `cad15541` | Podman host.containers.internal mapping accepted | Tested — `UrlPolicyTests.cs` validates actual resolved-address policy with Docker/Podman link-local exceptions and cloud/mixed-IP rejection | +| `a26a65d0`, `0d08bef6` | Series tags pick stream-bearing episode | Tested — `PosterEpisodeSelectorTests.cs` multi-page regular/special stream selection | +| `56c47892`, `f61513bf` | Selected version and IETF languages respected | Tested — `features-media-info.test.mjs` preserves en-US and selects requested media source | +| `62f47593`, `dc734143` | Inactive tabs do not poll/initialize unnecessarily | Partial — activity stop/detach/pending-load tests; native tab initialization remains | +| `3510fb27` | Disabled custom subtitles preserve Jellyfin styles | Tested — `features-subtitles.test.mjs` checks exact original style restoration | +| `07249c3f`, `cc7b2b80` | zxx displayed as no dialogue | Partial — core media-language labels and flag resolution; complete card UI remains | +| `5eb41f6c` | Player shortcuts and reserved keys | Tested — `bootstrap-shortcuts.test.mjs` actual editor and capture-listener execution | +| `5fd544b8` | CDN refresh continues after one request times out | Tested — `ApiCdnRefreshTests.cs` full refresh timeout continuation | +| `29f998c5` | Analytics refresh after host/build changes | Tested — IntegrationAnalytics host/target/plugin cadence tests | +| `ea7a19ff` | Stale tag render target re-resolved | Tested — `core-tag-pipeline.test.mjs` detached host replacement | +| `28e96c3a` | Letterboxd link reappears after delayed metadata | Tested — `core-letterboxd.test.mjs` remount and stale route response cases | +| `19559c93` | Sparse search cannot trigger runaway infinite scrolling | Tested — `pages-scroll.test.mjs` non-growing/sparse termination safety valve | +| `9d99821c` | Activity history writes remain safe under concurrency | Partial — shared-store integrity/retention/corruption tests; full event-write stress remains | + +## Execution ledger + +The frozen complete run finished with exit 0; authoritative report: `artifacts/final-validation/summary.json`. It passed 675/675 backend cases on each target, zero skipped; coverage jf10 47.03% lines / 34.90% branches and jf12 47.04% lines / 34.93% branches. Instrumented frontend passed 323/323: all-production coverage 23.55% statements, 18.40% branches, 24.98% functions and 24.50% lines. Mocked browser passed 39/39; tooling 5/5; poster pipeline 140 checks and strict name/casing parity passed. Actual hosts each passed 45 HTTP scenarios plus real Chromium regular/admin flow (46 entries per Jellyfin 10.11.11 and 12.0). Four mutation types were killed/restored on each target. The final frozen-source clean disposable checkout run also passed every stage with exit 0 and the same test counts; reports are `artifacts/clean-final/summary.json` and its `regression/summary.json`. See [progress](progress.md). + +## Remaining behavioral gaps + +These are assessed gaps beyond implemented cases, not claims that all other combinations were exhausted: + +- Spoiler/native image filtering: full episode/season/collection/trickplay orchestration, protected versus unprotected images through a real Jellyfin HTTP server, and physical native clients. Existing filters are executed in actual MVC contexts with real images; host smoke covers separate API/browser flows. +- Tag cache: actual changed-metadata incremental rebuilding and timer-save/server-mode transition interleavings. Existing tests cover transactional full-build replacement, user access revisions, queue/drain concurrency and persisted cache recovery. +- Enabled user import and reverse Seerr watchlist task: per-account partial provider failures, permission assignment and cancellation after some users succeed. Enabled audio/Arr/MDBList workers now have dedicated behavior tests, with partial multi-batch/provider failures still gaps. +- Review controller moderation and Jellyfin rating mirroring: administrator/self boundaries through the route and host metadata mutation failure. Author visibility/cache invalidation and feed filtering are now tested. +- Discovery-filter utilities and individual person/network/studio pages: media/sort persistence, request cache identity and cancellation while switching categories. Sparse infinite-scroll termination and recommendation mount teardown are now tested. +- Header/shortcut behavior now has actual component cases for overflow, foreign buttons, font isolation, editor validation and capture handling. Remaining: real-host theme layouts and device keyboard differences beyond these fixtures. +- More-info season-selector keyboard/focus journey; awards late response after leaving a page. Existing modal-generation tests cover close/reopen/item changes, actual account transitions and stale detail/rating/action responses. +- Playback features on a real media-playing host: PiP/auto-pause/resume events, track cycling/frame-step and rating overlays. Component tests cover subtitles, skip segments, pause overlay, seeking and speed controls; real-host browser flow currently covers settings persistence. +- Maintenance recovery cannot provide an atomic transaction across Jellyfin user policy and JE's journal; see [maintenance recovery limits](maintenance-recovery.md). Error-injection tests cover pending intent/retry/restart and failed checkpoints, not every host after-commit ambiguity. +- Actual rendered page coverage remains limited to selected mocked visuals and one real-host settings journey; full-page mobile/accessibility, reverse-proxy browser navigation and native-device rendering remain outside automated evidence. + +Uncovered cases remain documented for follow-up. Human visual and translation quality assessment is separate from deterministic regression assertions. diff --git a/tests/docs/history-audit.md b/tests/docs/history-audit.md new file mode 100644 index 000000000..7f0af5c3d --- /dev/null +++ b/tests/docs/history-audit.md @@ -0,0 +1,40 @@ +# Historical regression audit — 2026-10-05 + +The public GitHub API returned **849 issue/PR records: 522 issues and 327 pull requests**, through #910. All received initial triage; selected bugs received detailed source/test comparison and reproducible fault replay. This is not 849 reproduced bugs, an exhaustive comment/attachment review, or a historical detection percentage. Many applicable reports still lack demonstrated coverage. + +Per-record assessments and limitations: [1–349](history-early.md), [350–649](history-middle.md), [650–910](history-recent.md). The ignored raw snapshot is `artifacts/history-audit/issues.json`; durable ledgers retain source links. Closed issues/PRs are not assumed fixed, merged, or relevant to current hosts. Feature, dependency, translation and deployment records are distinguished from reproducible runtime faults. + +## What the previous tests would catch + +| Source | Existing assertion | Evidence and limits | +| --- | --- | --- | +| [#823 / #825](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/825) | `ApiCdnRefreshTests.FullKnownAssetRefreshContinuesAfterHttpTimeoutAndReportsCompletion` | Exact reversal of the cancellation catch filter makes a simulated HTTP timeout abort refresh. The test fails with `TaskCanceledException`; current/restored code completes the sweep. | +| [#861 / #873](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/873) | `UrlPolicyTests.ContainerAliasesPermitPodmanLinkLocalWithoutPermittingCloudMetadata` | Representative removal of the container-alias exception fails Podman's link-local cases. Tests inject resolved addresses into the actual policy; no real Podman DNS fixture is claimed. | +| [#900](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/900) | `ApiSeerrTests.RequestsUsePrincipalIdentityPreserveSeasonSelectionAndGateAdvancedOptions` | Removing advanced-field stripping fails the outgoing-payload assertion. This proves backend enforcement, not the report's complete modal-visibility journey. | +| [#513 / #519](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/519) | Existing translation language-fallback test | Reversing primary-language candidate insertion fails fallback behavior. | +| [#774](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/774) | Existing bookmark episode/season matching test | Removing the historical matching guard fails with bookmarks from other episodes. Player timeline integration remains separate. | + +## Blind spots found and repaired + +**The original 323-test frontend suite passed with three historical faults restored:** pending-season approval controlled by media availability, accumulated request-page click listeners, and stereo-first audio badge selection. New tests fail each fault. See [recent audit](history-recent.md) and `artifacts/history-audit/recent-prior-suite/` for original-suite survivor evidence. + +**The original native poster pipeline also passed with Jellium Desktop removed from the web-client exclusions**, exactly reversing [PR #906](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/906). Its test iterates the production list, so a missing entry disappears from both behavior and expectations. New `HistoryNativeClientTests` uses independently specified client names through the real claims-based policy and fails that reversal. Old pipeline baseline/fault/restored logs are in `artifacts/history-audit/jellium-original-harness/`; all three exited 0, demonstrating that specific gap. This does not claim every other suite also survived. + +Additional new tests cover Command-R versus random selection, subtitle-sheet toggling, disabled auto-pause, reverse-proxy avatar URLs/authentication, concurrent avatar download deduplication/cleanup, and preserving Jellyfin's native subtitle stylesheet. **Nine frontend tests and thirteen backend cases were added.** Subtitle-sheet and auto-pause scenarios pass current code but were not replayed against their historical faults. No JE production source needed modification in this audit. + +## Repeatable evidence + +```sh +python3 tests/run.py fast --artifacts artifacts/history-audit/integrated +python3 tests/run.py history --artifacts artifacts/history-audit/history-suite +``` + +`history` runs **13 distinct fault scenarios**: nine JavaScript cases and four backend cases on each build target, for 17 baseline/fault/restored experiments. Each must pass first, fail the selected test with the expected failure signature after mutation, and pass after restoration. Compilation errors and zero selected tests do not count. Manifests in `tests/history/*-mutations.json` record source links and exact-reversal versus representative-fault distinctions. Current-code tests run normally in PR suites; historical replay is part of `all` and scheduled/manual extended CI. + +The runner copies local sources into temporary storage, rejects absolute/traversing manifest paths, checks targets remain inside the copy, and never modifies live production source. Two tooling tests guard empty-case false success and escaping the disposable checkout. Historical proofs require no GitHub access after setup. Nothing was committed, pushed or posted upstream. + +## Remaining work + +Ledgers retain hundreds of unproven candidates. High-value gaps include composite Seerr fixes, admin configuration/reverse-proxy styling, restricted calendar libraries, tag-cache lifecycle/scale, React-owned search DOM, Moonfin header nesting, native-client behavior, and the open Edge/Windows Picture-in-Picture report. Linux jsdom or Chromium tests cannot establish recovery of Edge's native video surface. + +Selected historical experiments establish sensitivity to those faults only. They do not make the broader regression-testing goal complete or convert line coverage into complete behavioral coverage. See [validation](validation.md) for execution results and the distinction between this follow-up and the earlier full-host/fresh-tree run. diff --git a/tests/docs/history-early.md b/tests/docs/history-early.md new file mode 100644 index 000000000..236387095 --- /dev/null +++ b/tests/docs/history-early.md @@ -0,0 +1,48 @@ +# Historical issue audit: records 1–349 + +This audit indexes all **321 available records** in this range: **260 issues and 61 pull requests**. The per-record source links, classification, test links and limitations are in [`early-triage.json`](../../tests/history/early-triage.json). Deleted/missing issue numbers are not invented. The index is an initial triage, not 321 reproduced bugs: substantive report bodies and fix history guided the selected tests; many reports still need comments, an exact patch, or an affected-client fixture before a detection claim is possible. + +## Demonstrated historical detection + +[Issue #55](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/55) reported that Command-R selected random media instead of refreshing on macOS. [Fix 4f8feb69](https://github.com/n00bcodr/Jellyfin-Enhanced/commit/4f8feb69997749667e08d65a2e933b0999b9bd94) added the missing Meta modifier to shortcut matching. + +The new `history issue 55 Command-R preserves browser refresh without selecting random media` test executes the production event module. It asserts that Meta/Ctrl/Alt-R leave the event unconsumed and perform no random selection, while unmodified R still selects. Existing shortcut tests did not explicitly cover this browser shortcut. This is **newly added detection**, not evidence that the previous suite would have caught #55. + +The mutation manifest reverses the specific Meta term introduced by that fix in a disposable source copy. Current modifier ordering differs from the old file, so this is an exact reversal of the fixing expression, not a checkout of the entire obsolete application. Results: baseline **pass**, historical fault **assertion failure**, restored source **pass**. Production workspace files were never mutated. + +## Two additional report-derived tests + +The same [test file](../../tests/frontend/regressions/history-early-events.test.mjs) adds: + +- [#1](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/1): pressing S closes an existing subtitle sheet without clicking the opener; a subsequent S opens it. Covers the English Jellyfin sheet title used by current code. Localized sheet labels remain a gap. +- [#163](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/163): disabled automatic pause leaves playback untouched on tab visibility changes; enabled pause resumes only playback paused by JE, and disabling it again takes effect. This isolates actual event behavior; it does not establish administrator/default-settings precedence or physical device playback behavior. + +These two tests pass current code. Their historical failures were not replayed, so they are report-derived tests, not demonstrated historical catches. No production fixes were necessary for these three scenarios. + +## Existing coverage versus remaining gaps + +The index marks **37 records partially covered by existing tests**, with concrete files and limitations. Examples: + +| Historical reports | Existing contract | What remains unproven | +|---|---|---| +| #242, #329, #330 | `TaskSeerrSyncTests.InboundWatchlistIsUserScopedIdempotentAndDoesNotReaddRemovedItems` | Manual sync and library-event paths; exact historical patch replay | +| #206, #217, #226, #282, #349 | `TaskAutoRequestTests.SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest` | All release metadata combinations and concurrent cross-user duplicate requests | +| #71, #257 | Translation document-language fallback and Unicode resource loading | Original browser profile Auto state and release-specific failure | +| #130 | Authenticated route and safe configuration tests | Replaying the historical missing-authorization change | +| #151 | Poster resolver stream-height boundaries | Exact 1328×720 width-sensitive historical input; a height-only test may miss it | +| #319, #320 | Scroll empty/zero-progress termination, hidden sections and teardown | Complete discovery filter/dedup/retry journey | + +The conservative tally is: 1 proven new catch, 2 new passing report-derived tests, 37 partial existing mappings, 104 unresolved gaps, 103 feature requests, 42 historical changes without detection proof, 12 old deployment/version cases, 12 native-client gaps, 4 permission-environment cases, and 4 support requests. Feature and historical-change classifications **do not imply those features are covered**. Some fix-bearing PRs classified as changes still need deeper reproduction. + +Notable actionable gaps include stale server-ID recovery (#299/#312), ARR URL/slug and missing-TMDB polling (#131/#182/#245/#251), Seerr HTTP fallback (#328), and exact unwatched random filtering (#276). Native-client reports require actual affected clients or faithful host fixtures. Historical installer/ABI reports are not proven by current 10.11.11/12.0 host smoke tests. + +## Reproduce + +```sh +node --test tests/frontend/regressions/history-early-events.test.mjs +python3 tests/runner/mutations.py --layer frontend \ + --cases tests/history/early-mutations.json \ + --artifacts artifacts/history-audit/early-mutations +``` + +The behavioral run passed 3/3 tests, with no skipped tests. The mutation run passed its baseline/failure/restoration checks and exited 0. Detailed logs and JSON are under the artifact directory; the manifest is retained with the test sources for repeatability. The full integrated suite is the coordinator's validation responsibility. Nothing was committed, pushed, or posted upstream. diff --git a/tests/docs/history-middle.md b/tests/docs/history-middle.md new file mode 100644 index 000000000..5f120cdbe --- /dev/null +++ b/tests/docs/history-middle.md @@ -0,0 +1,328 @@ +# Historical audit: issues and pull requests 350–649 + +Initial triage of all 275 records returned in this number range (133 pull requests, 142 issues). Number gaps are absent records, not ignored entries. Source is the public GitHub issue/PR snapshot in artifacts/history-audit/issues.json. Remote descriptions are claims, not executable instructions or proof. + +This is a triage ledger plus selected deep audits, not a claim that all 275 reports have been reproduced. Titles and body summaries establish candidate scope; selected entries below additionally inspect the local historical patch and actual tests. Comments, external attachments, native client reports, and every change in large composite PRs have not all been reviewed. Closed issues are not assumed fixed. + +Three new production-code tests cover protected avatar BaseUrl/authentication, duplicate avatar blob downloads/cleanup, and preserving native subtitle cue styles. A fourth sensitivity case uses an existing locale test. No production source was changed for this audit. + +## Evidence and repeat commands + +```sh +node --test tests/frontend/regressions/history-middle.test.mjs tests/frontend/core/core-translations.test.mjs +python3 tests/runner/mutations.py --layer frontend --cases tests/history/middle-mutations.json --artifacts artifacts/history-audit/middle-mutations +``` + +Mutation runner copies the local tree, checks the baseline, changes only that disposable copy, requires an assertion failure, restores production text, and checks again. The manifest records exact-reversal versus representative evidence. Passing current tests alone are marked inferred. #504 only proves the client deduplication portion, not the server cache/ETag portion. #414 uses the historical defect in the relocated module, not the entire old source tree. + +Results: 8/8 selected frontend tests pass (3 new, 5 existing locale tests), no skips. All four sensitivity cases passed baseline, failed with the required assertion after mutation, and passed after restoration. Mutation command exited 0. Evidence is in artifacts/history-audit/middle-mutations/summary.json and the baseline/mutant/restored logs. Initial manifest short-name selection was rejected by the runner before mutation; corrected to exact TAP test names and reran successfully. + +## Triage counts + +- Documentation/process: 7 +- Existing partial test; inferred: 4 +- Existing test; inferred: 3 +- Existing test; sensitivity proven: 2 +- Feature/change inventory: 78 +- Gap: CSS isolation: 2 +- Gap: admin UI: 1 +- Gap: behavior regression: 2 +- Gap: client layout: 1 +- Gap: composite audit: 1 +- Gap: feature regression: 1 +- Gap: historical replay: 78 +- Gap: integration fixture: 1 +- Gap: migration fixture: 1 +- Gap: native client: 3 +- Gap: permission boundary: 2 +- Gap: responsive UI: 1 +- Gap: reverse-proxy UI: 1 +- Gap: settings interaction: 2 +- Gap: upgrade/cache fixture: 1 +- Localization change: 16 +- Maintenance/dependency: 28 +- Needs deeper triage: 30 +- New partial test; sensitivity proven: 1 +- New test; sensitivity proven: 4 +- Partial host evidence; inferred: 2 +- Superseded/decision needed: 2 + +## Per-record ledger + +| Source | Title | Assessment | Test/evidence or remaining work | +|---|---|---|---| +| [#350](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/350) | [WIP] Investigate cause of issue 349 | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#351](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/351) | 🐛[BUG] Rotten Tomatoes rating is not displayed everywhere. | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#352](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/352) | [QOL] Picture in Picture starts playback if paused | Gap: behavior regression | Paused-to-PiP auto-resume must be tested with playback lifecycle; feature/QOL title does not make it irrelevant. | +| [#353](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/353) | ➕[Feature Request] Hide enhanced panel menu entry | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#354](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/354) | ➕[Feature Request] Global language settings | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#355](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/355) | Unable to make Quality Tag working | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#356](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/356) | 🐛[BUG] Rating tag on episode wrong | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#357](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/357) | 🐛[BUG] Jellyseerr results: broken link when clicking on title | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#358](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/358) | ➕[Feature Request] Hide "My Media" Section by default | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#359](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/359) | Calendar UI polish: mobile sidebar toggle, grid tweaks, simplified css | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#360](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/360) | ➕[Feature Request] Further integration with home screen sections | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#361](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/361) | Add Norwegian (.no) locale translation file | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#362](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/362) | fix: prevent Jellyfin router from hijacking Jellyseerr title links | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#363](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/363) | feat: add IMAX quality tag | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#364](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/364) | fix(discovery): repair actor infinite scroll and default More From to All | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#365](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/365) | Improve Calendar API speed by using Database Implementation to fetch Jellyfin Items (10.11 only) | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#366](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/366) | feat: File Transformation plugin warning on config page | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#367](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/367) | 🐛[BUG] Jellyseer modal missing rating platform icons for percentage scores | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#368](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/368) | feat: add Clear Translation Cache as a scheduled task on startup | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#369](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/369) | feat: add Refresh Translation Cache scheduled task | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#371](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/371) | Update ratings.css | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#372](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/372) | Add HSS discovery handler for Jellyseerr modal | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#373](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/373) | ➕[Feature Request] Move all documentation to github pages | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#374](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/374) | Add Traditional Chinese (Hong Kong) translation | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#377](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/377) | ➕[Feature Request] Show umonitored series in calendar | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#378](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/378) | First commit | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#379](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/379) | Add support for unmonitored series in calendar view | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#380](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/380) | Sorted the Ratings alphabetically and removed duplicates | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#386](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/386) | 🐛[BUG] Special Episodes unavailable to request, and requesting all seasons breaks jellyseerrs ability to request special episode | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#387](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/387) | docs: Introduce Material for MkDocs | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#388](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/388) | fix: Calendar card click navigates to episode instead of series | Superseded/decision needed | Current pages-calendar.test.mjs explicitly expects series on card click and episode on play. Historical requested episode-card behavior is not assumed current contract. | +| [#389](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/389) | Calendar card click: navigate to episode or series? | Superseded/decision needed | Same #388 contract conflict; historical issue asks whether series navigation was intentional. | +| [#390](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/390) | feat: add cast/actors support for hidden content system | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#391](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/391) | 🐛[BUG] pt-BR language falls back to English | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#392](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/392) | ➕[QOL] Include everything to translate, including Watchlist, etc | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#393](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/393) | fix(ratings): update South Korea rating selector to KR-19 | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#394](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/394) | fix: prevent hide icon from appearing on image editor cards | Existing partial test; inferred | features-hidden.test.mjs: disabled filtering and search default preserve cards; image-editor cards are ignored. Button injection in edit dialogs is additional coverage gap. | +| [#395](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/395) | ➕[Feature Request] Calendar requests filter override | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#396](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/396) | ➕[Feature Request] Filtered view by Requests in Calendar does not include media that has metadata tags indicating the requesting user | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#397](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/397) | 🐛[BUG] HSS doesn't seem to open modal instead of Seerr | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#398](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/398) | 🐛[BUG] Automatic Requests requests content again | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#399](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/399) | feat: Spoiler Mode — per-user spoiler protection | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#400](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/400) | ➕[Feature Request] Use connected userId when creating Seerr requests | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#401](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/401) | feat: tag-based request matching fallback for calendar filter | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#402](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/402) | 🐛[BUG] Radarr/Sonarr/Bazarr buttons can appear for non admin users | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#403](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/403) | 🐛[BUG] Missing Jellyseerr Status Icon(s) | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#404](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/404) | feat: auto-reload monitors for user switch and config changes | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#406](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/406) | 🐛[BUG] Delay in Jellyseerr Search Results | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#407](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/407) | fix: hidden content page and hide button improvements | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#408](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/408) | 🐛[BUG] Translated show title is used for Sonarr link instead of original title | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#409](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/409) | Seerr request for new season of partially downloaded tv show | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#410](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/410) | ➕[Feature Request] Integration with the SIMKL plugin | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#411](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/411) | Added dutch locale | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#412](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/412) | ➕[Feature Request] Rename Jellyseerr to Seerr | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#413](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/413) | 🐛[BUG] Subtitle setting Disable custom styling not correctly respecting original settings | New test; sensitivity proven | Native cue preservation: `history414 legacy cue stylesheet survives custom subtitle enable and disable`; representative mutation. | +| [#414](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/414) | fix(subtitles): disable custom subtitles now respects default subtitle settings | New test; sensitivity proven | Same #413 test; historical e15cebdb overwrote native cue rules; equivalent fault reproduced in current relocated module. | +| [#416](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/416) | 🐛[BUG] Jellyfin v110.11.6 Branding CSS over rides custom splash. Menu removal from Dash. | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#417](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/417) | ➕[Feature Request] remaining time of media | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#418](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/418) | fix: prevent duplicate Jellyseerr requests from auto-season-request | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#419](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/419) | 🐛[BUG] "downloadable font: Glyph bbox was incorrect" | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#420](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/420) | ➕[Feature Request] Support deleted and blocklisted Seerr media status | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#422](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/422) | ➕[Feature Request] Custom Pause Screen should not appear when screen is being interacted with. | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#423](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/423) | fix: Use Sonarr's actual titleSlug for series links | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#424](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/424) | fix: wrap CheckPluginPages in try/catch to prevent plugin malfunction | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#425](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/425) | 🐛[BUG] Home tabs: Requests/Calendar render empty until page reload | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#426](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/426) | fix: stability fixes (CheckPluginPages + search grid alignment) | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#427](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/427) | feat: Seerr API update, search performance fix, discovery sort/filter, collection discovery | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#428](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/428) | chore(ci)(deps): bump ossf/scorecard-action from 2.4.0 to 2.4.3 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#429](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/429) | chore(ci)(deps): bump actions/upload-pages-artifact from 3 to 4 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#430](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/430) | chore(ci)(deps): bump actions/cache from 3 to 5 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#431](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/431) | chore(ci)(deps): bump actions/setup-dotnet from 4 to 5 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#432](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/432) | chore(ci)(deps): bump actions/setup-node from 4 to 6 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#433](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/433) | ➕[Feature Request] Language sensitive audio quality tags | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#434](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/434) | fix: CodeQL security fixes | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#435](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/435) | ➕[Feature Request] Remove transform CSS from .mediaInfoOfficialRating function | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#436](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/436) | 🐛[BUG] JellySeerr search integration failing; Other jellyseerr features working | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#438](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/438) | ➕[Feature Request] Media Stub Tags | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#439](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/439) | ➕[Feature Request] Separate Active Downloads from *ARR and Seer Requests/Issues on the Requests Page | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#440](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/440) | Investigate what is causing slowness when the plugin is installed | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#441](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/441) | 🐛[BUG] Language Settings in JE doesn't respect override user sets | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#442](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/442) | ➕[Feature Request] Be able to show Seerr results after Jellyfin local search results | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#443](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/443) | ➕[FeatureRequest] Media from blocked libraries appears on calendar | Gap: permission boundary | Calendar blocked-library report is a privacy regression even though labeled feature request; needs restricted real route + root-folder fallback proof. | +| [#445](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/445) | ➕[Feature Request] Be able to show Seerr results after Jellyfin local search results | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#446](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/446) | ➕[Feature Request] Be able to use Report feature with Seer search results disabled | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#447](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/447) | ➕[Feature Request] link available Seerr results poster to the item in jellyfin library | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#448](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/448) | navigates to Jellyfin item for existing media instead of opening Seerr | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#449](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/449) | ➕[Feature Request] Include documentation on endpoints needed for jellyseerr. | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#450](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/450) | 🐛[BUG] Seer request advanced options not displayed if requesting from Movie details modal | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#451](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/451) | ➕[Feature Request] Add Jellyfin Icon for available items in more-info-modal | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#452](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/452) | Bookmarks | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#453](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/453) | Fix: Calendar respects user library access (Issue #443) | Gap: permission boundary | #443 calendar library/root-folder filtering historical patch not replayed. | +| [#454](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/454) | added Arabic language | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#455](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/455) | ➕[Feature Request] Audio quality tags - display also number of channels | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#456](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/456) | ➕[Feature Request] Handle multiple instances *arr. | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#457](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/457) | fix: Plugin Pages and Custom Tabs working together | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#458](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/458) | 🐛[BUG] Cannot Rerequest previously deleted media | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#459](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/459) | ➕ [Feature Request] 4k requests for TV Shows | Gap: behavior regression | TV 4K request support reported broken; full season-modal journey needs fixture and assertion. | +| [#460](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/460) | chore(ci)(deps): bump the github-actions group with 2 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#461](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/461) | 🐛[BUG] Seasons and specials showing as just numbers | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#462](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/462) | 🐛[BUG] Not working on Jellyfin Desktop 2.0.0-dev | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#464](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/464) | Update languagetags.js | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#466](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/466) | feat: add Simplified Chinese (zh-CN) support | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#468](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/468) | fix: show proper season names and metadata for TheTVDB | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#469](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/469) | fix: prevent plugin from overwriting user's language on every page load | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#470](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/470) | Custom Pause Screen | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#471](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/471) | ➕[Feature Request] [Auto-Movie-Request] Request next movie in the same quality profile | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#472](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/472) | 🐛[BUG]Jellyseerr search results not showing - [appRouter] "/search.html" route not found - Jellyfin 10.11.2 | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#473](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/473) | fix: Jellyseerr search not triggering on Jellyfin 10.11.x | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#474](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/474) | 🐛[BUG] Trakt reference button settings toggle disappeared | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#475](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/475) | docs: installation, troubleshooting, etc | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#476](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/476) | 🐛[BUG] Audio Popup too small | Gap: CSS isolation | Same #484 native volume OSD sizing regression. | +| [#477](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/477) | 🐛[BUG] Film festival is not a streaming provider | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#479](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/479) | 🐛[BUG] | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#480](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/480) | ➕[Feature Request] issue visibility | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#482](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/482) | 🐛[BUG] Side menu order | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#483](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/483) | chore(ci)(deps): bump the github-actions group with 3 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#484](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/484) | fix: scope global .material-icons CSS to prevent volume OSD shrinking | Gap: CSS isolation | Volume OSD font-size must remain native when JE styles load; current focused snapshots do not include OSD. | +| [#485](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/485) | feat: quality profile mode for auto-movie-request | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#486](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/486) | ➕[Feature Request] Alternate External URL for Seerr and *arr Connections | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#487](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/487) | feat: tag-based request matching and library access filtering for calendar | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#488](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/488) | feat: tag-based request matching for calendar filter | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#489](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/489) | docs: fix: Jellyseerr URL mappings clarifications | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#490](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/490) | Catalan Translation | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#491](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/491) | ➕[Feature Request] Auto-Import Users in Seerr | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#492](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/492) | feat: Auto import Jellyfin users to Seerr | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#493](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/493) | Update Discord link in README.md | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#494](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/494) | Update ICON_MAP with Catalan TL | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#495](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/495) | feat: Config page search & setting dependency enforcement | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#496](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/496) | Update activity icons with polish language | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#497](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/497) | 4k series requests | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#498](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/498) | 🐛[BUG] Seerr Results not showing in search after migrating from Jellyseer. | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#499](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/499) | Auto intro skip not working on Firefox and possibly all non-Chromium browsers | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#500](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/500) | ➕[Feature Request] Watchlist with Seerr enchanced | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#501](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/501) | Added Catalan, Basque and Galician flag codes | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#502](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/502) | ➕[Feature Request] Request new seasons on a Series' page. | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#504](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/504) | fix: prevent duplicate avatar downloads filling up browser temp files | New partial test; sensitivity proven | `history504 repeated and concurrent avatar rendering downloads one blob and releases it`; client dedup fault detected. Server ETag/cache aspects still need historical proof. | +| [#505](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/505) | fix: fall back to show poster for seasons missing artwork | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#506](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/506) | Changed 2 translations that werent working. | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#507](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/507) | 2 more corrections catalan tl | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#508](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/508) | chore(ci)(deps): bump the github-actions group with 2 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#509](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/509) | chore(ci)(deps): bump actions/configure-pages from 5.0.0 to 6.0.0 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#510](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/510) | chore(ci)(deps): bump actions/deploy-pages from 4.0.5 to 5.0.0 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#511](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/511) | Reduce size of quality tags or show only inside details poster | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#512](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/512) | ➕[Feature Request] Reduce size of quality tags or show only inside details poster | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#513](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/513) | 🐛[BUG] Language does not adapt to the settings. | Existing test; sensitivity proven | `translations normalize per-user language and cache the bundled Unicode response` in core-translations.test.mjs detects #519 primary-language deletion. | +| [#514](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/514) | 🐛[BUG]The reviews sections only appear when logged in as an admin user | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#515](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/515) | perf: Consolidate MutationObservers and unify tag systems into single pipeline | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#516](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/516) | 🐛[BUG] Connection with Seerr not working on newest Jellyfin version (10.11.6) | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#517](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/517) | fix: move non-sensitive derived values from private-config to public-config | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#518](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/518) | fix: wait for access token before Seerr initialization | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#519](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/519) | fix: include primary language in translation fallback chain | Existing test; sensitivity proven | Same #513 test; exact primary-language insertion reversal from 7bb7f540. | +| [#520](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/520) | 🐛[BUG]The feature "Show Reviews from TMDB in Item Details Page" only works on admin accounts. | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#521](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/521) | add czech and slovak language | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#522](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/522) | fix: Clear stale UseCustomTabs/UsePluginPages flags when plugins not installed | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#523](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/523) | fix: Bookmarks tab empty, add sidebar nav and Custom Tabs support | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#524](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/524) | fix: filter download queue by user requests for non-admin users | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#525](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/525) | Requests & Downloads Page not showing up | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#526](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/526) | 🐛[BUG] Languages ​​are not working 404 on /main/Jellyfin.Plugin.JellyfinEnhanced/js/locales/xx.json | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#527](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/527) | perf: replace 100+ GitHub HEAD requests with server-side locale enumeration | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#528](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/528) | 🐛 [BUG] Jellyseerr search results not displayed when Meilisearch is enabled - wrong input selector + searchResults hidden | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#529](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/529) | fix: warn admin when jellyfin-web version is behind server | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#530](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/530) | 🐛[BUG] [v11.5.0.0] CRITICAL INITIALIZATION FAILURE: ReferenceError: JE is not defined (splashscreen.js) | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#531](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/531) | fix: prevent plugin UI disappearing after upgrade and crashing at start up | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#532](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/532) | 🐛[BUG] TMDB Unreachable | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#533](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/533) | ➕[Feature Request] Subtitle position slider | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#534](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/534) | 🐛[BUG] German language does not work (translation file missmatch de vs de-DE) | Existing test; inferred | ApiAssetTests.RegionalLocaleFallsBackToEmbeddedBaseLanguage checks regional locale HTTP content; historical reversal not executed here. | +| [#535](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/535) | Update flag image source to use CDN for flags | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#536](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/536) | 🐛[BUG] Requests and calendar tab | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#537](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/537) | chore(ci)(deps): bump trufflesecurity/trufflehog from 3.94.1 to 3.94.2 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#539](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/539) | fix: fall back from regional locale to base language | Existing test; inferred | Same #534 backend test; bbdd8ef7 is the identified historical fix. | +| [#540](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/540) | fix: custom tabs render empty after dashboard navigation | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#541](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/541) | ➕[Feature Request] Ability to write reviews | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#542](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/542) | feat: User Reviews | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#543](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/543) | feat: add "Request More" button next to Series heading on Series pages | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#544](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/544) | ➕[Feature Request] Language Flag Change | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#545](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/545) | Adding to the user reviews feature. Admin controls for users. Bugs | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#546](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/546) | 🐛[BUG]Only administrator accounts can see reviews written by Jellyfin users | Existing partial test; inferred | ReviewActivityVisibilityTests covers visibility helper; full authenticated review endpoint historical scenario unproven. | +| [#547](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/547) | ➕[Feature Request] User Rating System Improvements | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#548](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/548) | fix: hidden users could not see their own reviews | Existing test; inferred | ReviewActivityVisibilityTests checks hidden/disabled authors, self and administrator combinations. Historical reversal not executed. | +| [#549](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/549) | ➕[Feature Request] where was i? | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#550](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/550) | 🐛[BUG] Avatars in requests page point to incorrect URL | New test; sensitivity proven | `history551 protected avatars preserve the configured Jellyfin base path and authorization`; direct reversal of #551 fetch expression detected. | +| [#551](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/551) | Fix avatar image source for requests page | New test; sensitivity proven | Same #550 test; 8310d8c1 ApiClient.getUrl reversal in relocated helpers.js. | +| [#552](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/552) | 🐛[BUG] Request More not working with KefinTweaks "Collapse Single season series" | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#553](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/553) | chore(ci)(deps): bump trufflesecurity/trufflehog from 3.94.2 to 3.94.3 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#554](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/554) | chore(ci)(deps): bump actions/upload-pages-artifact from 4.0.0 to 5.0.0 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#555](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/555) | ➕[Feature Request] Make the custom pause screen not come back after hitting x, or make the time before it comes back customizable | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#556](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/556) | 🐛[BUG] Not supported | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#557](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/557) | ➕[Feature Request] Full-Series Language Tag | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#558](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/558) | fix: gate TMDB reviews on Elsewhere toggle, boxify reviews ui and blocks | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#559](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/559) | feat: multi-instance Sonarr/Radarr support | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#560](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/560) | ➕[Feature Request] More control over "Show Quality Tags" in UI Settings | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#561](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/561) | ➕[Feature Request] More granular control over what "Show Rating Tags" applies to. | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#562](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/562) | ➕[Feature Request] Hide zero star rating displayed by "Show Rating Tags" | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#563](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/563) | ➕[Feature Request] Improve subtitle configuration | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#564](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/564) | 🐛[BUG] Links to the Seerr instance get jellyfin mobile stuck woth no way to navigate back | Gap: native client | iOS external-link handoff needs host/client contract fixture; browser-only behavior insufficient. | +| [#566](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/566) | 🐛[BUG] Active Stream Widget Fit | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#567](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/567) | chore(ci)(deps): bump the github-actions group with 3 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#568](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/568) | Settings UI Redesign | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#569](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/569) | Settings UI Redesign fixed oops | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#570](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/570) | 🐛[BUG] Blank Screen in the Calendar Custom Tab | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#571](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/571) | 🐛[BUG] Radarr calendar instance "Radarr" failed: URL rejected by SSRF guard | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#572](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/572) | fix(config): inject configPage.css via ApiClient.getUrl for reverse-proxy support | Gap: reverse-proxy UI | Config CSS ApiClient.getUrl path needs admin configuration page at non-root BaseUrl; avatar test covers a different caller. | +| [#573](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/573) | Remove from Continue Watching and Hidden Content: non-destructive, and now work on all devices (no longer DOM-only) Also add hidden content settings to admin page instead of only in the user settings. | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#574](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/574) | fix: timezone-aware TMDB air dates for non-US viewers | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#575](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/575) | chore(ci)(deps): bump trufflesecurity/trufflehog from 3.94.3 to 3.95.2 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#576](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/576) | ➕[Feature Request] Click off modal to exit (Seerr) | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#577](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/577) | 🐛[BUG] Seerr discovery not appearing on item pages despite correct setup | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#578](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/578) | i18n: add Bulgarian locale | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#579](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/579) | feat: add frame-by-frame video controls (, and .) | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#580](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/580) | ➕[Feature Request] Better Seerr limit support | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#581](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/581) | ➕[Feature Request] Filter seerr results based on rating | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#582](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/582) | 🐛[BUG] Issue with overall loading | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#583](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/583) | feat: better Seerr request quota support | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#584](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/584) | Jellyfin Helper favicon.ico | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#585](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/585) | feat: split Quality Tags into 6 reorderable categories (#560) | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#586](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/586) | chore(ci)(deps): bump github/codeql-action from 4.35.2 to 4.35.3 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#587](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/587) | feat: trigger Seerr recently-added scan on Jellyfin item added | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#588](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/588) | feat: trigger Seerr recently-added scan on Jellyfin item added | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#589](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/589) | feat: per-section advanced filters for Seerr discovery sections | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#590](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/590) | ➕[Feature Request] Investigate if it is possible to burn tags into the poster or intercept the posters and show them with tags | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#591](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/591) | 🐛[BUG] Attempting to enable Seerr integration with a broken Seerr URL in Jellyfin 10.11.8 breaks entire web UI | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#592](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/592) | ➕[Feature Request] Fetch upcoming anime from Shoko | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#593](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/593) | Weblate translations | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#594](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/594) | 🐛[BUG] Deleted seasons cannot be requested again via Seerr in Jellyfin | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#595](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/595) | ➕[Feature Request] Media file source visibility | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#596](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/596) | 🐛[BUG] *arr URL Mappings not working | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#597](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/597) | feat: user reviews for seasons/episodes + average rating on posters and item details | Existing partial test; inferred | pages-reviews.test.mjs: episode reviews use series/season/episode key without fetching top-level TMDB reviews; poster averages/parent fallback need separate audit. | +| [#598](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/598) | fix(seerr): comprehensive audit + fixes (~70 bugs across discovery, auth, cache, SSRF/XSS) | Gap: composite audit | About 70 claimed root causes in one PR; transport/auth/UI tests overlap but each fix needs independent patch-to-assertion mapping. No aggregate would-catch claim. | +| [#599](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/599) | fix(seerr): Cloudflare / reverse-proxy / forward-auth compatibility + cache flush on config save | Existing partial test; inferred | IntegrationHttpTests and ApiSeerrTests cover transport/error contracts; whole reverse-proxy/forward-auth/config-cache combination unproven. | +| [#600](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/600) | 🐛[BUG] "Show rating in video player" does not update when playing next episode | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#601](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/601) | ➕[Feature Request] Individual settings for quality tags | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#602](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/602) | chore(ci)(deps): bump github/codeql-action from 4.35.3 to 4.35.4 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#603](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/603) | chore(ci)(deps): bump actions/dependency-review-action from 4.9.0 to 5.0.0 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#604](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/604) | fix: admin config page UI bugs | Gap: admin UI | Independent Radarr/Bazarr dependency gating and other config-page fixes need real page controls. | +| [#605](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/605) | ➕[Feature Request] Recent activity | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#606](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/606) | fix: config page renders broken on portrait phones | Gap: responsive UI | Portrait390/412px config fieldset and hint clipping require actual admin-page layout assertions. | +| [#607](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/607) | ➕[Feature Request]Adding language tags on collections | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#609](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/609) | 🐛[BUG] User Rating Tag Icon - Neutralfin Theme | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#610](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/610) | ➕[Feature Request] Integrate Anilist user reviews with new User Review feature | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#611](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/611) | 🐛[BUG] Tag categories do not seem to be applied | Gap: settings interaction | Per-category video format disable persistence must be verified through settings and rendered tags together. | +| [#612](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/612) | feat(spoiler-guard): per-user opt-in spoiler protection for unwatched content | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#613](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/613) | [StepSecurity] Apply security best practices | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#614](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/614) | chore(ci)(deps): bump the github-actions group with 2 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#615](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/615) | Bump Jellyfin.Controller and Jellyfin.Model | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#616](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/616) | Bump Jellyfin.Model from 10.11.0 to 10.11.8 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#617](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/617) | Bump Newtonsoft.Json from 13.0.1 to 13.0.4 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#618](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/618) | 🐛[BUG] | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#619](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/619) | 🐛[BUG] JSON parse error on media details page when Jellyfin Enhanced is enabled | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#621](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/621) | 🐛[BUG] Reopen: Links to the Seerr instance get jellyfin mobile stuck with no way to navigate back | Gap: native client | iOS external Seerr link trapping is not established by browser window.open tests; reopen of #564. | +| [#622](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/622) | Bump Jellyfin.Controller and Jellyfin.Model | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#623](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/623) | Bump Jellyfin.Model from 10.11.8 to 10.11.9 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#624](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/624) | Translations update from Hosted Weblate | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#625](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/625) | Translations update from Hosted Weblate (#624) | Localization change | Locale packaging/fallback tests cover mechanics; changed translation wording and language completeness not historically replayed. | +| [#626](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/626) | 🐛[BUG] MissingMethodException on Jellyfin 10.11.9 due to breaking IUserManager changes | Partial host evidence; inferred | Actual-host smoke covers pinned 10.11.11 and 12.0; old 10.11.9 binary/API transition and enabled user import not replayed. | +| [#627](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/627) | 🐛[BUG] Jellyfin Enhanced breaks profile/avatar button on official Xbox app | Gap: native client | Xbox profile-button activation semantics are not established by Chromium smoke. | +| [#628](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/628) | 🐛[BUG] Jellyfin 10.11.9: Jellyfin Enhanced Seerr user import and permission audit fail with MissingMethodException (IUserManager.get_Users) | Partial host evidence; inferred | Same #626: build compatibility is not proof enabled import/audit paths ran on historical 10.11.9. | +| [#629](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/629) | 🐛[BUG] | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#630](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/630) | Remove margin-right from letterboxd icon style | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#631](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/631) | chore(ci)(deps): bump the github-actions group with 3 updates | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#632](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/632) | Bump Jellyfin.Controller and Jellyfin.Model | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#633](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/633) | Bump Jellyfin.Model from 10.11.0 to 10.11.8 | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#634](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/634) | 🐛[BUG] ChunkLoadError on user menu / settings after 11.11.0.0 — immutable cache + stale chunk hash | Gap: upgrade/cache fixture | Stale host chunk hash after plugin upgrade requires mixed-version host asset/cache fixture; clean-host startup does not reproduce it. | +| [#636](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/636) | Feature Request Voting & Prioritization | Documentation/process | No direct runtime regression to replay; this classification does not certify documentation correctness. | +| [#637](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/637) | 🐛[BUG] Quality Tags, Language Tags and Elsewhere panel do not disable after saving settings | Gap: settings interaction | Global defaults versus existing per-user overrides and disable/restart interaction needs end-to-end fixture. | +| [#638](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/638) | ➕[Feature Request] Maintenance mode for Admins | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#639](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/639) | 🐛[BUG] Jellyfin-Enhanced interface fuctions not working on Jellyfin 10.11.10 | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#640](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/640) | ➕[Feature Request] Netflix-style age Announcement on player | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#641](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/641) | 🐛[BUG] Tiny subtitles | Gap: migration fixture | Existing users unexpectedly migrated to tiny subtitles; cue preservation test does not prove defaults migration. | +| [#642](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/642) | ➕[Feature Request] Sync Jellyfin Watchlist with Seerr Watchlist | Feature/change inventory | Feature request or extension; not automatically an existing bug. Behavior coverage remains governed by coverage-matrix.md, no historical proof asserted. | +| [#643](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/643) | chore(ci)(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 in the github-actions group | Maintenance/dependency | No specific JE behavioral reproducer in this dependency update; build/host checks are relevant, historical dependency matrix untested. | +| [#644](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/644) | Seerr request modal shows empty server/quality/folder dropdowns for new Jellyfin users (imported after Seerr v3.x upgrade) | Gap: integration fixture | New Seerr 3.x imported user settings:null must be exercised through service selectors/modal, not only permission helper. | +| [#645](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/645) | 🐛[BUG] Seerr Results not showing in search | Gap: historical replay | Applicable bug/fix candidate; not yet mapped to a fault-sensitive assertion for this exact report. Similar feature tests are insufficient evidence. | +| [#646](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/646) | 🐛[BUG/Enhancement] No page for Music Video bookmarks | Gap: feature regression | MusicVideo bookmarks persisted but absent from UI needs media-type render fixture. | +| [#647](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/647) | Banner too small | Needs deeper triage | Initial title/body triage only; comments/attachments/full patch may be needed to distinguish bug, feature, environment, or superseded behavior. | +| [#649](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/649) | 🐛[BUG] can’t reach the button to save a bookmark | Gap: client layout | iOS bookmark save button reachability needs keyboard/safe-area viewport fixture or physical client, beyond jsdom bookmark storage. | diff --git a/tests/docs/history-recent-triage.json b/tests/docs/history-recent-triage.json new file mode 100644 index 000000000..be4c665b9 --- /dev/null +++ b/tests/docs/history-recent-triage.json @@ -0,0 +1,2294 @@ +{ + "scope": "GitHub issues/PRs >=650 in fetched public snapshot; title triage all records, body excerpts for candidates, selected detailed source/test proof only", + "counts": { + "environment-gap": 2, + "not-selected-feature-request": 45, + "not-assessed": 141, + "backend-proof-see-coordinating-audit": 7, + "existing-related-assertions": 8, + "not-selected-maintenance": 41, + "not-selected-documentation": 5, + "existing-test-proven": 1, + "new-test-proven": 2, + "partial-related-proof": 1 + }, + "records": [ + { + "number": 910, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/910", + "title": "🐛[BUG] Auto PiP: video stuck in \"Playing in picture-in-picture\" state (audio only) when returning to the tab", + "state": "open", + "triage": "environment-gap", + "note": "Requires actual Edge Picture-in-Picture/video state on Windows, user activation and repeated visibility switches; jsdom or mobile viewport is insufficient." + }, + { + "number": 909, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/909", + "title": "➕[Feature Request] Be able to only activate star ratings without revies", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 908, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/908", + "title": "fix(recommendations): ship studio/network logos instead of fetching them", + "state": "open", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 907, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/907", + "title": "➕[Feature Request] Dismissable Banner", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 906, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/906", + "title": "Add Jellium Desktop to excluded clients for native poster tags", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 905, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/905", + "title": "🐛[BUG] Jellium Desktop shows native poster tags", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 904, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/904", + "title": "fix(ui): scope JE's Material Symbols rules to JE-private classes so theme and plugin icons keep the full font", + "state": "closed", + "triage": "existing-related-assertions", + "note": "Same stylesheet test covers ui-styles only; does not assert every downstream genre/people/reviews/calendar/config stylesheet or actual theme glyphs." + }, + { + "number": 903, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/903", + "title": "➕[Feature Request] Allow the option of the flags to be Name plates for Sub and/or dub depending on priorady language", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 902, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/902", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 901, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/901", + "title": "fix(tabs): native Home tabs coexist with Custom Tabs (positions, stable links, live reconciliation)", + "state": "closed", + "triage": "existing-related-assertions", + "note": "bootstrap-native-tabs tests assert reservations, late claims, placeholders, stale deep links, user choice, failed lookup; actual Custom Tabs host compatibility matrix not repeated." + }, + { + "number": 900, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/900", + "title": "🐛[BUG] Advanced request options shown to users without REQUEST_ADVANCED despite audit flagging them", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 899, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/899", + "title": "🐛[BUG] Header tray is injected inside the Moonfin button, so clicking Random also opens Moonfin", + "state": "closed", + "triage": "not-assessed", + "note": "Existing foreign-header-button test does not reconstruct Moonfin button as candidate host or assert parent click isolation." + }, + { + "number": 898, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/898", + "title": "➕[Feature Request]", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 897, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/897", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 896, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/896", + "title": "docs(readme): showcase README with screenshots, recordings and a fact-checked feature tour", + "state": "closed", + "triage": "not-selected-documentation", + "note": "Documentation scope; runtime regression not established." + }, + { + "number": 895, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/895", + "title": "fix(ui): give JE's Material Symbols subsets private font names so themes keep the full icon set", + "state": "closed", + "triage": "existing-related-assertions", + "note": "bootstrap-header font stylesheet test asserts public/full and private/subset separation; browser glyph failure under Jellyfish not reproduced." + }, + { + "number": 894, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/894", + "title": "feat(tags): native poster tags for clients without the web UI (experimental)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 893, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/893", + "title": "🐛[BUG]Mobile and WebOS Prompt Seer Login", + "state": "closed", + "triage": "environment-gap", + "note": "Mobile/webOS unexpected Seerr login requires host/plugin/client setup and routing reproduction; no physical-client validation." + }, + { + "number": 891, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/891", + "title": "perf: faster startup, browser-cached tag cache, faster details pages and smoother UI", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 890, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/890", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 889, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/889", + "title": "fix(config): don't show Custom Tabs sync alert when Custom Tabs isn't installed", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 888, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/888", + "title": "🐛[BUG] A repeated msg shows when missing an integration if you save ", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 887, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/887", + "title": "feat(media-tags): age rating badges, rating tag scope, preferred audio language, full-series language flags, and a tidier Media Tags fieldset", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 886, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/886", + "title": "chore(ci)(deps): bump the github-actions group with 4 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 885, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/885", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 884, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/884", + "title": "fix(settings): resolve casing mismatch when merging server plugin defaults", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 883, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/883", + "title": "feat(language-tags): distinguish full-series languages from partial ones on series and season cards", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 882, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/882", + "title": "feat(subtitles): text effect presets (outline / drop shadow)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 881, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/881", + "title": "feat(seerr): All | Movies | Series on studio and network pages (Recommendations tiles and \"More from\")", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 880, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/880", + "title": "feat(maintenance): countdown, playback reminders and daily scheduled window", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 879, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/879", + "title": "feat(quality-tags): preferred audio language for the sound tag (#433)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 878, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/878", + "title": "feat(tags): add Age Rating Tags poster overlay (FSK / MPAA / TV ratings)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 877, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/877", + "title": "feat(spoiler-guard): auto-enable on library add, scoped by content type and library", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 876, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/876", + "title": "feat(random-button): scope Random to a playlist or collection (#794)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 875, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/875", + "title": "feat(rating-tags): scope rating tags by item type and home row", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 874, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/874", + "title": "feat(seerr): manual TVDB match for series TMDB cannot auto-match (#653)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 873, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/873", + "title": "fix(url-guard): allow Podman's host.containers.internal link-local mapping", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 872, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/872", + "title": "🐛[BUG] 404 Not Found errors on static assets (fonts/branding)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 871, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/871", + "title": "🐛[BUG] AniSearch provider displays undefined", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 870, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/870", + "title": "perf: cut JE request volume (TMDB cache, batched lookups, request priority)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 869, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/869", + "title": "perf(requests): look up Radarr/Sonarr links for the whole page in one request", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 868, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/868", + "title": "perf(reviews): fetch rating chips for a whole page in one request", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 867, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/867", + "title": "🐛[BUG] Issues on 12.1", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 866, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/866", + "title": "perf(people-tags): look up the whole cast in one request and share it across episodes", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 865, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/865", + "title": "perf(api): cap JE at 4 requests and queue decorative lookups at low priority", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 864, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/864", + "title": "perf(seerr): load provider icons only for on-screen cards, in one batched request", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 863, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/863", + "title": "perf(tmdb): cache TMDB responses on the server and in the browser", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 862, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/862", + "title": "fix(custom-icons): match Segment Editor's real dashboard link", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 861, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/861", + "title": "🐛[BUG] rejects local container url", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 860, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/860", + "title": "feat(jellyseerr-search): support \"Title (YYYY)\" year filtering in search", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 859, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/859", + "title": "🐛[BUG] Search icon triggers nav drawer to open on first click (new v12 UI only)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 858, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/858", + "title": "Add option to exclude library items from Seerr search results", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 857, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/857", + "title": "➕[Feature Request] Add Age Ratings (FSK / MPAA) as poster overlays", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 856, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/856", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 855, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/855", + "title": "fix(tags): select stream-bearing episodes consistently for series tags", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 854, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/854", + "title": "fix(branding): serve uploaded SVG images with the correct content type", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 853, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/853", + "title": "🐛[BUG]", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 852, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/852", + "title": "Icon Transparent / custom branding doesn't apply on Jellyfin 12.x (header logo is no longer an element)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 851, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/851", + "title": "fix(custom-icons): serve the Moonbase icon at the size it is drawn", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 850, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/850", + "title": "➕[Feature Request] Notification sending functionality", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 849, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/849", + "title": "➕[Feature Request] Native functionality such as Custom Tabs", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 848, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/848", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 847, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/847", + "title": "chore(ci)(deps): bump the github-actions group with 4 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 846, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/846", + "title": "[BUG] Episode air-date badge ignores the series display order (Primal)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 845, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/845", + "title": "fix: defer inactive Home tabs and stop stale Activity polling", + "state": "closed", + "triage": "existing-related-assertions", + "note": "pages-activity stopped-pending-load test asserts no restarted polling; page-visibility helpers covered elsewhere; all tab integrations not proven." + }, + { + "number": 844, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/844", + "title": "fix: keep JE header shortcuts on a single row", + "state": "closed", + "triage": "existing-related-assertions", + "note": "bootstrap-header tests assert overflow dispatch and preserved foreign nodes using injected geometry; no actual narrow-header visual reversal." + }, + { + "number": 843, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/843", + "title": "fix(details): preserve IETF languages for selected versions", + "state": "closed", + "triage": "not-assessed", + "note": "features-media-info tests region projection without selecting a version, and separately test selected-source fallback. They do not combine regional metadata with a selected source: the exact reported regression is unproven." + }, + { + "number": 842, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/842", + "title": "feat(custom-icons): add Moonbase to supported plugins", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 841, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/841", + "title": "feat(reviews): optional Jellyfin rating mirror and admin list-all endpoint", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 840, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/840", + "title": "🐛[BUG] Active Sessions button not visible during playback in Jellyfin 12", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 839, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/839", + "title": "feat(random-button): roulette spin over on-page cards + autoplay of the pick", + "state": "open", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 838, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/838", + "title": "[BUG] Details-page audio language shows zxx instead of No dialogue", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 837, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/837", + "title": "🐛[BUG] Continue Watching Issue Regression", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 836, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/836", + "title": "🐛[BUG] Incorrect Australia Rating Colours", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 835, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/835", + "title": "🐛[BUG] Global Shortcuts work, but Player Shortcuts don't work", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 834, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/834", + "title": "➕[Feature Request] Support for SeerrNG (fork of Seerr with added books and music functionality)", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 833, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/833", + "title": "[Bug] Activity feed requests missing optional user avatars and produces repeated HTTP 404 errors", + "state": "closed", + "triage": "not-assessed", + "note": "Activity render tests do not establish zero avatar requests for every missing-image DTO; reproduce dedicated report before claiming caught." + }, + { + "number": 832, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/832", + "title": "chore(ci)(deps): bump the github-actions group with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 831, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/831", + "title": "🐛[BUG] Home nav bar splits into multiple rows rather than scrolling horizontally", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 830, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/830", + "title": "➕[Feature Request] independence of google fonts?", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 829, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/829", + "title": "🐛[BUG] Performance improvements", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 828, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/828", + "title": "fix: continue watching/next up removal not closing", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 827, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/827", + "title": "🐛[BUG] Remove from Continue Watching succeeds but leaves the action sheet open", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 826, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/826", + "title": "➕[Feature Request] Spoiler guard toggle next up image replacement", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 825, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/825", + "title": "fix(cdn): continue asset refresh after request timeouts", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 824, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/824", + "title": "fix(analytics): refresh reports after build or host changes", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 823, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/823", + "title": "🐛[BUG] Refresh CDN Assets is cancelled when one upstream host times out (FetchAsync re-throws its own HttpClient timeout)", + "state": "closed", + "triage": "backend-proof-see-coordinating-audit", + "note": "Selected fault detection demonstrated; see history-audit.md for exact test, prior-suite versus new-test distinction, and partial/backend-only limitations." + }, + { + "number": 822, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/822", + "title": "➕[Feature Request] Per-user audio language content filtering", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 821, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/821", + "title": "feat(custom-icons): add Jellyfin Helper to supported plugins", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 820, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/820", + "title": "➕[Feature Request] Support for 576p quality tags", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 819, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/819", + "title": "Details page: file size and audio languages follow the selected version", + "state": "closed", + "triage": "existing-related-assertions", + "note": "features-media-info tests cover per-source filesize/audio and late user responses; not a historical reversal." + }, + { + "number": 818, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/818", + "title": "Native Watchlist Tab", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 817, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/817", + "title": "🐛[BUG] media quality tags do not show up on all media (Fixed)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 816, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/816", + "title": "🐛[BUG] Won't let me use Jellyfin default subtitles settings", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 815, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/815", + "title": "🐛[BUG] Jellyfin-12 not working correctly", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 814, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/814", + "title": "fix(activity): reduce feed query cost and protect history writes", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 813, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/813", + "title": "Correcting and improving my Catalan activity icons tl", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 812, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/812", + "title": "Revert #798: restore protected request avatar proxying", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 811, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/811", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 810, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/810", + "title": "fix(custom tabs): defer tab-hosted content until its tab is active", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 809, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/809", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 808, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/808", + "title": "chore(ci)(deps): bump the github-actions group with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 807, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/807", + "title": "feat(jellyseerr): seamless scrolling and users' parental ratings on Seerr surfaces", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 806, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/806", + "title": "➕[Feature Request] Add a media tag for HDR content", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 805, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/805", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 804, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/804", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 803, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/803", + "title": "fix(language tags): show a \"no dialogue\" badge for zxx instead of no flag", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 802, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/802", + "title": "🐛[BUG] Awards Banner material icon not rendering.", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 801, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/801", + "title": "➕ [Feature Request] Enable guard per library or content type", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 800, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/800", + "title": "docs: unify manifest URLs and validate legacy releases", + "state": "closed", + "triage": "not-selected-documentation", + "note": "Documentation scope; runtime regression not established." + }, + { + "number": 799, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/799", + "title": "feat: deduplicate plugin revisions and expose build/host diagnostics", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 798, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/798", + "title": "fix Jellyseerr avatars for locally-created users (400 from proxy/avatar)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 797, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/797", + "title": "🐛[BUG] Tags show even when disabled", + "state": "closed", + "triage": "existing-related-assertions", + "note": "bootstrap-settings-panel asserts quality disabling removes overlays; core renderer disabled contract tested; original mixed server/user settings incident not reproduced." + }, + { + "number": 796, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/796", + "title": "chore(ci)(deps): bump the github-actions group with 4 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 795, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/795", + "title": "fix: search page removeChild crash — stop mutating React-owned no-results message", + "state": "closed", + "triage": "not-assessed", + "note": "No React reconciliation harness in current tests; DOM-only search tests cannot prove this React-owned text-node crash would be caught." + }, + { + "number": 794, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/794", + "title": "➕[Feature Request] Random button for a limited set of movies/TV shows", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 793, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/793", + "title": "🐛[BUG] Letterboxd text and icon gone after enabling Metadata Icons (Druidblack)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 792, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/792", + "title": "fix: make Seerr search React-safe and responsive", + "state": "closed", + "triage": "not-assessed", + "note": "Alternative proposed search fix; PR795 documents feature loss despite crash avoidance. Do not equate closed PR with accepted fix." + }, + { + "number": 791, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/791", + "title": "Add opt-in community usage analytics", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 790, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/790", + "title": "fix(tags): preserve Matroska LanguageBCP47 regions", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 789, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/789", + "title": "🐛[BUG] Language Tags lose Matroska LanguageIETF/BCP-47 regional variants before flag resolution", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 787, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/787", + "title": "🐛[BUG] Possible issue with search + \"Media Tags\" on searched items", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 786, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/786", + "title": "🐛[BUG] \"Media Tags\" show on playlist covers", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 785, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/785", + "title": "➕[Feature Request] Hide child collections", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 784, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/784", + "title": "➕[Feature Request] Changing order of Jellyfin vs Seerr results when searching", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 783, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/783", + "title": "tag-cache: no-store forces a full 3 MiB re-download on every page load — drop NoStore, add a body-hash ETag/304", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 782, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/782", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 781, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/781", + "title": "The client rewrites an unchanged `settings.json` on every item-detail view (reviews `toggle` write-back), and the `isAdmin` write-back can never converge because `UserSettings` has no `IsAdmin` property", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 780, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/780", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 779, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/779", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 778, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/778", + "title": "feat: enable quality tags in Home Videos and Photos libraries", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 777, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/777", + "title": "chore(ci)(deps): bump the github-actions group with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 776, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/776", + "title": "Anime episodes from Shoko in calendar", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 775, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/775", + "title": "➕[Feature Request] Allow Quality Tags in Home Videos and Photos Library", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 774, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/774", + "title": "🐛[BUG] Bookmark pins appear in the timeline of all episodes of the show", + "state": "closed", + "triage": "existing-test-proven", + "note": "Existing episode provider fallback test detects removal of the episode/season filtering hunk from 9acc883b. Does not prove actual player timeline/backfill end to end." + }, + { + "number": 773, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/773", + "title": "🐛[BUG] Seerr integration search is crashing client", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 772, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/772", + "title": "🐛[BUG] Movie version selector showing \"undefined\" with Spoiler Guard enabled", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 771, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/771", + "title": "🐛[BUG] Subtitles location is higher than in the settings", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 770, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/770", + "title": "➕[Feature Request] Ability to filter by All | Movies | Series in the Studios and Networks pages from Seerr Recommendations", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 769, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/769", + "title": "➕[Feature Request] Custom subtitle fonts & subtitle outline/drop shadow", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 768, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/768", + "title": "➕[Feature Request] Root Folder Size Preview", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 767, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/767", + "title": "🐛[BUG] Jellyfin Enhanced blocks Seerr requests when REQUEST_ADVANCED permission is missing", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 766, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/766", + "title": "🐛[BUG] Subtitle backgrounds overlap", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 765, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/765", + "title": "🐛[BUG] Ultra-wide 1080p files show a 4K Quality Tag", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 764, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/764", + "title": "chore(ci)(deps): bump the github-actions group with 5 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 763, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/763", + "title": "➕[Feature Request] text size change", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 762, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/762", + "title": "feat(spoiler-guard): advanced per-category reveals for the next unwatched episode and current season", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 761, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/761", + "title": "fix: honour \"Remove from Continue Watching\" on Home Screen Sections rows", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 760, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/760", + "title": "➕[Feature Request] Language Flag Filters", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 759, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/759", + "title": "➕[Feature Request] Discuss with SeerrFin team to merge / integrate their changes into Jellyfin-Enhanced", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 758, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/758", + "title": "🐛[BUG] disable custom subtitles renders black bar behind subtitles on ios app", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 756, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/756", + "title": "🐛[BUG] UI elements not appearing", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 755, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/755", + "title": "🐛[BUG] Recommendations Seerr page remains over the Home screen after navigating back", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 754, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/754", + "title": "Requests settings page appears under Plugin Settings when \"Use Plugin Pages for Requests\" is enabled", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 753, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/753", + "title": "chore(ci)(deps): bump the github-actions group with 4 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 752, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/752", + "title": "➕ have an option to have full list in Elsewhere, and/or adding ones not on the list", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 750, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/750", + "title": "🐛[BUG] Element removed from continue watching reappear after refresh", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 749, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/749", + "title": "Fix German FSK rating colors", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 748, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/748", + "title": "Add official German FSK colors and normalization", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 747, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/747", + "title": "🐛[BUG] Reviews not visible on Jellyfin Android 2.6.3", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 746, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/746", + "title": "🐛[BUG] Recommendations page shows watchlistpage instead", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 744, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/744", + "title": "feat(player): auto-skip intros/outros from Jellyfin's media segments", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 743, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/743", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 742, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/742", + "title": "Player shortcuts without opening panels (API-driven track cycling, aspect ratio, playback info, segment skip)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 741, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/741", + "title": "🐛[BUG] Incorrect subtitle positioning and oversized subtitle background", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 740, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/740", + "title": "fix: re-scope all per-user state when switching users without a page reload", + "state": "closed", + "triage": "existing-related-assertions", + "note": "Bootstrap/session/bookmark/hidden/Seerr cache tests exercise account switching and stale replies; no complete reversal of this multi-feature PR." + }, + { + "number": 739, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/739", + "title": "fix(tags): preserve explicit language regions in flag resolution (pt-BR, es-419, zh-Hant…)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 738, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/738", + "title": "fix(player): harden OSD track cycling (waits for the menu, stops matching English UI text)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 737, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/737", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 736, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/736", + "title": "➕[Feature Request] Change Spoiler Guard Filters For \"Next Episode.\"", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 734, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/734", + "title": "chore(ci)(deps): bump actions/stale from 10.4.0 to 11.0.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 733, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/733", + "title": "chore(ci)(deps): bump the github-actions group across 1 directory with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 732, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/732", + "title": "🐛[BUG] 8K vidoes showing as 1080p in quality tag", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 731, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/731", + "title": "➕[Feature Request] Download subtitle files to pc", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 730, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/730", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 729, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/729", + "title": "fix(i18n): validate {{icon:xxx}} placeholders and fix Norwegian typo", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 728, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/728", + "title": "Translations update from Hosted Weblate", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 727, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/727", + "title": "feat(i18n): add Finnish and Ukrainian locales, sync pending Weblate translations", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 726, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/726", + "title": "🐛[BUG] New translations are not added to repo from Weblate", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 723, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/723", + "title": "refactor: modularize the client by feature", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 722, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/722", + "title": "feat(panel): searchable section navigation for the user settings panel", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 721, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/721", + "title": "fix: Seerr results row cannot be touch-scrolled on phones", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 720, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/720", + "title": "chore(ci)(deps): bump the github-actions group with 6 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 719, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/719", + "title": "cant find custom branding on jellyfin 10.11.11", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 718, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/718", + "title": "🐛[BUG] Preventing user accounts from being disabled during maintenance mode does not work", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 717, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/717", + "title": "➕[Feature Request] Improve Download Status Tracking and Add Download History for Request Page", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 716, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/716", + "title": "🐛[BUG] Auto-Skip doesn't trigger during media segment.", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 715, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/715", + "title": "🐛[BUG] - Rotten Tomatoes rating tag adds a zero if rating is a single digit", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 714, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/714", + "title": "🐛[BUG]UI settings overlap", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 713, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/713", + "title": "fix(tags): memory-bounded tag cache builds + honor Server-Side Tag Cache setting (startup OOM crash loop)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 712, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/712", + "title": "chore(ci)(deps): bump actions/setup-dotnet from 5.4.0 to 6.0.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 711, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/711", + "title": "chore(ci)(deps): bump actions/setup-node from 6.4.0 to 7.0.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 710, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/710", + "title": "chore(ci)(deps): bump actions/setup-python from 6.3.0 to 7.0.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 709, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/709", + "title": "chore(ci)(deps): bump the github-actions group with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 708, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/708", + "title": "Startup tag-cache full build OOM-kills the server on very large libraries; TagCacheServerMode=false does not prevent it", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 707, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/707", + "title": "➕[Feature Request] include seerr in *arr UI Links", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 706, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/706", + "title": "🐛[BUG] Seerr won't scroll in search", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 705, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/705", + "title": "🐛[BUG] \"Cycle Subtitle Tracks\" keyboard shortcut doesn't work", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 704, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/704", + "title": "fix(tag-cache): stop re-saving the whole cache on no-op library events", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 703, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/703", + "title": "chore(ci)(deps): bump the github-actions group with 6 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 702, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/702", + "title": "➕[Feature Request] Shortcut for \"Start Now\" button at episode end", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 701, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/701", + "title": "➕[Feature Request] Disable certain hotkeys", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 700, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/700", + "title": "feat(spoiler-guard): per-user image identity tags for native clients", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 699, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/699", + "title": "Bump SkiaSharp from 3.116.1 to 4.150.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 698, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/698", + "title": "➕[Feature Request] Automatically translate TMDb reviews to the user's Jellyfin language", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 697, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/697", + "title": "Bump SkiaSharp from 3.116.1 to 4.148.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 696, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/696", + "title": "chore(ci)(deps): bump the github-actions group with 4 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 695, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/695", + "title": "🐛[BUG] When Seerr integration is enabled, a request to search endpoint is never stopped once you search anything", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 694, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/694", + "title": "Fix MutationObserver leak in addUserPreferencesLink (one observer piled up per navigation)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 693, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/693", + "title": "🐛[BUG] MutationObservers from addUserPreferencesLink pile up on every navigation, UI gets slower over time", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 692, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/692", + "title": "feat: serve third-party assets from a local CDN cache (+ off-thread tag-cache updates)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 691, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/691", + "title": "feat(spoiler-guard): per-user opt-in spoiler protection for unwatched content (Jellyfin 10.11 + 12)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 690, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/690", + "title": "🐛[BUG] Auto-Skip ignores IntroSkipper offsets", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 689, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/689", + "title": "➕[Feature Request] Make Sonarr configuration optional for the Requests Page", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 688, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/688", + "title": "➕[Feature Request] - Maintenance Mode Reminders", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 687, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/687", + "title": "➕[Feature Request] - Maintenance Mode Home Page Banner", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 686, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/686", + "title": "➕[Feature Request] - Maintenance Mode Automations", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 685, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/685", + "title": "feat: reliable Remove from Continue Watching + add Remove from Next Up (multi-select & confirm)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 684, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/684", + "title": "Admin visibility and management of user-hidden content + bug fixes", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 683, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/683", + "title": "chore(ci)(deps): bump actions/cache from 5.0.5 to 6.1.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 682, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/682", + "title": "chore(ci)(deps): bump the github-actions group with 2 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 681, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/681", + "title": "feat: Jellyfin 12 compatibility", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 680, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/680", + "title": "➕[Feature Request] Add Leterboxd link for people", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 679, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/679", + "title": "🐛[BUG] Jellyseer Request integration tab using Custom Tab shows up blank", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 678, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/678", + "title": "🐛[BUG] Random Dice does not go away when Disabled", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 677, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/677", + "title": "🐛[BUG] v12 - Random Button and Active Sessions button are not added", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 676, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/676", + "title": "🐛[BUG] Request More button dissapeared", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 675, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/675", + "title": "feat(seerr): Jellyfin 12 compatibility for the Seerr integration", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 674, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/674", + "title": "Merge main", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 673, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/673", + "title": "feat: Jellyfin 12 (.NET 10) support; drop the File Transformation dependency", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 672, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/672", + "title": "feat: Jellyfin 12 (.NET 10) compatibility", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 671, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/671", + "title": "chore(ci)(deps): bump actions/checkout from 6.0.3 to 7.0.0", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 670, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/670", + "title": "chore(ci)(deps): bump trufflesecurity/trufflehog from 3.95.5 to 3.95.6 in the github-actions group", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 669, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/669", + "title": "➕[Feature Request] See what content users have hidden.", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 668, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/668", + "title": "docs: API overhaul", + "state": "closed", + "triage": "not-selected-documentation", + "note": "Documentation scope; runtime regression not established." + }, + { + "number": 667, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/667", + "title": "🐛[BUG] Seerr integration broken when EnableLegacyAuthorization=false (Jellyfin 10.11+ / all of 10.12+)", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 666, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/666", + "title": "docs: Formatting fixes", + "state": "closed", + "triage": "not-selected-documentation", + "note": "Documentation scope; runtime regression not established." + }, + { + "number": 665, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/665", + "title": "➕[Feature Request] Reclaimerr Integration: Delete & Protection Request Support", + "state": "open", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 664, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/664", + "title": "README: fix: Seerr URL", + "state": "closed", + "triage": "not-selected-documentation", + "note": "Documentation scope; runtime regression not established." + }, + { + "number": 663, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/663", + "title": "🐛[BUG] Enable \"Remove from Continue Watching\" doesn't show the option on applicable items", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 662, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/662", + "title": "🐛[BUG] Android mobile app shows blank", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 661, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/661", + "title": "➕[Feature Request] Digital Release /Airing Date display option", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 660, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/660", + "title": "➕[Feature Request] Quality Tag reload or reset", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 659, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/659", + "title": "🐛[BUG] Audio channel tag shows `2.0`/nothing instead of `5.1` when stereo is the first/default audio stream", + "state": "closed", + "triage": "new-test-proven", + "note": "Real quality rendering test now covers stereo-first order; precise historical getChannelTag function restored and detected." + }, + { + "number": 658, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658", + "title": "fix: duplicate approval notifications and un-approvable pending requests on available shows", + "state": "closed", + "triage": "new-test-proven", + "note": "Both actual rendering defects were missing in existing browser unit tests; added pending-season and repeated-render tests; historical predicate/faithful unguarded binding faults killed." + }, + { + "number": 657, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/657", + "title": "🐛 [BUG] Back button must be pressed three times to navigate back", + "state": "closed", + "triage": "partial-related-proof", + "note": "New PR658 test proves one showItem dispatch after repeated renders; actual browser history/back button report not reproduced." + }, + { + "number": 656, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/656", + "title": "[Clarification] JF-Seerr Watchlist synch", + "state": "open", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 655, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/655", + "title": "🐛[BUG] New TV show season unable to be downloaded: marked as available", + "state": "closed", + "triage": "not-assessed", + "note": "Candidate behavior/change identified by title and available body excerpt; no test sensitivity claim. Full comments/patch/reproduction mapping remains." + }, + { + "number": 654, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/654", + "title": "➕[Feature Request] Add Awards to Item Details pages", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 653, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/653", + "title": "➕[Feature Request] Request series with no auto match", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 652, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/652", + "title": "➕[Feature Request] Remove from next up", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + }, + { + "number": 651, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/651", + "title": "chore(ci)(deps): bump the github-actions group with 3 updates", + "state": "closed", + "triage": "not-selected-maintenance", + "note": "Dependency/translation maintenance; no specific JE behavioral regression established in this triage." + }, + { + "number": 650, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/650", + "title": "➕[Feature Request] Approve requests on jellyfin", + "state": "closed", + "triage": "not-selected-feature-request", + "note": "Requested functionality; not treated as a confirmed historical regression. Implemented behavior still belongs in general coverage inventory." + } + ] +} diff --git a/tests/docs/history-recent.md b/tests/docs/history-recent.md new file mode 100644 index 000000000..821a7d86d --- /dev/null +++ b/tests/docs/history-recent.md @@ -0,0 +1,45 @@ +# Recent JE historical-regression audit + +This pass triaged **253 public issue/PR records numbered 650–910** from the fetched snapshot. [Per-record triage](history-recent-triage.json) distinguishes title/body-excerpt screening from detailed source/test assessment. It is **not** a claim that 253 bug reproductions were validated. Dependency, translation, documentation and feature requests are not automatically historical bugs; open/closed state is not evidence of correctness. Comments, screenshots, client-specific environments, and full patches were not exhaustively examined. + +## Proven findings + +| Report | Existing suite assessment | Added or existing assertion | Sensitivity evidence | +|---|---|---|---| +| [PR 658: repeated approvals](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658) | Existing `pages-downloads` approval test substitutes `renderPage`; it cannot exercise accumulating DOM listeners. | New real cards/page render test rerenders the same container five times; one click must dispatch one approval, decline or navigation. Also covers three custom-container renders. | A faithful historical fault removes the bind-once condition. Baseline passes; mutant fails with duplicate action dispatch; restored passes. | +| [PR 658: pending season on available show](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658) | No existing real request-card assertion covered this status combination. | New real card/page rendering checks pending request actions for Partially Available, Available and Pending media; approved/declined/nonprivileged cases have no actions. | Replaced the predicate with the exact pre-`d76a5847` media-status predicate, in its now-split file. Baseline passes; mutant fails; restored passes. | +| [Issue 659: stereo first suppresses surround badge](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/659) | Existing shared renderer tests use a synthetic renderer; neither these nor single-track fixtures establish stream-order behavior. Poster parity is a separate suite and is not claimed to survive this fault. | New production quality renderer plus shared DOM overlay tests two stream orders for 5.1 and two for 7.1, asserting real badge values. | Restored the complete historical `getChannelTag` function from `4fc376fb^`, adjusting indentation only. Baseline passes; mutant fails; restored passes. | +| [Issue 774: bookmark pins leak across episodes](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/774) | **Existing test catches the central matching defect.** | `episode provider fallback excludes other episodes and seasons but retains legacy records` in `features-bookmarks.test.mjs`. | Exact removal of the matching guard introduced by `9acc883b`: baseline passes; mutant fails with extra episode/season matches; restored passes. This does not prove player-timeline integration, metadata backfill, or every changed hunk. | + +The **pre-audit frontend suite was also run against each of the three new-test faults** in a disposable copy: all 33 pre-existing test files (323 tests) still passed for all three mutants. The new tests therefore close demonstrated frontend-suite blind spots. Reports are in `artifacts/history-audit/recent-prior-suite/summary.json`; this experiment does not claim that the separate backend, poster-parity or real-host suites would also survive. + +Tests call real production modules. Request rendering substitutes unrelated avatar/link hydration and captures the action/navigation boundary; it does not simulate real Seerr notifications or prove its backend emits one notification. No production code needed changing for these cases. + +## Repeatable verification + +```sh +node --test tests/frontend/regressions/history-recent-regressions.test.mjs +python3 tests/runner/mutations.py --cases tests/history/recent-mutations.json --layer frontend --artifacts artifacts/history-audit/recent-mutations +``` + +Results: **3 new tests passed; 4 mutation cases each passed baseline, failed at an expected assertion, and passed after restoration.** Mutation evidence is in `artifacts/history-audit/recent-mutations/summary.json` with per-stage logs. All faults were applied in disposable source copies; live production files were untouched. Historical provenance is stored in the version-controlled case manifest. + +## Related assertions are not proof of catching the historical bug + +- [PR 740](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/740): extensive session/bootstrap/cache race tests exist, but no claim of reversing every changed module. +- [PR 901](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/901): existing native-tab tests assert placeholders, late claims, index rewriting and user choice. This is not the full real Custom Tabs/Jellyfin layout matrix. +- [PRs 895](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/895) and [904](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/904): stylesheet assertions cover public/full versus private/subset font definitions. They do not prove all feature-injected styles or actual glyph rendering under each theme. +- [PR 845](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/845): the Activity pending-load cleanup test directly covers stale polling restart, but not every inactive tab integration. +- [Issue 657](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/657): the new repeated-render test proves one navigation dispatch, not the reported browser back-stack behavior on a real host. + +## Important remaining candidates + +The JSON records **141 not-assessed candidates**, **2 environment gaps**, **8 related-assertion mappings without historical proof**, and **7 backend records with selected proof in the [coordinating audit](history-audit.md)**. Other counts are 45 feature requests, 41 maintenance records, 5 documentation records, 3 issue/PR records with proven tests (four defects), and one partial navigation mapping. These classifications describe this pass, not global JE completeness. + +- [Issue 899](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/899): Moonfin button chosen as the header host. Existing foreign-button fixtures do not reproduce the reported nesting/click-through structure. +- [PR 795](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/795): React-owned text-node replacement. A DOM-only assertion is not enough to establish React reconciliation safety. The alternative [PR 792](https://github.com/n00bcodr/Jellyfin-Enhanced/pull/792) is an example of eliminating a crash by inadvertently stopping the feature; closed proposed fixes must not be treated as accepted fixes. +- [Issue 833](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/833): optional avatar 404s need a request-count test for absent image metadata. +- [Issue 910](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/910): actual Edge/Windows Picture-in-Picture video recovery needs real media, user activation and visibility transitions. jsdom or mobile viewport emulation cannot establish this. +- [Issue 893](https://github.com/n00bcodr/Jellyfin-Enhanced/issues/893): mobile/webOS Seerr-login takeover is not reproduced in the current native Linux Chromium host smoke suite. + +Remaining reports include tag-cache memory/disk lifecycle, subtitle layout and disablement, player skip/track-selection behavior, library-version compatibility, and admin configuration. They remain explicit audit work, not implicitly covered because a similarly named feature has tests. diff --git a/tests/docs/maintenance-recovery.md b/tests/docs/maintenance-recovery.md new file mode 100644 index 000000000..7cea92bf2 --- /dev/null +++ b/tests/docs/maintenance-recovery.md @@ -0,0 +1,9 @@ +# Maintenance recovery regression contract + +`tests/backend/Core/CoreMaintenanceTests.cs` exercises the actual maintenance service against a disposable state directory and a mocked Jellyfin policy store. Policy copies model independent persisted reads, so a failed update cannot accidentally mutate the test store. + +The suite verifies that administrators and previously restricted users keep their original access, only successful maintenance changes enter restoration lists, and failed restores remain pending across restart. Each successfully restored user is removed from the journal; retries do not change that user's later administrator-set restrictions. Reconciliation becomes inactive while restoration is pending, and cannot replace unresolved restoration intent with a new selection. Null persisted lists are normalized. Failed journal writes are observable, use an atomic replacement, and do not silently change the cached state. + +These tests do **not** establish a transaction across Jellyfin's policy database and JE's journal file. A process failure between a committed policy update and its journal checkpoint, a policy backend that commits and then throws, or administrator changes to a still-pending user's same permission remain ambiguous. The two stores do not expose a shared transaction or policy revision check. Recovery retries are covered for policy operations that fail before committing and for successful checkpoints; crash-exactly-between-stores and after-commit exceptions remain explicit limitations. + +Real Jellyfin host tests separately exercise loading and HTTP behavior. Maintenance policy failure injection here uses mocked host interfaces rather than a real database outage. diff --git a/tests/docs/mutations.md b/tests/docs/mutations.md new file mode 100644 index 000000000..6f37042d3 --- /dev/null +++ b/tests/docs/mutations.md @@ -0,0 +1,25 @@ +# Targeted mutation verification + +Historical issue/PR cases are also available via `python3 tests/run.py history`. See the [audit](history-audit.md). To run a specific case group, pass `--cases tests/history/backend-mutations.json` (or the early/middle/recent manifest) to this runner. Manifests use relative checkout paths and identify source provenance and the selected test. Absolute paths, parent traversal, empty case lists and empty layer selections are rejected. + +Run from any working directory: + +```sh +python3 tests/runner/mutations.py --target jf12 +python3 tests/runner/mutations.py --target jf10 --layer backend --artifacts artifacts/mutations-jf10 +``` + +Prerequisites are the .NET SDK/runtime for the selected target (jf12: .NET 10; jf10: .NET 9), Node 22.12 or newer, and `npm ci`. Install current servicing patches for both Microsoft.NETCore.App and Microsoft.AspNetCore.App: a test host built against a newer patch cannot run on an older runtime. A portable installation works by setting PATH and DOTNET_ROOT to its directory. NuGet packages must be cached or available for restore. Use `--layer frontend` for the JavaScript-only checks. The default report location is `artifacts/mutations`; use distinct artifact directories to retain multiple target reports. + +The runner copies the current checkout, including uncommitted production files and tests, into a disposable directory managed by Python. It excludes Git metadata, dependencies and build artifacts; JavaScript dependencies are linked read-only by convention and are never installed or modified by the runner. The actual mutations and builds happen exclusively inside the copied tree. It neither commits nor changes production files in the original checkout. Temporary directories are removed on normal completion and exceptions. + +For each case the selected test must first pass. The runner then introduces exactly one production fault, requires a failing test with the expected assertion, restores the original copied source, and requires the same test to pass again. A compilation failure, missing test, setup failure, or surviving mutation fails verification. Every command, exit status and log is retained; backend TRX reports are retained too. `summary.json` records the baseline, mutant, and restored outcomes. A nonzero runner exit means verification was not completed successfully. + +| Production fault | Regression assertion | +| --- | --- | +| Allow arbitrary explicit user selection | A regular user cannot select another user's identity | +| Allow blocked tags to override parental restrictions | A blocked keyword/genre still denies access when an allowed keyword matches | +| Ignore the cookie-miss negative cache | Repeated forged-cookie requests perform exactly two session scans | +| Accept a hidden-content response from a previous user session | A late response cannot overwrite the incoming user's preferences | + +These deliberately selected mutations demonstrate sensitivity of privacy, authorization, parental-policy, and resource-use regressions. They are not an exhaustive mutation score and do not imply that all tests or production branches detect every possible fault. Mutation anchors are checked for exactly one occurrence, so production refactors require explicit review and updates rather than silently testing a different path. diff --git a/tests/docs/perf-round2.md b/tests/docs/perf-round2.md new file mode 100644 index 000000000..ccd5069da --- /dev/null +++ b/tests/docs/perf-round2.md @@ -0,0 +1,84 @@ +# perf/round2 regression comparison — 2026-10-05 + +**One newly introduced regression was reproduced.** The branch was tested at `6fcc633e` against its merge base `8b8a5baa`, using the same local regression tests in separate disposable source snapshots. Neither snapshot received the local production bug fixes from the regression worktree. The original `perf/round2` worktree was clean and untouched; nothing was committed or pushed. + +## Confirmed regression: discovery retry skips titles + +`js/jellyseerr/discovery/discovery-base.js`, `renderChunkInSlices`, lines 514–519 on the tested branch advances `renderedCount` and `hasMorePages` **before** awaiting card construction/appending. If card construction rejects, the next attempt starts after the failed chunk. The old synchronous path advances its cursor only after constructing and appending the fragment successfully. + +Reproduction: six person-discovery results, chunks of two. Render `[1,2]`, inject one transient failure while building the next card, then retry. The base renders `[1,2,3,4]`; the branch renders `[1,2,5,6]`. Titles 3 and 4 are skipped. This is a recovery-path regression, not evidence that ordinary successful rendering fails. A failure in the last chunk can also leave the pagination state exhausted by the same mechanism; that variant was inspected, not separately executed. + +The new [behavioral test](../../tests/frontend/regressions/perf-round2-discovery.test.mjs) executes the real discovery/filter/rendering modules and drives the scroll callback explicitly. The card factory throws once to make the failure deterministic. It passes on the base and local regression worktree, and fails on the candidate with the expected missing IDs. Evidence: `artifacts/perf-round2/{base,candidate}/discovery-recovery.txt`. + +Suggested correction: update pagination only for successfully appended results, with explicit handling of partially appended slices and aborted navigation. No production correction was applied as part of this comparison. + +## Results + +| Check | Base | perf/round2 | Assessment | +| --- | --- | --- | --- | +| Existing backend tests, jf10 | 654 passed / 34 failed | 654 passed / 34 failed | Same 34 test identities fail; zero skipped/unexecuted. | +| Existing backend tests, jf12 | 654 passed / 34 failed | 654 passed / 34 failed | Same 34 failures; no new failure in the existing backend suite. | +| Added tag-cache checks, each target | 5 passed | 5 passed | Full-build/on-demand representative and language parity; actual MVC JSON execution, ETag/304, user isolation and access invalidation. | +| Browser component/visual tests | 39 passed | 39 passed | Chromium desktop/mobile and Firefox; unchanged visual baselines. | +| Native poster pipeline | 140 passed | 140 passed | Actual production pipeline contracts. | +| Native assets/shaping | Passed both Skia lines | Passed both Skia lines | 253 flags per line plus HarfBuzz checks. | +| Poster resolver/profile comparison and casing | Passed | Passed | 40 synthetic profiles and 31,676 casing comparisons. | +| Strict language-name oracle | 51 mismatches | Same 51 mismatches | Preexisting Baku variant aliases; runner correctly exits nonzero. | +| Real-host API/browser smoke | Not repeated in this comparison | Both 10.11.11 and 12.0 passed | 45 HTTP checks plus regular/admin Chromium login, panel save/reload, restart persistence per host. | +| New discovery retry test | Passed | Failed | Confirmed branch-introduced regression described above. | + +### Frontend failures and fixture corrections + +The initial 332-test frontend attempt reported **275 passed / 56 failed / one hung test** on base and **273 passed / 58 failed / the same hung test** on candidate. Each file had a 30-second process timeout; the hanging bookmark test was then isolated and independently timed out after 10 seconds on both commits. It is not a new branch failure. It must not be counted as passed or skipped. + +Three apparent candidate-only failures were test-fixture defects: Episode objects contained `SeriesProviderIds` but no real `SeriesId` or parent-item response. The branch deliberately resolves the parent series, matching the actual DTO contract. Correcting those fixtures preserves the visible assertions and the no-external-TMDB-call requirement. All 32 tests in those three files then produced identical results: **31 passed and one existing release-date escaping failure** on each snapshot. The original and corrected logs are retained separately. + +The branch also passes the existing modal-replacement test that fails on base; its captured closing-node change prevents an old close timer removing the successor modal. A separate diagnostic confirms another improvement: awards use a parent series TMDB ID even when the Episode has a different own ID. That diagnostic fails base and passes candidate. Its source is archived under `artifacts/perf-round2/awards-parent-namespace.mjs` rather than adding an intentionally failing test to the local suite without fixing the local production code. + +The local regression worktree, which already contains its separate production fixes, passes **333 frontend tests** after the fixture corrections and discovery-test addition. This local passing result is not the result for `perf/round2`. + +## Harness compatibility and scope + +Production snapshots differ from their Git commits only by the test assembly's `InternalsVisibleTo` entry and lockfile support in the project file. Tests, harnesses, pinned package manifests and lockfiles were overlaid identically. `node_modules` was shared by symlink; production source was not merged from the regression worktree. Snapshot hashes and resolved commit IDs are recorded in `artifacts/perf-round2/checkouts.json`. + +The minimal poster VM lacked the standard browser globals `URL` and `URLSearchParams`, newly used by the branch's navigation logic. Adding those real globals to the harness resolved the setup failure. Rerunning then exposed the same 51 real language-name mismatches as base. No assertions were weakened, snapshots regenerated, or production changes applied to obtain these results. + +Full backend commands, TRX reports, per-file frontend outcomes, corrected fixtures, browser reports, host logs, screenshots and poster comparisons are retained under `artifacts/perf-round2/`. Backend runs used `-p:JellyfinTarget=jf10` and `jf12`, locked restore, separate build output paths, and a 60-second test-hang limit. Host/browser suites used the already documented isolated infrastructure. The local .NET runtime override was `PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet`. + +Representative commands **inside either prepared snapshot**: + +```sh +dotnet test tests/backend/JE.Tests.csproj -c Release -p:JellyfinTarget=jf12 +dotnet test tests/backend/JE.Tests.csproj -c Release -p:JellyfinTarget=jf10 --filter FullyQualifiedName~PerfRound2 +node --test tests/frontend/regressions/perf-round2-discovery.test.mjs +npm run test:browser +python3 tests/host/run.py --target all --browser --artifacts artifacts/host +python3 tests/runner/poster.py --artifacts artifacts/poster +``` + +To recreate just the confirmed discovery comparison from this regression worktree (`npm ci` required), without changing either worktree: + +```sh +python3 - <<'PY' +from pathlib import Path +import shutil, subprocess, tarfile, tempfile +root = Path.cwd() +for ref in ('8b8a5baa', '6fcc633e'): + with tempfile.TemporaryDirectory(prefix='je-discovery-check-') as temp: + work = Path(temp) + with tempfile.TemporaryFile() as archive: + subprocess.run(['git', 'archive', ref], stdout=archive, check=True) + archive.seek(0) + with tarfile.open(fileobj=archive) as tree: + tree.extractall(work, filter='data') + shutil.copytree(root / 'tests/frontend', work / 'tests/frontend') + (work / 'node_modules').symlink_to(root / 'node_modules', target_is_directory=True) + result = subprocess.run(['node', '--test', + 'tests/frontend/regressions/perf-round2-discovery.test.mjs'], cwd=work) + print(ref, 'exit:', result.returncode) +PY +``` + +Expected: base exits 0; candidate exits 1 with the missing-chunk assertion. Temporary source snapshots are deleted after each check. + +This is a behavioral comparison, not a throughput/latency benchmark or proof that all 36 changed files are regression-free. Existing coverage gaps still apply, especially native clients, complete movie-collection batch behavior, live external providers and every discovery/search state combination. The branch is not reported as green: it has inherited failures and the confirmed new recovery defect. diff --git a/tests/docs/production-inventory.json b/tests/docs/production-inventory.json new file mode 100644 index 000000000..0f258f9a5 --- /dev/null +++ b/tests/docs/production-inventory.json @@ -0,0 +1,8814 @@ +{ + "schema_version": 1, + "extraction": "Lexical inventory only; entries do not imply behavioral coverage. Public property types/defaults and route attributes require semantic review.", + "build_targets": [ + { + "selector": "jf12", + "framework": "net10.0", + "jellyfin_reference": "12.0.0" + }, + { + "selector": "jf10", + "framework": "net9.0", + "jellyfin_reference": "10.11.0" + } + ], + "files": [ + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/age-rating-colours.json", + "category": "Assets", + "lines": 1 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/icons.json", + "category": "Assets", + "lines": 1 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/manifest.json", + "category": "Assets", + "lines": 1389 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/quality-colours.json", + "category": "Assets", + "lines": 1 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/vector-icons.json", + "category": "Assets", + "lines": 1 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "category": "Configuration", + "lines": 1287 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "category": "Configuration", + "lines": 401 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "category": "Configuration", + "lines": 938 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.css", + "category": "Configuration", + "lines": 2318 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.html", + "category": "Configuration", + "lines": 10937 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/whats-new-seed.json", + "category": "Configuration", + "lines": 268 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "category": "Controllers", + "lines": 11836 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ActivityPlaybackConsumer.cs", + "category": "EventHandlers", + "lines": 90 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", + "category": "EventHandlers", + "lines": 231 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/MaintenancePlaybackReminder.cs", + "category": "EventHandlers", + "lines": 77 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/SpoilerAutoEnableEvents.cs", + "category": "EventHandlers", + "lines": 141 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/UserTopologyEvents.cs", + "category": "EventHandlers", + "lines": 107 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/ItemRepositoryExtensions.cs", + "category": "Extensions", + "lines": 37 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/JellyfinDbContextExtensions.cs", + "category": "Extensions", + "lines": 69 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/UserManagerExtensions.cs", + "category": "Extensions", + "lines": 62 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs", + "category": "Helpers", + "lines": 139 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/AudioLanguageTagHelper.cs", + "category": "Helpers", + "lines": 156 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/BuildAttestation.cs", + "category": "Helpers", + "lines": 49 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrPermissionHelper.cs", + "category": "Helpers", + "lines": 19 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrUserImportHelper.cs", + "category": "Helpers", + "lines": 100 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalRatingDecision.cs", + "category": "Helpers", + "lines": 63 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalTagDecision.cs", + "category": "Helpers", + "lines": 94 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrCertificationExtractor.cs", + "category": "Helpers", + "lines": 166 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrHttpHelper.cs", + "category": "Helpers", + "lines": 262 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrTagSignatureExtractor.cs", + "category": "Helpers", + "lines": 84 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MatroskaLanguageReader.cs", + "category": "Helpers", + "lines": 548 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MediaStreamLanguageResolver.cs", + "category": "Helpers", + "lines": 96 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ProviderHelper.cs", + "category": "Helpers", + "lines": 78 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ReviewVisibility.cs", + "category": "Helpers", + "lines": 81 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/TagEpisodeSelector.cs", + "category": "Helpers", + "lines": 110 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/UserHelper.cs", + "category": "Helpers", + "lines": 38 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs", + "category": "JellyfinEnhanced.cs", + "lines": 701 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Logger.cs", + "category": "Logger.cs", + "lines": 121 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AnalyticsData.cs", + "category": "Model", + "lines": 113 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrInstance.cs", + "category": "Model", + "lines": 38 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrItem.cs", + "category": "Model", + "lines": 183 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrType.cs", + "category": "Model", + "lines": 9 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AwardsData.cs", + "category": "Model", + "lines": 69 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrPermission.cs", + "category": "Model", + "lines": 35 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrUser.cs", + "category": "Model", + "lines": 15 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/MdblistRatings.cs", + "category": "Model", + "lines": 85 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/TagCacheEntry.cs", + "category": "Model", + "lines": 235 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/WhatsNewData.cs", + "category": "Model", + "lines": 29 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/ActivityPage.html", + "category": "PluginPages", + "lines": 59 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/BookmarksPage.html", + "category": "PluginPages", + "lines": 57 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/CalendarPage.html", + "category": "PluginPages", + "lines": 66 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/DownloadsPage.html", + "category": "PluginPages", + "lines": 134 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/HiddenContentPage.html", + "category": "PluginPages", + "lines": 61 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/RecommendationsPage.html", + "category": "PluginPages", + "lines": 57 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginServiceRegistrator.cs", + "category": "PluginServiceRegistrator.cs", + "lines": 198 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AnalyticsReportTask.cs", + "category": "ScheduledTasks", + "lines": 57 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs", + "category": "ScheduledTasks", + "lines": 315 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AudioLanguageTagsSyncTask.cs", + "category": "ScheduledTasks", + "lines": 123 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/BuildTagCacheTask.cs", + "category": "ScheduledTasks", + "lines": 73 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ClearTranslationCacheTask.cs", + "category": "ScheduledTasks", + "lines": 57 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs", + "category": "ScheduledTasks", + "lines": 374 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrUserImportTask.cs", + "category": "ScheduledTasks", + "lines": 107 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs", + "category": "ScheduledTasks", + "lines": 652 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchRunner.cs", + "category": "ScheduledTasks", + "lines": 157 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchTask.cs", + "category": "ScheduledTasks", + "lines": 82 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsSyncTask.cs", + "category": "ScheduledTasks", + "lines": 181 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/RefreshCdnAssetsTask.cs", + "category": "ScheduledTasks", + "lines": 73 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/SpoilerApplyExistingTitlesTask.cs", + "category": "ScheduledTasks", + "lines": 48 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFavoriteMonitor.cs", + "category": "Services", + "lines": 80 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFeedBuilder.cs", + "category": "Services", + "lines": 237 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityService.cs", + "category": "Services", + "lines": 54 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", + "category": "Services", + "lines": 700 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestMonitor.cs", + "category": "Services", + "lines": 168 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs", + "category": "Services", + "lines": 688 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestMonitor.cs", + "category": "Services", + "lines": 238 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestService.cs", + "category": "Services", + "lines": 716 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/BrandingAssetStartupFilter.cs", + "category": "Services", + "lines": 244 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "category": "Services", + "lines": 704 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs", + "category": "Services", + "lines": 215 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs", + "category": "Services", + "lines": 564 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs", + "category": "Services", + "lines": 85 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/Identity/RequestIdentityService.cs", + "category": "Services", + "lines": 544 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ItemStatsService.cs", + "category": "Services", + "lines": 369 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs", + "category": "Services", + "lines": 625 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceScheduleService.cs", + "category": "Services", + "lines": 159 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs", + "category": "Services", + "lines": 685 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/CompositeImageCache.cs", + "category": "Services", + "lines": 320 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativeClientPolicy.cs", + "category": "Services", + "lines": 84 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativePosterTagStamper.cs", + "category": "Services", + "lines": 247 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagComposer.cs", + "category": "Services", + "lines": 224 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagDataProvider.cs", + "category": "Services", + "lines": 280 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagImageFilter.cs", + "category": "Services", + "lines": 574 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagModel.cs", + "category": "Services", + "lines": 92 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagResolver.cs", + "category": "Services", + "lines": 172 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagReviewRatings.cs", + "category": "Services", + "lines": 180 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs", + "category": "Services", + "lines": 532 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", + "category": "Services", + "lines": 98 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagUserCache.cs", + "category": "Services", + "lines": 49 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs", + "category": "Services", + "lines": 248 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "category": "Services", + "lines": 385 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagCss.cs", + "category": "Services", + "lines": 226 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagImageScaler.cs", + "category": "Services", + "lines": 130 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagPlan.cs", + "category": "Services", + "lines": 381 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagRenderer.cs", + "category": "Services", + "lines": 778 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagText.cs", + "category": "Services", + "lines": 415 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagTileCache.cs", + "category": "Services", + "lines": 227 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/AgeRatingRules.cs", + "category": "Services", + "lines": 94 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/GenreIcons.cs", + "category": "Services", + "lines": 134 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/JsText.cs", + "category": "Services", + "lines": 340 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageFlagRules.cs", + "category": "Services", + "lines": 404 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs", + "category": "Services", + "lines": 298 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/QualityTagRules.cs", + "category": "Services", + "lines": 480 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/RatingRules.cs", + "category": "Services", + "lines": 86 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs", + "category": "Services", + "lines": 4183 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/RadarrService.cs", + "category": "Services", + "lines": 149 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ReviewAuthorCache.cs", + "category": "Services", + "lines": 62 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs", + "category": "Services", + "lines": 177 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrParentalFilter.cs", + "category": "Services", + "lines": 1393 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrScanTriggerService.cs", + "category": "Services", + "lines": 246 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SonarrService.cs", + "category": "Services", + "lines": 147 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageBlurService.cs", + "category": "Services", + "lines": 391 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageTagDecoration.cs", + "category": "Services", + "lines": 140 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableArmer.cs", + "category": "Services", + "lines": 193 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableFilter.cs", + "category": "Services", + "lines": 95 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs", + "category": "Services", + "lines": 1325 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerExistingTitlesApplier.cs", + "category": "Services", + "lines": 491 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerFieldStripFilter.cs", + "category": "Services", + "lines": 1387 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityService.cs", + "category": "Services", + "lines": 209 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityTagFilter.cs", + "category": "Services", + "lines": 333 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerLibraryAddAutoEnabler.cs", + "category": "Services", + "lines": 256 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerNextUnwatchedService.cs", + "category": "Services", + "lines": 236 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs", + "category": "Services", + "lines": 380 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerTagDataStripper.cs", + "category": "Services", + "lines": 581 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerUserResolver.cs", + "category": "Services", + "lines": 322 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/StartupService.cs", + "category": "Services", + "lines": 161 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheMonitor.cs", + "category": "Services", + "lines": 157 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCachePendingChanges.cs", + "category": "Services", + "lines": 58 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs", + "category": "Services", + "lines": 2111 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbCompanyTvDiscover.cs", + "category": "Services", + "lines": 201 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs", + "category": "Services", + "lines": 855 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs", + "category": "Services", + "lines": 273 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs", + "category": "Services", + "lines": 199 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs", + "category": "Services", + "lines": 440 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", + "category": "Services", + "lines": 280 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs", + "category": "Services", + "lines": 486 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-links.js", + "category": "js", + "lines": 786 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-tag-links.js", + "category": "js", + "lines": 220 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-custom-tab.js", + "category": "js", + "lines": 153 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", + "category": "js", + "lines": 276 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js", + "category": "js", + "lines": 489 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-init.js", + "category": "js", + "lines": 347 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-events.js", + "category": "js", + "lines": 502 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-views.js", + "category": "js", + "lines": 500 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-styles.js", + "category": "js", + "lines": 1124 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-custom-tab.js", + "category": "js", + "lines": 184 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-actions.js", + "category": "js", + "lines": 109 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-data.js", + "category": "js", + "lines": 373 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-init.js", + "category": "js", + "lines": 482 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-cards.js", + "category": "js", + "lines": 559 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-helpers.js", + "category": "js", + "lines": 416 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render.js", + "category": "js", + "lines": 485 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-styles.js", + "category": "js", + "lines": 601 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/awards/awards.js", + "category": "js", + "lines": 547 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/component-scripts.json", + "category": "js", + "lines": 214 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js", + "category": "js", + "lines": 925 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js", + "category": "js", + "lines": 407 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/lifecycle.js", + "category": "js", + "lines": 220 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/media-language.js", + "category": "js", + "lines": 346 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/navigation.js", + "category": "js", + "lines": 318 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/session.js", + "category": "js", + "lines": 281 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js", + "category": "js", + "lines": 667 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/ui-kit.js", + "category": "js", + "lines": 229 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js", + "category": "js", + "lines": 1186 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js", + "category": "js", + "lines": 1230 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-init.js", + "category": "js", + "lines": 127 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js", + "category": "js", + "lines": 402 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-modals.js", + "category": "js", + "lines": 351 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-page.js", + "category": "js", + "lines": 329 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js", + "category": "js", + "lines": 344 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js", + "category": "js", + "lines": 345 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-styles.js", + "category": "js", + "lines": 903 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "category": "js", + "lines": 1269 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", + "category": "js", + "lines": 233 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js", + "category": "js", + "lines": 403 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/features-random-button.js", + "category": "js", + "lines": 241 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/header-actions.js", + "category": "js", + "lines": 498 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/helpers.js", + "category": "js", + "lines": 750 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-buttons.js", + "category": "js", + "lines": 233 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-custom-tab.js", + "category": "js", + "lines": 145 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js", + "category": "js", + "lines": 528 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-dialogs.js", + "category": "js", + "lines": 378 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js", + "category": "js", + "lines": 573 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-init.js", + "category": "js", + "lines": 104 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-admin.js", + "category": "js", + "lines": 486 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-cards.js", + "category": "js", + "lines": 465 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-init.js", + "category": "js", + "lines": 109 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-nav.js", + "category": "js", + "lines": 368 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-render.js", + "category": "js", + "lines": 476 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-state.js", + "category": "js", + "lines": 154 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-styles.js", + "category": "js", + "lines": 542 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-panel.js", + "category": "js", + "lines": 333 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-save.js", + "category": "js", + "lines": 275 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-styles.js", + "category": "js", + "lines": 388 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-home.js", + "category": "js", + "lines": 588 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-multiselect.js", + "category": "js", + "lines": 234 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/icons.js", + "category": "js", + "lines": 266 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js", + "category": "js", + "lines": 830 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-page.js", + "category": "js", + "lines": 356 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js", + "category": "js", + "lines": 267 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/native-tabs.js", + "category": "js", + "lines": 681 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/auto-skip.js", + "category": "js", + "lines": 556 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/osd-rating.js", + "category": "js", + "lines": 225 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js", + "category": "js", + "lines": 983 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback-rating-badge.js", + "category": "js", + "lines": 287 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback.js", + "category": "js", + "lines": 1500 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/subtitles.js", + "category": "js", + "lines": 364 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-entry-points.js", + "category": "js", + "lines": 217 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-hidden-content.js", + "category": "js", + "lines": 123 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", + "category": "js", + "lines": 185 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "category": "js", + "lines": 861 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", + "category": "js", + "lines": 188 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-template.js", + "category": "js", + "lines": 715 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js", + "category": "js", + "lines": 371 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-release-notes.js", + "category": "js", + "lines": 182 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/detail-button.js", + "category": "js", + "lines": 155 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/dialog.js", + "category": "js", + "lines": 67 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/identity.js", + "category": "js", + "lines": 57 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/ids.js", + "category": "js", + "lines": 30 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js", + "category": "js", + "lines": 58 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/index.js", + "category": "js", + "lines": 51 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/seerr-toggle.js", + "category": "js", + "lines": 80 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/settings-tab.js", + "category": "js", + "lines": 126 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/snooze.js", + "category": "js", + "lines": 52 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js", + "category": "js", + "lines": 247 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/styles.js", + "category": "js", + "lines": 61 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/watched-refresh.js", + "category": "js", + "lines": 74 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/themer.js", + "category": "js", + "lines": 196 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", + "category": "js", + "lines": 232 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/ui-styles.js", + "category": "js", + "lines": 317 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/active-streams.js", + "category": "js", + "lines": 1279 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-custom-tab.js", + "category": "js", + "lines": 161 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-page.js", + "category": "js", + "lines": 576 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-activity-icons.js", + "category": "js", + "lines": 473 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-ratings.js", + "category": "js", + "lines": 255 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/login-image.js", + "category": "js", + "lines": 508 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-icons.js", + "category": "js", + "lines": 404 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-revisions.js", + "category": "js", + "lines": 60 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/theme-selector.js", + "category": "js", + "lines": 449 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/api.js", + "category": "js", + "lines": 979 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/collection-discovery.js", + "category": "js", + "lines": 221 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-base.js", + "category": "js", + "lines": 1418 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-filter-utils.js", + "category": "js", + "lines": 678 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/genre-discovery.js", + "category": "js", + "lines": 170 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/network-discovery.js", + "category": "js", + "lines": 257 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/person-discovery.js", + "category": "js", + "lines": 224 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/tag-discovery.js", + "category": "js", + "lines": 115 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/hss-discovery-handler.js", + "category": "js", + "lines": 47 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/issue-reporter.js", + "category": "js", + "lines": 1114 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/item-details.js", + "category": "js", + "lines": 755 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js", + "category": "js", + "lines": 848 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/modal.js", + "category": "js", + "lines": 321 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions-tv.js", + "category": "js", + "lines": 273 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions.js", + "category": "js", + "lines": 498 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-badges.js", + "category": "js", + "lines": 256 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-data.js", + "category": "js", + "lines": 174 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js", + "category": "js", + "lines": 260 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-render.js", + "category": "js", + "lines": 438 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-seasons.js", + "category": "js", + "lines": 418 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-styles.js", + "category": "js", + "lines": 1408 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-catalog.js", + "category": "js", + "lines": 135 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-category.js", + "category": "js", + "lines": 476 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-custom-tab.js", + "category": "js", + "lines": 139 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-data.js", + "category": "js", + "lines": 99 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-init.js", + "category": "js", + "lines": 133 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-page.js", + "category": "js", + "lines": 101 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-render.js", + "category": "js", + "lines": 476 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-styles.js", + "category": "js", + "lines": 227 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/request-manager.js", + "category": "js", + "lines": 15 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seamless-scroll.js", + "category": "js", + "lines": 595 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-detail-link.js", + "category": "js", + "lines": 177 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-status.js", + "category": "js", + "lines": 256 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-badges.js", + "category": "js", + "lines": 373 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js", + "category": "js", + "lines": 339 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-cards.js", + "category": "js", + "lines": 729 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-icons.js", + "category": "js", + "lines": 43 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-popover.js", + "category": "js", + "lines": 320 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-quota.js", + "category": "js", + "lines": 178 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-request-modals.js", + "category": "js", + "lines": 302 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js", + "category": "js", + "lines": 458 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-season-modal.js", + "category": "js", + "lines": 559 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-styles.js", + "category": "js", + "lines": 435 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json", + "category": "js", + "lines": 724 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js", + "category": "js", + "lines": 215 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/mdblist-ratings.js", + "category": "js", + "lines": 393 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/splashscreen.js", + "category": "js", + "lines": 459 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "category": "js", + "lines": 1367 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ageratingtags.js", + "category": "js", + "lines": 313 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/genretags.js", + "category": "js", + "lines": 340 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/languagetags.js", + "category": "js", + "lines": 397 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/peopletags.js", + "category": "js", + "lines": 1075 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/qualitytags.js", + "category": "js", + "lines": 1072 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ratingtags.js", + "category": "js", + "lines": 521 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-cache-store.js", + "category": "js", + "lines": 361 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-pipeline.js", + "category": "js", + "lines": 1627 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/userreviewtags.js", + "category": "js", + "lines": 541 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json", + "category": "packages.jf10.lock.json", + "lines": 348 + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json", + "category": "packages.jf12.lock.json", + "lines": 305 + } + ], + "routes": [ + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/status", + "method": "GetJellyseerrStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1134, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/validate", + "method": "ValidateJellyseerr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1178, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/trigger-recently-added-scan", + "method": "TriggerJellyseerrRecentlyAddedScan", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1242, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/user-status", + "method": "GetJellyseerrUserStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1303, + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/permission-audit", + "method": "GetPermissionAudit", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1359, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/search", + "method": "JellyseerrSearch", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1483, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/sonarr", + "method": "GetSonarrInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1516, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/radarr", + "method": "GetRadarrInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1523, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/sonarr/lookup/{tmdbId}", + "method": "GetSonarrLookup", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1534, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/settings/{type}", + "method": "GetJellyseerrArrSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1548, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/host-compat", + "method": "GetHostCompatibility", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1605, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyfin-urls", + "method": "GetJellyfinNetworkUrls", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1634, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/{type}/{serverId}", + "method": "GetServiceDetails", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1646, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/request", + "method": "JellyseerrRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1661, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/request", + "method": "GetJellyseerrRequests", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1793, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/quota", + "method": "GetJellyseerrQuota", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1801, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}", + "method": "GetTvShow", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1932, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/season/{seasonNumber}", + "method": "GetTvSeason", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1939, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}", + "method": "GetMovie", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1946, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/similar", + "method": "GetSimilarMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1953, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/recommendations", + "method": "GetRecommendedMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1960, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/ratingscombined", + "method": "GetMovieRatingsCombined", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1967, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/similar", + "method": "GetSimilarTvShows", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1974, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/recommendations", + "method": "GetRecommendedTvShows", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1981, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/ratings", + "method": "GetTvRatingsCombined", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1988, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/network/{networkId}", + "method": "DiscoverTvByNetwork", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 1995, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/studio/{studioId}", + "method": "DiscoverMoviesByStudio", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2002, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/studio/{studioId}", + "method": "DiscoverTvByStudio", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2017, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/trending", + "method": "DiscoverTrending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2109, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies", + "method": "DiscoverMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2116, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv", + "method": "DiscoverTv", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2123, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/upcoming", + "method": "DiscoverUpcomingMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2130, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/upcoming", + "method": "DiscoverUpcomingTv", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2137, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/studio/{studioId}", + "method": "GetStudioInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2144, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/boxset/{boxsetId}", + "method": "GetBoxSetInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2178, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/person/{personId}", + "method": "GetPersonInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2212, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/people/info", + "method": "GetPeopleInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2256, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/genre/{genreId}", + "method": "GetGenreInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2568, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/person/{personId}", + "method": "GetJellyseerrPerson", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2594, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/person/{personId}/combined_credits", + "method": "GetJellyseerrPersonCredits", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2613, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/genre/{genreId}", + "method": "DiscoverTvByGenre", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2629, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/genre/{genreId}", + "method": "DiscoverMoviesByGenre", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2636, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/keyword/{keywordId}", + "method": "DiscoverTvByKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2643, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/keyword/{keywordId}", + "method": "DiscoverMoviesByKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2650, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/search/person", + "method": "SearchTmdbPerson", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2657, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/search/keyword", + "method": "SearchTmdbKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2668, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/genres/movie", + "method": "GetTmdbMovieGenres", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2679, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/genres/tv", + "method": "GetTmdbTvGenres", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2686, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/movie", + "method": "GetMovieGenreSlider", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2693, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/tv", + "method": "GetTvGenreSlider", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2700, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/overrideRule", + "method": "GetOverrideRules", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2707, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/collection/{collectionId}", + "method": "GetCollection", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2714, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/user", + "method": "GetJellyseerrUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2721, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/request/tv/{tmdbId}/seasons", + "method": "RequestTvSeasons", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2736, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/watchlist", + "method": "GetJellyseerrWatchlist", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2773, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/sync-watchlist", + "method": "SyncJellyseerrWatchlist", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2787, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/import-users", + "method": "ImportJellyseerrUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 2902, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/settings/partial-requests", + "method": "GetJellyseerrPartialRequestsSetting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3230, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/validate", + "method": "ValidateTmdb", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3300, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/script", + "method": "GetMainScript", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3344, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/js/{**path}", + "method": "GetScript", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3346, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/Configuration/configPage.css", + "method": "GetConfigPageStylesheet", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3349, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/version", + "method": "GetVersion", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3355, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/private-config", + "method": "GetPrivateConfig", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3358, + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/public-config", + "method": "GetPublicConfig", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3422, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bootstrap", + "method": "GetBootstrap", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3762, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bundle.js", + "method": "GetScriptBundle", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3860, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bundle.js.map", + "method": "GetScriptBundleMap", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3863, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/{**apiPath}", + "method": "ProxyTmdbRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3893, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/watch-providers", + "method": "GetWatchProvidersBatch", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4002, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/usage/track", + "method": "TrackUsage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4127, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/usage/preview", + "method": "PreviewUsageReport", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4183, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/audio-language-tags", + "method": "GetAudioLanguageTags", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4234, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/awards/{mediaType}/{tmdbId}", + "method": "GetAwards", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4265, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/mdblist-ratings/{mediaType}/{tmdbId}", + "method": "GetMdblistRatings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4303, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/mdblist-ratings/account-status", + "method": "GetMdblistAccountStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4346, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/locales", + "method": "GetAvailableLocales", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4368, + "following_attributes": [ + "Authorize", + "ResponseCache(Duration = 86400)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/cdn/{source}/{**path}", + "method": "GetCdnAsset", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4398, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/fonts/{name}", + "method": "GetBundledFont", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4447, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/locales/{lang}.json", + "method": "GetLocale", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4466, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/settings.json", + "method": "GetUserSettingsSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4673, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/shortcuts.json", + "method": "GetUserSettingsShortcuts", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4786, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/elsewhere.json", + "method": "GetUserSettingsElsewhere", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4801, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/settings.json", + "method": "SaveUserSettingsSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4816, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/shortcuts.json", + "method": "SaveUserSettingsShortcuts", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4870, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json", + "method": "GetUserBookmark", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4894, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json", + "method": "SaveUserBookmark", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4910, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json/add", + "method": "AddUserBookmark", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4948, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json/{bookmarkId}", + "method": "RemoveUserBookmark", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4998, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/elsewhere.json", + "method": "SaveUserSettingsElsewhere", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5038, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/hidden-content.json", + "method": "GetUserHiddenContent", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5062, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/hidden-content.json", + "method": "SaveUserHiddenContent", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5133, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/spoilerblur.json", + "method": "GetUserSpoilerBlur", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5187, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/spoilerblur.json", + "method": "SaveUserSpoilerBlur", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5216, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")", + "Consumes(\"application/json\")", + "RequestSizeLimit(2 * 1024 * 1024)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/admin/hidden-content-users", + "method": "GetHiddenContentUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5331, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}", + "method": "GetUserHiddenContentForAdmin", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5395, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/unhide", + "method": "AdminUnhideForUser", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5440, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/hide", + "method": "AdminHideForUser", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5511, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/health", + "method": "GetSpoilerBlurHealth", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5607, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/health/{targetUserId}", + "method": "AckSpoilerBlurCorruption", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5642, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/preview", + "method": "PreviewSpoilerApplyExisting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5679, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing", + "method": "StartSpoilerApplyExisting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5706, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/status", + "method": "GetSpoilerApplyExistingStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5739, + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/series", + "method": "GetSpoilerBlurSeries", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5757, + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/user-prefs", + "method": "GetSpoilerBlurUserPrefs", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5799, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/user-prefs", + "method": "SetSpoilerBlurUserPrefs", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5833, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")", + "Consumes(\"application/json\")", + "RequestSizeLimit(8 * 1024)" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/series/{seriesId}", + "method": "EnableSpoilerBlurForSeries", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5890, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/series/{seriesId}", + "method": "DisableSpoilerBlurForSeries", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5979, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/movies/{movieId}", + "method": "EnableSpoilerBlurForMovie", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6043, + "following_attributes": [ + "Authorize", + "RequestSizeLimit(8 * 1024)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/movies/{movieId}", + "method": "DisableSpoilerBlurForMovie", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6138, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/collections/{collectionId}", + "method": "EnableSpoilerBlurForCollection", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6200, + "following_attributes": [ + "Authorize", + "RequestSizeLimit(8 * 1024)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/collections/{collectionId}", + "method": "DisableSpoilerBlurForCollection", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6288, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", + "method": "EnableSpoilerBlurPending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6350, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", + "method": "DisableSpoilerBlurPending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6600, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", + "method": "HideFromContinueWatching", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6805, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/next-up/hide/{itemId}", + "method": "HideFromNextUp", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6810, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", + "method": "UnhideFromContinueWatching", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6929, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/next-up/hide/{itemId}", + "method": "UnhideFromNextUp", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6934, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "GetItemReviews", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7109, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/ratings", + "method": "GetReviewRatings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7179, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "UpsertReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7286, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", + "method": "AdminUpsertReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7331, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "DeleteReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7378, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/admin/all", + "method": "AdminListReviews", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7502, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", + "method": "AdminDeleteReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7605, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reset-all-users-settings", + "method": "ResetAllUsersSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7645, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/tag-cache/rebuild", + "method": "RebuildTagCache", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7807, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tag-cache/{userId}", + "method": "GetTagCache", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7826, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/activity", + "method": "GetActivityFeed", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7966, + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/whats-new", + "method": "GetWhatsNew", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7994, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/whats-new/dismiss", + "method": "DismissWhatsNew", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8010, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/tag-data/{userId}", + "method": "GetTagData", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8020, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/item-stats/{userId}/{itemId}", + "method": "GetItemStatsByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8464, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/native-poster-tags/preview/{itemId}", + "method": "PreviewNativePosterTags", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8498, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/file-size/{userId}/{itemId}", + "method": "GetFileSizeByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8576, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/watch-progress/{userId}/{itemId}", + "method": "GetWatchProgressByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8597, + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/issue", + "method": "GetJellyseerrIssues", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8618, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/issue/{id}", + "method": "GetJellyseerrIssueById", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8657, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/issue", + "method": "ReportJellyseerrIssue", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8674, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/UploadBrandingImage", + "method": "UploadBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8681, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/BrandingStatus", + "method": "GetBrandingStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8748, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/BrandingImage", + "method": "GetBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8763, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/DeleteBrandingImage", + "method": "DeleteBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8789, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/validate/sonarr", + "method": "ValidateSonarr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8836, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/validate/radarr", + "method": "ValidateRadarr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8843, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/identify-url", + "method": "IdentifyUrl", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 8889, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/series-slug", + "method": "GetSeriesSlug", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9010, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/series-slugs", + "method": "GetSeriesSlugs", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9047, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/links", + "method": "GetArrLinks", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9132, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/movie-instances", + "method": "GetMovieInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9385, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/active-streams/sessions", + "method": "GetActiveSessions", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9437, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/active-streams/broadcast", + "method": "BroadcastMessage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9516, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/queue", + "method": "GetDownloadQueue", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9770, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/history", + "method": "GetDownloadHistory", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 9966, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/requests", + "method": "GetRequests", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 10163, + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/arr/requests/{requestId}/approve", + "method": "ActOnRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 10556, + "following_attributes": [ + "HttpPost(\"arr/requests/{requestId}/decline\")", + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/arr/requests/{requestId}/decline", + "method": "ActOnRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 10557, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/calendar", + "method": "GetCalendarEvents", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 10607, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/arr/calendar/user-data", + "method": "GetCalendarUserDataForEvents", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11193, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/proxy/avatar", + "method": "ProxyAvatar", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11506, + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/items/by-providers", + "method": "GetItemIdByProviders", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11650, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/MaintenanceMode/Status", + "method": "GetMaintenanceStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11677, + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Enable", + "method": "EnableMaintenanceMode", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11701, + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Disable", + "method": "DisableMaintenanceMode", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11723, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/MaintenanceMode/Users", + "method": "GetMaintenanceModeUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11732, + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Broadcast", + "method": "BroadcastMaintenanceMessage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11745, + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/{viewName}", + "method": "GetView", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 11775, + "following_attributes": [] + } + ], + "configuration_properties": [ + { + "class": "Shortcut", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 10 + }, + { + "class": "Shortcut", + "name": "Key", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 11 + }, + { + "class": "Shortcut", + "name": "Label", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 12 + }, + { + "class": "Shortcut", + "name": "Category", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 13 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 441 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"This server is currently undergoing maintenance. Please try again.\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 443 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeNotificationMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"Server undergoing maintenance.\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 445 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeAction", + "type": "string", + "initializer": "\"disable_accounts\"", + "constructor_assignment": "\"disable_accounts\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 448 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeAffectedUsers", + "type": "string", + "initializer": "\"all\"", + "constructor_assignment": "\"all\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 455 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeDurationMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 457 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeRemindOnPlayback", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 459 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 461 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleStart", + "type": "string", + "initializer": "\"00:00\"", + "constructor_assignment": "\"00:00\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 463 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleEnd", + "type": "string", + "initializer": "\"08:00\"", + "constructor_assignment": "\"08:00\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 465 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"The library is updating", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 467 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleNotificationMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"The library is updating", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 469 + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleAction", + "type": "string", + "initializer": "\"none\"", + "constructor_assignment": "\"none\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 472 + }, + { + "class": "PluginConfiguration", + "name": "ToastDuration", + "type": "int", + "initializer": null, + "constructor_assignment": "1500", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 475 + }, + { + "class": "PluginConfiguration", + "name": "HelpPanelAutocloseDelay", + "type": "int", + "initializer": null, + "constructor_assignment": "15000", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 476 + }, + { + "class": "PluginConfiguration", + "name": "EnableCustomSplashScreen", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 477 + }, + { + "class": "PluginConfiguration", + "name": "SplashScreenImageUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"/web/assets/img/banner-light.png\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 478 + }, + { + "class": "PluginConfiguration", + "name": "DevMode", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 479 + }, + { + "class": "PluginConfiguration", + "name": "DisableScriptInjectionMiddleware", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 485 + }, + { + "class": "PluginConfiguration", + "name": "DisableBrandingMiddleware", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 491 + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 494 + }, + { + "class": "PluginConfiguration", + "name": "TMDB_API_KEY", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 495 + }, + { + "class": "PluginConfiguration", + "name": "DEFAULT_REGION", + "type": "string", + "initializer": null, + "constructor_assignment": "\"US\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 496 + }, + { + "class": "PluginConfiguration", + "name": "DEFAULT_PROVIDERS", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 497 + }, + { + "class": "PluginConfiguration", + "name": "IGNORE_PROVIDERS", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 498 + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereCustomBrandingText", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 499 + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereCustomBrandingImageUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 500 + }, + { + "class": "PluginConfiguration", + "name": "ClearLocalStorageTimestamp", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 501 + }, + { + "class": "PluginConfiguration", + "name": "ClearTranslationCacheTimestamp", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 502 + }, + { + "class": "PluginConfiguration", + "name": "AutoPauseEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 505 + }, + { + "class": "PluginConfiguration", + "name": "AutoResumeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 506 + }, + { + "class": "PluginConfiguration", + "name": "AutoPipEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 507 + }, + { + "class": "PluginConfiguration", + "name": "AutoSkipIntro", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 508 + }, + { + "class": "PluginConfiguration", + "name": "AutoSkipOutro", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 509 + }, + { + "class": "PluginConfiguration", + "name": "LongPress2xEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 510 + }, + { + "class": "PluginConfiguration", + "name": "RandomButtonEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 511 + }, + { + "class": "PluginConfiguration", + "name": "RandomIncludeMovies", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 512 + }, + { + "class": "PluginConfiguration", + "name": "RandomIncludeShows", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 513 + }, + { + "class": "PluginConfiguration", + "name": "RandomUnwatchedOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 514 + }, + { + "class": "PluginConfiguration", + "name": "RandomScopeCurrentContainer", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 515 + }, + { + "class": "PluginConfiguration", + "name": "ShowWatchProgress", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 516 + }, + { + "class": "PluginConfiguration", + "name": "WatchProgressDefaultMode", + "type": "string", + "initializer": null, + "constructor_assignment": "\"percentage\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 517 + }, + { + "class": "PluginConfiguration", + "name": "WatchProgressTimeFormat", + "type": "string", + "initializer": null, + "constructor_assignment": "\"hours\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 518 + }, + { + "class": "PluginConfiguration", + "name": "ShowFileSizes", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 519 + }, + { + "class": "PluginConfiguration", + "name": "RemoveContinueWatchingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 520 + }, + { + "class": "PluginConfiguration", + "name": "ShowAudioLanguages", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 521 + }, + { + "class": "PluginConfiguration", + "name": "ShowReviews", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 522 + }, + { + "class": "PluginConfiguration", + "name": "ShowUserReviews", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 523 + }, + { + "class": "PluginConfiguration", + "name": "ReviewsExpandedByDefault", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 524 + }, + { + "class": "PluginConfiguration", + "name": "HideReviewsFromHiddenUsers", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 525 + }, + { + "class": "PluginConfiguration", + "name": "HideReviewsFromDisabledUsers", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 526 + }, + { + "class": "PluginConfiguration", + "name": "MirrorReviewRatingsToJellyfin", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 535 + }, + { + "class": "PluginConfiguration", + "name": "ShowAwards", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 536 + }, + { + "class": "PluginConfiguration", + "name": "MdblistApiKey", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 540 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 542 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsShowOnItemDetails", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 544 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsFetchEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 550 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsAutoSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 556 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsOverwriteExisting", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 565 + }, + { + "class": "PluginConfiguration", + "name": "MdblistFetchReserve", + "type": "int", + "initializer": "400", + "constructor_assignment": "400", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 571 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsSources", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 577 + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsShowPercentSymbol", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 579 + }, + { + "class": "PluginConfiguration", + "name": "ShowReleaseDates", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 580 + }, + { + "class": "PluginConfiguration", + "name": "ShowUserRatingOnPosters", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 581 + }, + { + "class": "PluginConfiguration", + "name": "ShowUserRatingDash", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 586 + }, + { + "class": "PluginConfiguration", + "name": "Shortcuts", + "type": "List", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 587 + }, + { + "class": "PluginConfiguration", + "name": "PauseScreenEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 588 + }, + { + "class": "PluginConfiguration", + "name": "ShowPlaybackRatingBadge", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 589 + }, + { + "class": "PluginConfiguration", + "name": "PauseScreenDelaySeconds", + "type": "int", + "initializer": "5", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 590 + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 591 + }, + { + "class": "PluginConfiguration", + "name": "ShowResolutionTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 592 + }, + { + "class": "PluginConfiguration", + "name": "ShowSourceTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 593 + }, + { + "class": "PluginConfiguration", + "name": "ShowDynamicRangeTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 594 + }, + { + "class": "PluginConfiguration", + "name": "ShowSpecialFormatTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 595 + }, + { + "class": "PluginConfiguration", + "name": "ShowVideoCodecTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 596 + }, + { + "class": "PluginConfiguration", + "name": "ShowAudioInfoTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 597 + }, + { + "class": "PluginConfiguration", + "name": "ResolutionTagOrder", + "type": "int", + "initializer": "1", + "constructor_assignment": "1", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 598 + }, + { + "class": "PluginConfiguration", + "name": "SourceTagOrder", + "type": "int", + "initializer": "2", + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 599 + }, + { + "class": "PluginConfiguration", + "name": "DynamicRangeTagOrder", + "type": "int", + "initializer": "3", + "constructor_assignment": "3", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 600 + }, + { + "class": "PluginConfiguration", + "name": "SpecialFormatTagOrder", + "type": "int", + "initializer": "4", + "constructor_assignment": "4", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 601 + }, + { + "class": "PluginConfiguration", + "name": "VideoCodecTagOrder", + "type": "int", + "initializer": "5", + "constructor_assignment": "5", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 602 + }, + { + "class": "PluginConfiguration", + "name": "AudioInfoTagOrder", + "type": "int", + "initializer": "6", + "constructor_assignment": "6", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 603 + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 604 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 605 + }, + { + "class": "PluginConfiguration", + "name": "AgeRatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 606 + }, + { + "class": "PluginConfiguration", + "name": "PeopleTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 607 + }, + { + "class": "PluginConfiguration", + "name": "NativePosterTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 609 + }, + { + "class": "PluginConfiguration", + "name": "NativePosterTagsWebClientNames", + "type": "string", + "initializer": null, + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 616 + }, + { + "class": "PluginConfiguration", + "name": "TagsCacheTtlDays", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 617 + }, + { + "class": "PluginConfiguration", + "name": "DisableTagsOnSearchPage", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 618 + }, + { + "class": "PluginConfiguration", + "name": "TagsHideOnHover", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 619 + }, + { + "class": "PluginConfiguration", + "name": "TagCacheServerMode", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 620 + }, + { + "class": "PluginConfiguration", + "name": "EnableTagsLocalStorageFallback", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 621 + }, + { + "class": "PluginConfiguration", + "name": "DisableAllShortcuts", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 622 + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleStyle", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 623 + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleSize", + "type": "int", + "initializer": null, + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 624 + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleFont", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 625 + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleTextEffect", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 626 + }, + { + "class": "PluginConfiguration", + "name": "DisableCustomSubtitleStyles", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 627 + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsPosition", + "type": "string", + "initializer": "\"top-left\"", + "constructor_assignment": "\"top-left\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 629 + }, + { + "class": "PluginConfiguration", + "name": "GenreTagsPosition", + "type": "string", + "initializer": "\"top-right\"", + "constructor_assignment": "\"top-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 631 + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPosition", + "type": "string", + "initializer": "\"bottom-left\"", + "constructor_assignment": "\"bottom-left\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 633 + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPriority", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 646 + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPriorityStrict", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 655 + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsPreferredAudioLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 662 + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsAudioLanguageFromUser", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 669 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": "\"bottom-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 671 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 676 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 677 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnSeasons", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 678 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnEpisodes", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 679 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 680 + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 681 + }, + { + "class": "PluginConfiguration", + "name": "AgeRatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": "\"bottom-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 683 + }, + { + "class": "PluginConfiguration", + "name": "ShowRatingInPlayer", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 684 + }, + { + "class": "PluginConfiguration", + "name": "GenreTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 685 + }, + { + "class": "PluginConfiguration", + "name": "DefaultLanguage", + "type": "string", + "initializer": null, + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 686 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 689 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowSearchResults", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 690 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrSearchExcludeLibraryItems", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 691 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowReportButton", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 692 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowIssueIndicator", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 693 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnable4KRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 694 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnable4KTvRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 695 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowAdvanced", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 696 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowQuotaInfo", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 697 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowSimilar", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 698 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowRecommended", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 699 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowRequestMoreOnSeries", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 700 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowNetworkDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 701 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowGenreDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 702 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowTagDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 703 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowPersonDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 704 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowCollectionDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 705 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowDetailPageLink", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 706 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowDetailPageLinkAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 707 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrExcludeLibraryItems", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 708 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrExcludeBlocklistedItems", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 709 + }, + { + "class": "PluginConfiguration", + "name": "ShowElsewhereOnJellyseerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 710 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUseMoreInfoModal", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 711 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrAvailablePosterLinksToJellyfin", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 712 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUrls", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 713 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 714 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 715 + }, + { + "class": "PluginConfiguration", + "name": "TriggerSeerrScanOnItemAdded", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 719 + }, + { + "class": "PluginConfiguration", + "name": "SeerrScanDebounceSeconds", + "type": "int", + "initializer": "60", + "constructor_assignment": "60", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 720 + }, + { + "class": "PluginConfiguration", + "name": "ShowCollectionsInSearch", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 721 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrDisableCache", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 722 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrResponseCacheTtlMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "10", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 723 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUserIdCacheTtlMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 724 + }, + { + "class": "PluginConfiguration", + "name": "ArrLinksEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 727 + }, + { + "class": "PluginConfiguration", + "name": "SonarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 728 + }, + { + "class": "PluginConfiguration", + "name": "RadarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 729 + }, + { + "class": "PluginConfiguration", + "name": "BazarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 730 + }, + { + "class": "PluginConfiguration", + "name": "ShowArrLinksAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 731 + }, + { + "class": "PluginConfiguration", + "name": "ArrLinksShowStatusSingle", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 739 + }, + { + "class": "PluginConfiguration", + "name": "SonarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 740 + }, + { + "class": "PluginConfiguration", + "name": "RadarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 741 + }, + { + "class": "PluginConfiguration", + "name": "BazarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 742 + }, + { + "class": "PluginConfiguration", + "name": "SonarrInstances", + "type": "string", + "initializer": "\"[]\"", + "constructor_assignment": "\"[]\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 745 + }, + { + "class": "PluginConfiguration", + "name": "RadarrInstances", + "type": "string", + "initializer": "\"[]\"", + "constructor_assignment": "\"[]\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 746 + }, + { + "class": "PluginConfiguration", + "name": "ShokoUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 750 + }, + { + "class": "PluginConfiguration", + "name": "ShokoApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 751 + }, + { + "class": "PluginConfiguration", + "name": "ShokoUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 752 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowEpisodes", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 756 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowSpecials", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 757 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowCredits", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 758 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowTrailers", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 759 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowParodies", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 760 + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowOther", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 761 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 764 + }, + { + "class": "PluginConfiguration", + "name": "SonarrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 765 + }, + { + "class": "PluginConfiguration", + "name": "RadarrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 766 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsPrefix", + "type": "string", + "initializer": null, + "constructor_assignment": "\"JE Arr Tag: \"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 767 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsClearOldTags", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 768 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsShowAsLinks", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 769 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsLinksFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 770 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsLinksHideFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 771 + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsSyncFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 772 + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 775 + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagPrefix", + "type": "string", + "initializer": null, + "constructor_assignment": "\"JE Language: \"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 776 + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagShowAsLinks", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 777 + }, + { + "class": "PluginConfiguration", + "name": "LetterboxdEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 780 + }, + { + "class": "PluginConfiguration", + "name": "ShowLetterboxdLinkAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 781 + }, + { + "class": "PluginConfiguration", + "name": "MetadataIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 784 + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 787 + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestThresholdValue", + "type": "int", + "initializer": null, + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 788 + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestRequireAllWatched", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 789 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 792 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestTriggerType", + "type": "string", + "initializer": null, + "constructor_assignment": "\"OnMinutesWatched\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 793 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestMinutesWatched", + "type": "int", + "initializer": null, + "constructor_assignment": "20", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 794 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCheckReleaseDate", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 795 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestQualityMode", + "type": "string", + "initializer": null, + "constructor_assignment": "\"default\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 796 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomServerId", + "type": "int", + "initializer": null, + "constructor_assignment": "-1", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 797 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomProfileId", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 798 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomRootFolder", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 799 + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestFallbackOn4k", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 800 + }, + { + "class": "PluginConfiguration", + "name": "AddRequestedMediaToWatchlist", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 803 + }, + { + "class": "PluginConfiguration", + "name": "SyncJellyseerrWatchlist", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 804 + }, + { + "class": "PluginConfiguration", + "name": "SyncJellyfinWatchlistToSeerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 805 + }, + { + "class": "PluginConfiguration", + "name": "PreventWatchlistReAddition", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 806 + }, + { + "class": "PluginConfiguration", + "name": "WatchlistMemoryRetentionDays", + "type": "int", + "initializer": null, + "constructor_assignment": "365", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 807 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrAutoImportUsers", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 810 + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrImportBlockedUsers", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 811 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 814 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 815 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 816 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 824 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 832 + }, + { + "class": "PluginConfiguration", + "name": "BookmarksCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 841 + }, + { + "class": "PluginConfiguration", + "name": "UseIcons", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 844 + }, + { + "class": "PluginConfiguration", + "name": "IconStyle", + "type": "string", + "initializer": null, + "constructor_assignment": "\"emoji\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 846 + }, + { + "class": "PluginConfiguration", + "name": "ColoredRatingsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 849 + }, + { + "class": "PluginConfiguration", + "name": "ThemeSelectorEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 850 + }, + { + "class": "PluginConfiguration", + "name": "ColoredActivityIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 851 + }, + { + "class": "PluginConfiguration", + "name": "PluginIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 852 + }, + { + "class": "PluginConfiguration", + "name": "EnableLoginImage", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 853 + }, + { + "class": "PluginConfiguration", + "name": "CustomPluginLinks", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 854 + }, + { + "class": "PluginConfiguration", + "name": "ActiveStreamsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 855 + }, + { + "class": "PluginConfiguration", + "name": "ActiveStreamsAllUsers", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 856 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 861 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowWatched", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 862 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowFavorited", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 863 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowReviewed", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 864 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowActiveStreams", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 873 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 874 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 875 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 883 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 884 + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 886 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 889 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 890 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 891 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 899 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 900 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 902 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPagePollingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 903 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPollIntervalSeconds", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 904 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPageShowIssues", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 905 + }, + { + "class": "PluginConfiguration", + "name": "ShowDownloadsInRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 906 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsFilterByUserRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 907 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsShowHistory", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 908 + }, + { + "class": "PluginConfiguration", + "name": "DownloadsHistoryAdminOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 909 + }, + { + "class": "PluginConfiguration", + "name": "CalendarPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 912 + }, + { + "class": "PluginConfiguration", + "name": "CalendarUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 913 + }, + { + "class": "PluginConfiguration", + "name": "CalendarUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 914 + }, + { + "class": "PluginConfiguration", + "name": "CalendarUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 922 + }, + { + "class": "PluginConfiguration", + "name": "CalendarAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 923 + }, + { + "class": "PluginConfiguration", + "name": "CalendarCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 925 + }, + { + "class": "PluginConfiguration", + "name": "CalendarFirstDayOfWeek", + "type": "string", + "initializer": null, + "constructor_assignment": "\"Monday\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 926 + }, + { + "class": "PluginConfiguration", + "name": "CalendarTimeFormat", + "type": "string", + "initializer": null, + "constructor_assignment": "\"5pm/5:30pm\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 927 + }, + { + "class": "PluginConfiguration", + "name": "CalendarHighlightFavorites", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 928 + }, + { + "class": "PluginConfiguration", + "name": "CalendarHighlightWatchedSeries", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 929 + }, + { + "class": "PluginConfiguration", + "name": "CalendarFilterByLibraryAccess", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 930 + }, + { + "class": "PluginConfiguration", + "name": "CalendarShowOnlyRequested", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 931 + }, + { + "class": "PluginConfiguration", + "name": "CalendarForceOnlyRequested", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 932 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 935 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 936 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 937 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 938 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 939 + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 941 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 944 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 945 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 946 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 954 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 955 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 957 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentAdmin", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 960 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 963 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowHideButtons", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 964 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowHideConfirmation", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 965 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonJellyseerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 966 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonLibrary", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 967 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonDetails", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 968 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonCast", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 969 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterLibrary", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 970 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 971 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterSearch", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 972 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterCalendar", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 973 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterUpcoming", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 974 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterRecommendations", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 975 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 976 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterNextUp", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 977 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterContinueWatching", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 978 + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultExperimentalHideCollections", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 979 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurEnabled", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 985 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurIntensity", + "type": "int", + "initializer": "40", + "constructor_assignment": "40", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 989 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripSeriesOverview", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1001 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1002 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripTags", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1003 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripChapters", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1004 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripTaglines", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1005 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1009 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripPremiereDate", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1010 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerReplaceTitle", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1014 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripCast", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1019 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripCastMode", + "type": "string", + "initializer": "\"GuestStars\"", + "constructor_assignment": "\"GuestStars\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1020 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripReviews", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1024 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurMode", + "type": "string", + "initializer": "\"hide\"", + "constructor_assignment": "\"hide\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1030 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurArtwork", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1035 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnFirstPlay", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1039 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnSeerrRequest", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1045 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnLibraryAdd", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1051 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1058 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1059 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableLibraryIds", + "type": "string", + "initializer": "\"\"", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1060 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurStrictRefresh", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1066 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerKeepMoviePosters", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1072 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAdvancedMode", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1079 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripTitle", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1084 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1085 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1086 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripImage", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1087 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripTitle", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1088 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1089 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1090 + }, + { + "class": "PluginConfiguration", + "name": "SpoilerOverviewPlaceholder", + "type": "string", + "initializer": "\"Spoiler Guard activated\"", + "constructor_assignment": "\"Spoiler Guard activated\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1093 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1102 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareFeatureFlags", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1104 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareUsageCounts", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1106 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareDataSizes", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1108 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsHasBeenConfigured", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1116 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsInstallId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1118 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsInstallSecret", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1120 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsReportIntervalDays", + "type": "int", + "initializer": "15", + "constructor_assignment": "15", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1122 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedAt", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1124 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastPayloadJson", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1126 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedPluginVersion", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1128 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedJellyfinTarget", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1130 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedJellyfinVersion", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1132 + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsForbiddenSinceLastSuccess", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "line": 1141 + }, + { + "class": "UserSettings", + "name": "AutoPauseEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 9 + }, + { + "class": "UserSettings", + "name": "AutoResumeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 10 + }, + { + "class": "UserSettings", + "name": "AutoPipEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 11 + }, + { + "class": "UserSettings", + "name": "LongPress2xEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 12 + }, + { + "class": "UserSettings", + "name": "PauseScreenEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 13 + }, + { + "class": "UserSettings", + "name": "PauseScreenDelaySeconds", + "type": "int", + "initializer": "5", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 14 + }, + { + "class": "UserSettings", + "name": "AutoSkipIntro", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 15 + }, + { + "class": "UserSettings", + "name": "AutoSkipOutro", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 16 + }, + { + "class": "UserSettings", + "name": "DisableCustomSubtitleStyles", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 17 + }, + { + "class": "UserSettings", + "name": "SelectedStylePresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 18 + }, + { + "class": "UserSettings", + "name": "SelectedFontSizePresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 19 + }, + { + "class": "UserSettings", + "name": "SelectedFontFamilyPresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 20 + }, + { + "class": "UserSettings", + "name": "SelectedTextEffectPresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 21 + }, + { + "class": "UserSettings", + "name": "CustomSubtitleTextColor", + "type": "string", + "initializer": "\"#FFFFFFFF\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 22 + }, + { + "class": "UserSettings", + "name": "CustomSubtitleBgColor", + "type": "string", + "initializer": "\"#00000000\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 23 + }, + { + "class": "UserSettings", + "name": "UsingCustomColors", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 24 + }, + { + "class": "UserSettings", + "name": "SubtitleVerticalPosition", + "type": "int", + "initializer": "85", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 25 + }, + { + "class": "UserSettings", + "name": "SubtitleHorizontalPosition", + "type": "int", + "initializer": "50", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 26 + }, + { + "class": "UserSettings", + "name": "RandomButtonEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 27 + }, + { + "class": "UserSettings", + "name": "RandomUnwatchedOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 28 + }, + { + "class": "UserSettings", + "name": "RandomIncludeMovies", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 29 + }, + { + "class": "UserSettings", + "name": "RandomIncludeShows", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 30 + }, + { + "class": "UserSettings", + "name": "RandomScopeCurrentContainer", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 31 + }, + { + "class": "UserSettings", + "name": "RandomSourceId", + "type": "string", + "initializer": "\"\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 32 + }, + { + "class": "UserSettings", + "name": "ShowWatchProgress", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 33 + }, + { + "class": "UserSettings", + "name": "WatchProgressMode", + "type": "string", + "initializer": "\"percentage\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 34 + }, + { + "class": "UserSettings", + "name": "WatchProgressTimeFormat", + "type": "string", + "initializer": "\"hours\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 35 + }, + { + "class": "UserSettings", + "name": "ShowFileSizes", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 36 + }, + { + "class": "UserSettings", + "name": "ShowAudioLanguages", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 37 + }, + { + "class": "UserSettings", + "name": "UseNativePosterTags", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 39 + }, + { + "class": "UserSettings", + "name": "QualityTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 40 + }, + { + "class": "UserSettings", + "name": "ShowResolutionTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 41 + }, + { + "class": "UserSettings", + "name": "ShowSourceTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 42 + }, + { + "class": "UserSettings", + "name": "ShowDynamicRangeTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 43 + }, + { + "class": "UserSettings", + "name": "ShowSpecialFormatTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 44 + }, + { + "class": "UserSettings", + "name": "ShowVideoCodecTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 45 + }, + { + "class": "UserSettings", + "name": "ShowAudioInfoTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 46 + }, + { + "class": "UserSettings", + "name": "ResolutionTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 47 + }, + { + "class": "UserSettings", + "name": "SourceTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 48 + }, + { + "class": "UserSettings", + "name": "DynamicRangeTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 49 + }, + { + "class": "UserSettings", + "name": "SpecialFormatTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 50 + }, + { + "class": "UserSettings", + "name": "VideoCodecTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 51 + }, + { + "class": "UserSettings", + "name": "AudioInfoTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 52 + }, + { + "class": "UserSettings", + "name": "GenreTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 53 + }, + { + "class": "UserSettings", + "name": "LanguageTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 54 + }, + { + "class": "UserSettings", + "name": "RatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 55 + }, + { + "class": "UserSettings", + "name": "AgeRatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 56 + }, + { + "class": "UserSettings", + "name": "PeopleTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 57 + }, + { + "class": "UserSettings", + "name": "TagsHideOnHover", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 58 + }, + { + "class": "UserSettings", + "name": "QualityTagsPosition", + "type": "string", + "initializer": "\"top-left\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 59 + }, + { + "class": "UserSettings", + "name": "QualityTagsPreferredAudioLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 63 + }, + { + "class": "UserSettings", + "name": "GenreTagsPosition", + "type": "string", + "initializer": "\"top-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 64 + }, + { + "class": "UserSettings", + "name": "LanguageTagsPosition", + "type": "string", + "initializer": "\"bottom-left\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 65 + }, + { + "class": "UserSettings", + "name": "RatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 66 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 67 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 68 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnSeasons", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 69 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnEpisodes", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 70 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 71 + }, + { + "class": "UserSettings", + "name": "RatingTagsOnNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 72 + }, + { + "class": "UserSettings", + "name": "AgeRatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 73 + }, + { + "class": "UserSettings", + "name": "ShowRatingInPlayer", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 74 + }, + { + "class": "UserSettings", + "name": "RemoveContinueWatchingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 75 + }, + { + "class": "UserSettings", + "name": "LastOpenedTab", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 76 + }, + { + "class": "UserSettings", + "name": "ReviewsExpandedByDefault", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 77 + }, + { + "class": "UserSettings", + "name": "DisplayLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 78 + }, + { + "class": "UserSettings", + "name": "CalendarDisplayMode", + "type": "string", + "initializer": "\"list\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 79 + }, + { + "class": "UserSettings", + "name": "CalendarDefaultViewMode", + "type": "string", + "initializer": "\"agenda\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 80 + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "SeriesId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 89 + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "SeriesName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 92 + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 94 + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "MovieId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 102 + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "MovieName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 103 + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 104 + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "CollectionId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 116 + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "CollectionName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 117 + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 118 + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 131 + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 132 + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "DisplayName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 133 + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "RequestedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 134 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideSeriesDescriptions", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 143 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideEpisodeDescriptions", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 144 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideTags", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 145 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideChapterNames", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 146 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideTaglines", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 147 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideRatings", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 149 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideAirDate", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 150 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "ReplaceEpisodeTitles", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 151 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideCast", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 152 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideReviews", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 153 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "UseAdvancedCategories", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 160 + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "SkipDisableConfirm", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 163 + }, + { + "class": "UserSpoilerBlur", + "name": "Prefs", + "type": "SpoilerBlurUserPrefs", + "initializer": "new SpoilerBlurUserPrefs()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 225 + }, + { + "class": "UserShortcuts", + "name": "Shortcuts", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 230 + }, + { + "class": "BookmarkItem", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 235 + }, + { + "class": "BookmarkItem", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 236 + }, + { + "class": "BookmarkItem", + "name": "TvdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 237 + }, + { + "class": "BookmarkItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 238 + }, + { + "class": "BookmarkItem", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 239 + }, + { + "class": "BookmarkItem", + "name": "Timestamp", + "type": "double", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 240 + }, + { + "class": "BookmarkItem", + "name": "Label", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 241 + }, + { + "class": "BookmarkItem", + "name": "CreatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 242 + }, + { + "class": "BookmarkItem", + "name": "UpdatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 243 + }, + { + "class": "BookmarkItem", + "name": "SyncedFrom", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 244 + }, + { + "class": "BookmarkItem", + "name": "SeasonNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 245 + }, + { + "class": "BookmarkItem", + "name": "EpisodeNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 246 + }, + { + "class": "UserBookmark", + "name": "Bookmarks", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 251 + }, + { + "class": "ElsewhereSettings", + "name": "Region", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 256 + }, + { + "class": "ElsewhereSettings", + "name": "Regions", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 257 + }, + { + "class": "ElsewhereSettings", + "name": "Services", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 258 + }, + { + "class": "PendingWatchlistItem", + "name": "TmdbId", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 263 + }, + { + "class": "PendingWatchlistItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 264 + }, + { + "class": "PendingWatchlistItem", + "name": "RequestedAt", + "type": "System.DateTime", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 265 + }, + { + "class": "PendingWatchlistItems", + "name": "Items", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 270 + }, + { + "class": "ProcessedWatchlistItem", + "name": "TmdbId", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 275 + }, + { + "class": "ProcessedWatchlistItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 276 + }, + { + "class": "ProcessedWatchlistItem", + "name": "ProcessedAt", + "type": "System.DateTime", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 277 + }, + { + "class": "ProcessedWatchlistItem", + "name": "Source", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 284 + }, + { + "class": "ProcessedWatchlistItems", + "name": "Items", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 289 + }, + { + "class": "HiddenContentItem", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 294 + }, + { + "class": "HiddenContentItem", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 295 + }, + { + "class": "HiddenContentItem", + "name": "Type", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 296 + }, + { + "class": "HiddenContentItem", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 297 + }, + { + "class": "HiddenContentItem", + "name": "HiddenAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 298 + }, + { + "class": "HiddenContentItem", + "name": "PosterPath", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 299 + }, + { + "class": "HiddenContentItem", + "name": "SeriesId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 300 + }, + { + "class": "HiddenContentItem", + "name": "SeriesName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 301 + }, + { + "class": "HiddenContentItem", + "name": "SeasonNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 302 + }, + { + "class": "HiddenContentItem", + "name": "EpisodeNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 303 + }, + { + "class": "HiddenContentItem", + "name": "HideScope", + "type": "string", + "initializer": "\"global\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 304 + }, + { + "class": "HiddenContentSettings", + "name": "Enabled", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 309 + }, + { + "class": "HiddenContentSettings", + "name": "FilterLibrary", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 310 + }, + { + "class": "HiddenContentSettings", + "name": "FilterDiscovery", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 311 + }, + { + "class": "HiddenContentSettings", + "name": "FilterUpcoming", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 312 + }, + { + "class": "HiddenContentSettings", + "name": "FilterCalendar", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 313 + }, + { + "class": "HiddenContentSettings", + "name": "FilterSearch", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 314 + }, + { + "class": "HiddenContentSettings", + "name": "FilterRecommendations", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 315 + }, + { + "class": "HiddenContentSettings", + "name": "FilterRequests", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 316 + }, + { + "class": "HiddenContentSettings", + "name": "FilterNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 317 + }, + { + "class": "HiddenContentSettings", + "name": "FilterContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 318 + }, + { + "class": "HiddenContentSettings", + "name": "ShowHideButtons", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 319 + }, + { + "class": "HiddenContentSettings", + "name": "ShowHideConfirmation", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 320 + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonJellyseerr", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 321 + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonLibrary", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 322 + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonDetails", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 323 + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonCast", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 324 + }, + { + "class": "HiddenContentSettings", + "name": "ExperimentalHideCollections", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 325 + }, + { + "class": "UserHiddenContent", + "name": "Items", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 330 + }, + { + "class": "UserHiddenContent", + "name": "Settings", + "type": "HiddenContentSettings", + "initializer": "new HiddenContentSettings()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 331 + }, + { + "class": "UserReview", + "name": "UserId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 340 + }, + { + "class": "UserReview", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 341 + }, + { + "class": "UserReview", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 343 + }, + { + "class": "UserReview", + "name": "Content", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 344 + }, + { + "class": "UserReview", + "name": "Rating", + "type": "double?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 346 + }, + { + "class": "UserReview", + "name": "CreatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 347 + }, + { + "class": "UserReview", + "name": "UpdatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 348 + }, + { + "class": "AllReviewsStore", + "name": "Reviews", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 357 + }, + { + "class": "ActivityEntry", + "name": "UserId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 368 + }, + { + "class": "ActivityEntry", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 370 + }, + { + "class": "ActivityEntry", + "name": "ActivityType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 372 + }, + { + "class": "ActivityEntry", + "name": "OccurredAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 373 + }, + { + "class": "ActivityEntry", + "name": "Completed", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 382 + }, + { + "class": "ActivityEntry", + "name": "Progress", + "type": "double", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 388 + }, + { + "class": "AllActivityStore", + "name": "Entries", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "line": 399 + } + ], + "storage_references": [ + { + "literal": "reviews.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "line": 398 + }, + { + "literal": "reviews.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "line": 549 + }, + { + "literal": "activity.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "line": 773 + }, + { + "literal": "processed-watchlist-items.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "line": 905 + }, + { + "literal": "processed-watchlist-items.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "line": 911 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3807 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3808 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3808 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3809 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3809 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3810 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3810 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 3811 + }, + { + "literal": "locales/{lang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4466 + }, + { + "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{sanitizedLang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4470 + }, + { + "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{baseLang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4480 + }, + { + "literal": "user-settings/{userId}/settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4673 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4695 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4764 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4769 + }, + { + "literal": "user-settings/{userId}/shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4786 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4797 + }, + { + "literal": "user-settings/{userId}/elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4801 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4812 + }, + { + "literal": "user-settings/{userId}/settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4816 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4830 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4855 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4861 + }, + { + "literal": "user-settings/{userId}/shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4870 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4883 + }, + { + "literal": "Saved user shortcuts for {ResolveUserDisplay(authorizedUserId)} to shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4884 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4885 + }, + { + "literal": "user-settings/{userId}/bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4894 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4906 + }, + { + "literal": "user-settings/{userId}/bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4910 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4923 + }, + { + "literal": "Saved enhanced bookmarks for {ResolveUserDisplay(authorizedUserId)} to bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4924 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4925 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 4969 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5017 + }, + { + "literal": "user-settings/{userId}/elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5038 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5051 + }, + { + "literal": "Saved user elsewhere settings for {ResolveUserDisplay(authorizedUserId)} to elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5052 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5053 + }, + { + "literal": "user-settings/{userId}/hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5062 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5086 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5091 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5106 + }, + { + "literal": "user-settings/{userId}/hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5133 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5151 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5157 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5171 + }, + { + "literal": "Saved hidden content for {ResolveUserDisplay(authorizedUserId)} to hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5174 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5175 + }, + { + "literal": "user-settings/{userId}/spoilerblur.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5187 + }, + { + "literal": "user-settings/{userId}/spoilerblur.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5216 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5369 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5423 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5470 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 5544 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6874 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6878 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 6959 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7732 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "line": 7745 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", + "line": 81 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", + "line": 207 + }, + { + "literal": "config.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs", + "line": 401 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", + "line": 413 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", + "line": 416 + }, + { + "literal": ".meta.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "line": 507 + }, + { + "literal": ".meta.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "line": 649 + }, + { + "literal": ".meta.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "line": 650 + }, + { + "literal": "component-scripts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs", + "line": 48 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs", + "line": 19 + }, + { + "literal": "https://raw.githubusercontent.com/n00bcodr/jellyfin-plugins/main/manifest.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs", + "line": 48 + }, + { + "literal": "maintenance-state.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs", + "line": 71 + }, + { + "literal": "mdblist-ratings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs", + "line": 82 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", + "line": 18 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", + "line": 33 + }, + { + "literal": "icons.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "line": 211 + }, + { + "literal": "vector-icons.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "line": 228 + }, + { + "literal": "quality-colours.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "line": 247 + }, + { + "literal": "age-rating-colours.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "line": 267 + }, + { + "literal": "spoilerblur.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs", + "line": 45 + }, + { + "literal": "tag-cache.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs", + "line": 229 + }, + { + "literal": "tmdb-cache.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs", + "line": 167 + }, + { + "literal": "usage-counters.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs", + "line": 53 + }, + { + "literal": "Jellyfin.Plugin.JellyfinEnhanced.Configuration.whats-new-seed.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", + "line": 35 + }, + { + "literal": "config-schema-snapshot.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", + "line": 57 + }, + { + "literal": "whats-new.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", + "line": 58 + }, + { + "literal": "awards.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs", + "line": 83 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", + "line": 59 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", + "line": 72 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js", + "line": 444 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js", + "line": 810 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js", + "line": 262 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "line": 275 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "line": 305 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "line": 336 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "line": 386 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "line": 427 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", + "line": 58 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", + "line": 62 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", + "line": 62 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js", + "line": 145 + }, + { + "literal": "en.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", + "line": 60 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", + "line": 142 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 54 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 150 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 157 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 172 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 213 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 244 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 296 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 340 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 389 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 514 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 572 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 592 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 602 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 616 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 617 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 651 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 687 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "line": 813 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", + "line": 65 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", + "line": 157 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js", + "line": 258 + }, + { + "literal": "/JellyfinEnhanced/locales/en.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", + "line": 175 + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "line": 907 + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "line": 908 + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "line": 909 + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "line": 910 + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "line": 911 + } + ], + "scheduled_tasks": [ + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AnalyticsReportTask.cs", + "class": "AnalyticsReportTask", + "key": "JellyfinEnhancedAnalyticsReport" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs", + "class": "ArrTagsSyncTask", + "key": "JellyfinEnhancedArrTagsSync" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AudioLanguageTagsSyncTask.cs", + "class": "AudioLanguageTagsSyncTask", + "key": "JellyfinEnhancedAudioLanguageTagsSync" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/BuildTagCacheTask.cs", + "class": "BuildTagCacheTask", + "key": "JellyfinEnhancedBuildTagCache" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ClearTranslationCacheTask.cs", + "class": "ClearTranslationCacheTask", + "key": "JellyfinEnhancedClearTranslationCache" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs", + "class": "JellyfinToSeerrWatchlistSyncTask", + "key": "JellyfinEnhancedJellyfinToSeerrWatchlistSync" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrUserImportTask.cs", + "class": "JellyseerrUserImportTask", + "key": "JellyfinEnhancedJellyseerrUserImport" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs", + "class": "JellyseerrWatchlistSyncTask", + "key": "JellyfinEnhancedJellyseerrWatchlistSync" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchTask.cs", + "class": "MdblistRatingsFetchTask", + "key": "JellyfinEnhancedMdblistRatingsFetch" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsSyncTask.cs", + "class": "MdblistRatingsSyncTask", + "key": "JellyfinEnhancedMdblistRatingsSync" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/RefreshCdnAssetsTask.cs", + "class": "RefreshCdnAssetsTask", + "key": "JellyfinEnhancedRefreshCdnAssets" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/SpoilerApplyExistingTitlesTask.cs", + "class": "SpoilerApplyExistingTitlesTask", + "key": "JellyfinEnhancedSpoilerApplyExistingTitles" + } + ], + "locales": [ + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json", + "top_level_keys": 722 + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json", + "top_level_keys": 722 + } + ], + "counts": { + "build_targets": 2, + "files": 339, + "routes": 167, + "configuration_properties": 501, + "storage_references": 135, + "scheduled_tasks": 12, + "locales": 33 + } +} diff --git a/tests/docs/progress.md b/tests/docs/progress.md new file mode 100644 index 000000000..a8fce9a44 --- /dev/null +++ b/tests/docs/progress.md @@ -0,0 +1,62 @@ +# Regression implementation progress + +No commits or pushes were made. Production fixes, tests and documentation remain reviewable in the existing worktree. + +## Test directory organization + +All regression test code, harnesses, fixtures, orchestration and documentation now live under `tests/`. Entry point: `python3 tests/run.py all`; layout and contribution rules: [tests README](../README.md). CI/npm paths, historical manifests, C# project references and browser configuration were updated. Shared backend fixtures live in `backend/Support/`; frontend discovery is recursive and rejects an empty suite. Fresh-copy fast validation passed both backend targets (693 each), frontend (333) and tooling (8). Full relocated-suite validation passed all 17 stages (exit 0), including browser/visual checks, both actual hosts, poster checks, and standard/historical fault replay; evidence: `artifacts/test-organization/full/summary.json`. No commits or pushes. + +## perf/round2 comparison + +Compared `6fcc633e` to `8b8a5baa` with identical tests and no local production-fix overlay. Found one new discovery retry regression; both backend targets share the same 34 existing failures, while actual-host and browser smoke checks pass. Added five targeted backend cases and one discovery test, and corrected unrealistic Episode fixtures. See [the comparison report](perf-round2.md) for failed, passed and timed-out checks. The performance branch remains untouched and uncommitted. + +## Historical audit follow-up + +The [issue/PR audit](history-audit.md) adds nine frontend tests, thirteen backend cases, and replayable historical faults. These changes postdate the full/fresh-tree run below. The earlier completion claim was too broad: many feasible behavioral gaps remain, and the broader comprehensive-testing goal is not complete. Follow-up integrated checks passed: 688 backend tests per target, 332 instrumented frontend tests and seven tooling checks. Coverage is 47.14%/47.16% backend lines and 26.10% frontend lines. All 13 historical fault scenarios (17 experiments across targets) also passed their baseline/failure/restoration checks. Historical fault replay is recorded separately in [validation.md](validation.md). No commits or pushes. + +## Previous full-run validation state + +The earlier code-frozen complete run finished successfully (exit 0). Its authoritative report is `artifacts/final-validation/summary.json`; console log `/tmp/je-all-verified.log`. The final clean disposable checkout run also finished with exit 0: `artifacts/clean-final/summary.json` and its `regression/summary.json` record every stage passing against the same frozen sources, with the same test counts. + +| Layer | Latest verified evidence before final run | +|---|---| +| Backend | Final frozen run passed 675/675 on each jf10/net9 and jf12/net10, zero failures/skips. Coverage: jf10 47.03% lines / 34.90% branches; jf12 47.04% lines / 34.93% branches. | +| Frontend | Final frozen instrumented run passed 323/323 with zero failures/skips. All production JavaScript: 23.55% statements, 18.40% branches, 24.98% functions, 24.50% lines. | +| Mocked browser | 39 cases passed across Chromium desktop, mobile Chromium and Firefox, including three reviewed visual baselines. These use mocked fixture pages. | +| Actual Jellyfin hosts | Final frozen run: both digest-pinned 10.11.11 and 12.0 passed 45 HTTP scenarios plus a real Chromium browser flow (46 report entries per host). Browser flow covers regular/admin login, injected JE/bootstrap, Enhanced panel Auto Pause save and persistence after reload. | +| Posters | 140 pipeline checks; strict metadata parity over 40 profiles × 101 fixtures; 115,374 language-name and 31,676 casing comparisons; all 253 flag assets and HarfBuzz shaping on Skia 3.116.1/3.119.4. | +| Tooling and mutation | Five tooling tests passed. All four selected production fault types were killed on each target, with passing baseline and restored-source checks. | +| Inventory | `python3 tests/inventory/generate.py --check` passes after regeneration from current production sources. | + +Current coverage floors: backend 38% lines / 27% branches; frontend 20% statements / 15% branches / 21% functions / 21% lines. These guard against regressions in established coverage; they do not establish comprehensive behavioral coverage. Every production feature area is assessed in [coverage-matrix.md](coverage-matrix.md), including remaining gaps. + +## Reproduced regressions and corrections + +Tests were observed failing before these fixes, or deliberately injected faults were detected: + +- Malformed review-store shapes could be overwritten; strict shape validation preserves existing data. +- Corrupt TMDB and awards cache entries could prevent startup; invalid entries are rejected while valid cache data survives. +- Maintenance restoration failures could lose pending intent or overwrite it during reconciliation. Journal/checkpoint/retry tests now preserve unresolved work; [cross-store transaction limits](maintenance-recovery.md) remain explicit. +- Usage counters needed malformed-state validation and Gregorian periods under non-Gregorian cultures. +- DOM observer replacement could retain wrong priorities, disconnect the wrong registration, leak dedicated observers or loop during dispatch re-registration. API cache replacement could evict an unrelated entry. +- Previous-user responses could repopulate bookmark/spoiler/hidden-content/media-info state. Regressions exercise logout/switch during successful and failed asynchronous operations. +- Pause/release-date content needed escaping and cleanup. Old Letterboxd and More Info responses could decorate a replacement page/modal; generation/lifecycle cases prevent this. +- Watch-provider region/logo validation accepted a trailing newline; strict end-of-input matching now rejects it. +- Native blur replacement leaked the consumed input stream; actual MVC stream-result ownership tests detect it. +- Existing language parity checks printed mismatches without failing. Strict failure behavior exposed 51 Baku alias mismatches; production mapping and resolver version were corrected. A deliberately wrong oracle now fails verification. + +Targeted mutation verification is documented in [mutations.md](mutations.md), including authorization, parental policy, negative-cache resource bounds and stale-user responses. Additional isolated API/navigation probes were restored after detection. Intermediate failing snapshots taken during those probes are not final regressions. + +## Execution and evidence locations + +- Main entry point: `python3 tests/run.py all` (see [testing README](README.md) for prerequisites and selectable suites). +- Real-host runner: `python3 tests/host/run.py --target all --browser`; reports and browser evidence live under `.engineering-artifacts/host/{jf10,jf12}/`. +- Backend tests: `dotnet test tests/backend/JE.Tests.csproj -p:JellyfinTarget=jf10` and `jf12`; use separate artifact directories to avoid concurrent target output collisions. +- Frontend: `npm run test:frontend:coverage`; browser: `npm run test:browser`. +- Initial isolated logs used disposable `/tmp/je-*` paths; final retained artifacts are authoritative and should be used for review. + +## Ownership and review + +Independent agents owned core persistence/maintenance, provider integrations, privacy/filters, API/middleware, tasks, poster rendering/parity, browser fixtures, actual host infrastructure, frontend runtime/features/pages/request flows, and inventory. Backend/frontend reviewers identified missing behaviors and weak assertions; contributors added and executed focused regressions. The coordinating agent owns integrated and clean-environment validation. + +Remaining limits include real-host playback/native image journeys, selected enabled import/reverse-sync error paths, header/shortcut UI, cross-store maintenance crash ambiguity, and unexecuted feature combinations. Passing suites are not reported as proof that these gaps are covered. diff --git a/tests/docs/validation.md b/tests/docs/validation.md new file mode 100644 index 000000000..f7af7a656 --- /dev/null +++ b/tests/docs/validation.md @@ -0,0 +1,96 @@ +# Local validation — 2026-10-05 + +All implementation remains uncommitted. The repository HEAD remains `8b8a5baa`; no push, release, or GitHub Actions run was performed. + +Commands in this document use the current organized paths under `tests/`. Archived logs retain the exact paths used when each earlier run executed. Directory-reorganization validation is recorded separately below; historical passing runs do not by themselves validate moved entry points. + +## Directory organization validation + +The relocated complete entry point exited **0**, with **all 17 stages passed**: + +```sh +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/run.py all --artifacts artifacts/test-organization/full +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/runner/clean.py --suite fast --artifacts artifacts/test-organization/clean +``` + +The full run passed 693 backend tests on each target, 333 instrumented frontend tests, eight tooling checks, 39 browser tests including unchanged visual baselines, poster pipeline/parity/assets checks, both actual Jellyfin hosts (46 report entries each), and standard plus historical fault replay. Inventory and coverage checks passed. Evidence: `artifacts/test-organization/full/summary.json`; console log: `/tmp/je-organization-all.log`. + +The fresh disposable copy installed dependencies and passed the **fast** suite on both targets with the same backend/frontend/tooling counts. Evidence: `artifacts/test-organization/clean/summary.json`. The full suite was run in the working tree; it was not rerun in that fresh copy. No visual baselines were updated. No commits or pushes were made. These results validate the current regression worktree, not the `perf/round2` branch, and do not imply complete behavioral coverage. + +## Historical audit follow-up + +The [historical audit](history-audit.md) postdates the full run below. Its integrated `fast` run passed **688 backend tests on each target, 332 instrumented frontend tests, seven tooling checks, and inventory verification**, with zero failed or skipped tests. Commands on this machine: + +```sh +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/run.py fast --artifacts artifacts/history-audit/integrated +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/run.py history --artifacts artifacts/history-audit/history-suite +``` + +The historical entry point also exited **0**: all **13 distinct fault scenarios** were detected, with all **17 target-specific experiments** passing baseline/failure/restoration checks. Both backend targets and all three frontend history groups passed. + +Follow-up coverage: backend jf10 **47.14% lines / 35.00% branches**, jf12 **47.16% / 35.02%**; frontend **26.10% lines / 19.86% branches** (25.07% statements, 26.15% functions). No exclusions or relaxed thresholds were added. Historical replay results are recorded in `artifacts/history-audit/history-suite/summary.json` and nested per-case reports. Each successful proof requires passing baseline/restored tests and the expected failure with the fault present. + +The pre-audit 323 frontend tests passed with each of three historical faults restored; new tests detect those faults. The original native poster pipeline also passed with the Jellium exclusion removed; the new independent client test detects it. Proof details and exact-versus-representative distinctions are in the audit. + +Browser visual/full real-host/fresh-tree suites were **not rerun for this test-only follow-up**; their evidence below belongs to the earlier snapshot. Scheduled CI was updated locally but not executed on GitHub. The comprehensive coverage goal remains unfinished; many historical reports and behavioral branches are unproven. + +## Previous full integrated run + +This command exited **0**, with every stage passed: + +```sh +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/run.py all --artifacts artifacts/final-validation +``` + +The temporary .NET installation supplies the runtime versions missing from this machine's default installation. Other machines should follow the [setup instructions](README.md); they should not depend on this `/tmp` path. + +| Suite | Result | +| --- | --- | +| Tooling false-green checks | 5 passed | +| Backend, Jellyfin 10.11 / .NET 9 | 675 passed; 0 failed, skipped, or unexecuted | +| Backend, Jellyfin 12 / .NET 10 | 675 passed; 0 failed, skipped, or unexecuted | +| JavaScript, instrumented production modules | 323 passed; 0 failed or skipped | +| Playwright component/visual checks | 39 passed across Chromium desktop/mobile and Firefox; 3 visual baselines matched | +| Native poster pipeline | 140 checks passed | +| JavaScript/C# poster parity | All synthetic profiles matched; 115,374 language names and 31,676 casing strings matched | +| Native assets | 253 flags decoded and shaping checks passed on Skia 3.116.1 and 3.119.4 | +| Real Jellyfin 10.11.11 and 12.0 | 45 API checks per host plus actual Chromium regular/admin login, bootstrap, panel save, and reload journeys | +| Isolated mutation checks | Four fault types detected by their intended assertions, with passing baselines and restored runs on both targets | + +Machine-readable stage status, commands, durations, logs, TRX reports, coverage, host reports, and mutation evidence are under `artifacts/final-validation/`. Browser component reports are under `artifacts/browser/`; frontend coverage is under `artifacts/frontend-coverage/`. These generated files are intentionally ignored by Git. + +Measured production coverage, without excluding untested production modules: + +| Target | Line coverage | Branch coverage | +| --- | --- | --- | +| Backend jf10 | 47.03% | 34.90% | +| Backend jf12 | 47.04% | 34.93% | +| Frontend | 24.50% | 18.40% | + +Frontend statement coverage is 23.55% and function coverage is 24.98%. These are partial behavioral coverage, not a claim that every feature combination is tested. The [coverage matrix](coverage-matrix.md) records remaining cases explicitly. + +## Fresh source-tree verification + +The final source snapshot passed the complete suite again, with exit code **0**, using: + +```sh +PATH=/tmp/je-dotnet:$PATH DOTNET_ROOT=/tmp/je-dotnet \ + python3 tests/runner/clean.py --artifacts artifacts/clean-final +``` + +This copies tracked and unignored local files into a fresh temporary directory, installs JavaScript dependencies with `npm ci`, restores locked .NET dependencies, and runs the complete entry point. It records source hashes in `artifacts/clean-final/source-manifest.json` and removes its temporary source tree afterward. The final passing result is recorded in `artifacts/clean-final/summary.json`; every stage in its `regression/summary.json` passed. Counts match the integrated run above. + +An earlier complete fresh-tree checkpoint also passed; its reports under `artifacts/clean/` predate the final additions and must not be substituted for the final snapshot. + +## Additional compatibility and review + +- The final 323 JavaScript tests also passed in an isolated, network-disabled Node 22.23.1 Linux container. The main run used Node 26.2.0, matching the generated ICU poster tables, and Playwright 1.58.2. +- Both backend compatibility targets were built using .NET SDK 10.0.108 with suitable .NET 9/10 runtimes. Real hosts use the digest-pinned images in `tests/host/run.py`. +- Independent reviews identified additional regressions in observer dispatch, asynchronous ownership, watchlist deduplication, and maintenance checkpoints; targeted tests reproduced the failures before fixes. +- `git diff --check`, workflow YAML parsing, inventory freshness, and lockfile restoration passed. Workflow execution on GitHub remains unexecuted because this work is intentionally local. +- Native clients, complete playback workflows, every page's accessibility/layout, and additional feature combinations remain unverified. Maintenance's cross-store recovery limitations are documented in [maintenance recovery](maintenance-recovery.md). diff --git a/tests/frontend/README.md b/tests/frontend/README.md new file mode 100644 index 000000000..bdf05a3bd --- /dev/null +++ b/tests/frontend/README.md @@ -0,0 +1,44 @@ +# Frontend regression tests + +Run `npm ci`, then `npm run test:frontend` (Node 22.12 or newer). Run +`npm run test:frontend:coverage` for instrumented production-module coverage. +These are isolated DOM/module tests, not tests against a live Jellyfin host. +`npm run test:browser` provides the separate browser suite. + +Each test evaluates real classic-script production modules in a fresh jsdom +window. The shared helper refuses unexpected network access, closes windows and +timers, and fails on uncaught browser errors. Tests supply synthetic host APIs +and local responses. Register `t.after(() => harness.close())` in every test. +Use deferred promises for concurrency tests and actual MutationObserver delivery +for DOM behavior. Do not add arbitrary sleeps or retries. + +Coverage instruments loaded modules and adds zero-coverage entries for **every** +production `.js` file under `Jellyfin.Plugin.JellyfinEnhanced/js`. There are no +production-module exclusions. jsdom/dependencies, test fixtures, and HTML inline +scripts are outside this JS coverage report. Reports are written to +`artifacts/frontend-coverage` (HTML, LCOV, JSON summary). Browser-suite execution +is not merged into this unit/DOM report. + +Minimum floors are 20% statements, 21% lines, 15% branches, and 21% +functions across the whole production inventory; these deliberately expose the +large remaining untested surface. The API, DOM observer, lifecycle, navigation, +and session modules individually require 60% statements and 45% branches. +These measured regression floors are not a completeness target. Raise them when +new coverage lands; never lower them merely to accept lost coverage. The +behavior coverage matrix records remaining gaps separately. + +Mutation evidence: temporarily removing the API cache's session-epoch guard +caused `late previous-user response cannot repopulate cleared cache` to fail with +user A's response present after switching to B. The original source was restored. +The priority-subscriber replacement test also failed before the corresponding +DOM observer fix: a normal subscriber left at the start of the queue prevented +remaining high-priority subscribers from running before paint. + +Unexpected console errors fail teardown; negative tests must explicitly allow +only their expected diagnostic with `h.expectConsoleError(/specific message/)`. +Unexpected default fetch calls fail teardown even when production catches them. + +Specs are grouped by feature under `bootstrap/`, `core/`, `features/`, `pages/`, +`requests/`, and `regressions/`. Both runners discover nested `*.test.mjs` files +through `helpers/discovery.mjs`; an empty discovery fails. Shared helpers live +in `helpers/`. See the [test directory guide](../README.md) for contribution rules. diff --git a/tests/frontend/bootstrap/bootstrap-header.test.mjs b/tests/frontend/bootstrap/bootstrap-header.test.mjs new file mode 100644 index 000000000..3b770ff37 --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-header.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +async function until(fn) { + const deadline = performance.now() + 3000; + while (!fn() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5)); + assert.ok(fn(), 'header update did not finish'); +} +function header(t, width = 170) { + let reset, signedIn = true; + const h = createHarness({ html: '

    ', + JE: { t: key => key, session: { getUserId: () => signedIn ? 'user' : null, onUserChange: (_, callback) => { reset = callback; } }, helpers: {} }, + globals: { ResizeObserver: class { observe() {} unobserve() {} disconnect() {} } } }); + t.after(() => h.close()); + h.JE.helpers = { getHeaderRightContainer: () => h.document.querySelector('.headerRight'), onBodyMutation() {}, + addCSS(id, css) { const style = h.document.createElement('style'); style.id = id; style.textContent = css; h.document.head.appendChild(style); } }; + Object.defineProperty(h.window.HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return this.classList.contains('headerTop') ? width : 40; } }); + h.window.HTMLElement.prototype.getClientRects = function() { return [this.getBoundingClientRect()]; }; + h.window.HTMLElement.prototype.getBoundingClientRect = function() { return { left: 0, right: 40, top: 0, bottom: 40, width: 40, height: 40 }; }; + h.window.HTMLDialogElement.prototype.close = function() { this.removeAttribute('open'); }; + h.window.HTMLDialogElement.prototype.showModal = function() { this.setAttribute('open', ''); }; + h.load('enhanced/header-actions.js'); + const tray = h.JE.headerActions.getTray(); + for (const id of ['randomItemButton', 'calendar', 'requests', 'bookmarks']) { + const button = h.document.createElement('button'); button.id = id; button.className = 'headerButton'; button.title = `<${id}>`; + button.innerHTML = `event`; tray.appendChild(button); + } + return { ...h, tray, resize(next) { width = next; h.window.dispatchEvent(new h.window.Event('resize')); }, logout() { signedIn = false; reset(); } }; +} + +test('narrow header preserves foreign plugin buttons and forwards overflow actions to mounted originals', async t => { + const h = header(t, 130); + await until(() => h.document.querySelector('[data-je-header-action="requests"]')); + const original = h.document.getElementById('requests'); let clicks = 0; original.addEventListener('click', () => clicks++); + const mirror = h.document.querySelector('[data-je-header-action="requests"]'); + assert.equal(mirror.querySelector('[id]'), null); + assert.equal(mirror.querySelector('.je-launcher-label').textContent, ''); + assert.equal(mirror.querySelector('requests'), null); + mirror.click(); assert.equal(clicks, 1); assert.equal(h.document.getElementById('requests'), original); + const foreign = h.document.getElementById('foreign'); + assert.equal(foreign.parentElement.className.includes('headerRight'), true); + assert.equal(foreign.hasAttribute('aria-hidden'), false); + assert.equal(h.document.querySelector('[data-je-header-action="foreign"]'), null); + assert.equal(h.document.getElementById('randomItemButton').classList.contains('je-header-overflowed'), false); +}); + +test('expanding header restores original actions and clears obsolete mirrors without duplicate trays', async t => { + const h = header(t, 130); await until(() => h.document.querySelector('.je-launcher-action')); + assert.equal(h.JE.headerActions.getTray(), h.tray); + h.resize(1000); + await until(() => h.document.querySelectorAll('.je-launcher-action').length === 0); + assert.equal(h.document.querySelectorAll('#je-header-buttons-group').length, 1); + assert.equal(h.tray.querySelectorAll('.je-header-overflowed').length, 0); + assert.equal(h.document.getElementById('je-header-launcher').style.display, 'none'); +}); + +test('identity reset discards previous user actions and signed-out tray access', async t => { + const h = header(t, 130); await until(() => h.document.querySelector('.je-launcher-action')); + h.logout(); + assert.equal(h.JE.headerActions.getTray(), null); + assert.equal(h.document.getElementById('requests'), null); + assert.equal(h.document.querySelector('.je-launcher-action'), null); + assert.ok(h.document.getElementById('foreign')); +}); + +test('injected icon stylesheet keeps public full fonts distinct from JE subsets and respects theme classes', t => { + const h = createHarness({ html: 'public_icon
    storage
    ', + JE: { cdn: { font: name => `/JellyfinEnhanced/fonts/${name}` } } }); + t.after(() => h.close()); h.load('enhanced/ui-styles.js'); h.JE.injectGlobalStyles(); h.JE.injectGlobalStyles(); + assert.equal(h.document.querySelectorAll('#jellyfin-enhanced-styles').length, 1); + assert.equal(h.window.getComputedStyle(h.document.getElementById('foreign')).fontFamily, '"Material Symbols Rounded"'); + assert.equal(h.window.getComputedStyle(h.document.getElementById('own')).fontFamily, '"JE Material Symbols Rounded"'); + const faces = [...h.document.getElementById('jellyfin-enhanced-styles').sheet.cssRules].filter(rule => rule.type === 5); + for (const face of faces) { + const family = face.style.getPropertyValue('font-family'); + const src = face.style.getPropertyValue('src'); + assert.equal(src.includes('-subset.woff2'), family.includes('JE Material')); + } + assert.equal(faces.length, 4); + const theme = h.document.createElement('style'); theme.textContent = '.material-symbols-rounded { font-family: ThemeIcons; }'; h.document.head.appendChild(theme); + assert.equal(h.window.getComputedStyle(h.document.getElementById('foreign')).fontFamily, 'ThemeIcons'); + assert.equal(h.window.getComputedStyle(h.document.getElementById('own')).fontFamily, '"JE Material Symbols Rounded"'); +}); diff --git a/tests/frontend/bootstrap/bootstrap-native-tabs.test.mjs b/tests/frontend/bootstrap/bootstrap-native-tabs.test.mjs new file mode 100644 index 000000000..f976bbc41 --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-native-tabs.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, deferred } from '../helpers/harness.mjs'; + +async function until(fn) { + const deadline = performance.now() + 3000; + while (!fn() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5)); + assert.ok(fn(), 'native tab reconciliation did not finish'); +} +const home = `
    `; +function setup(t, { count = 0, hash = '#/home', configRequest } = {}) { + const h = createHarness({ url: `http://jellyfin.test/web/index.html${hash}`, html: `
    ${home}`, + JE: { pluginConfig: {}, hasCustomTabs: count > 0 || !!configRequest }, apiClient: { fetch: configRequest || (async () => Array(count).fill({})) } }); + t.after(() => h.close()); + Object.defineProperty(h.window.HTMLElement.prototype, 'offsetParent', { configurable: true, get() { return this.parentElement; } }); + for (const file of ['core/navigation.js', 'core/lifecycle.js', 'core/dom-observer.js', 'core/ui-kit.js', 'enhanced/helpers.js', 'enhanced/native-tabs.js']) h.load(file); + const tabs = h.document.querySelector('[is="emby-tabs"]'); + let refreshes = 0, selections = 0; + tabs.refresh = () => refreshes++; + tabs.selectedTabIndex = 0; + tabs.selectedIndex = function(index) { + if (index === undefined) return this.selectedTabIndex; + selections++; + this.dispatchEvent(new h.window.CustomEvent('beforetabchange', { detail: { selectedTabIndex: index } })); + this.selectedTabIndex = index; + h.document.querySelectorAll('.emby-tab-button')[index]?.classList.add('emby-tab-button-active'); + h.document.querySelector('#home').querySelectorAll('.tabContent')[index]?.classList.add('is-active'); + }; + const mountCounts = {}; + function register(id = 'requests') { + h.JE.nativeTabs.register(id, `<${id}>`, panel => { mountCounts[id] = (mountCounts[id] || 0) + 1; panel.textContent = 'Mounted ' + id; }); + } + const panel = (id = 'requests') => h.document.getElementById(`je-native-tab-panel-${id}`); + const button = (id = 'requests') => h.document.getElementById(`je-native-tab-btn-${id}`); + const reconcile = () => h.window.history.replaceState({}, '', h.window.location.href); + return { ...h, tabs, register, panel, button, mountCounts, reconcile, get refreshes() { return refreshes; }, get selections() { return selections; } }; +} + +function assertAligned(h) { + for (const selector of ['.emby-tabs-slider .emby-tab-button', '#home > .tabContent']) { + const nodes = [...h.document.querySelectorAll(selector)]; + assert.deepEqual(nodes.map(node => Number(node.dataset.index)), nodes.map((_, index) => index)); + } +} + +test('native tabs mount once, escape labels, and remain stable across duplicate registration and mutations', async t => { + const h = setup(t); h.register(); h.register(); + await until(() => h.panel()); + const panel = h.panel(); const button = h.button(); + assert.equal(button.textContent, ''); assert.equal(button.querySelector('requests'), null); + h.register(); panel.appendChild(h.document.createElement('span')); + h.document.dispatchEvent(new h.window.CustomEvent('viewshow')); + await until(() => h.refreshes === 1); + assert.equal(h.panel(), panel); assert.equal(h.button(), button); + assert.equal(h.mountCounts.requests, 1); assertAligned(h); +}); + +test('Custom Tabs count reserves positional slots before JE tabs', async t => { + const h = setup(t, { count: 3 }); h.register(); h.register('calendar'); + await until(() => h.panel('calendar')); + assert.equal(h.button().dataset.index, '5'); assert.equal(h.panel('calendar').dataset.index, '6'); + assert.equal(h.document.querySelectorAll('[id^="je-native-tab-reserved-btn-"]').length, 3); + assertAligned(h); +}); + +test('late Custom Tabs DOM collision moves existing JE panels without remounting or losing content', async t => { + const h = setup(t); h.register(); await until(() => h.panel()); + const original = h.panel(); + const foreignButton = h.document.createElement('button'); foreignButton.className = 'emby-tab-button'; foreignButton.dataset.index = '2'; + h.document.querySelector('.emby-tabs-slider').appendChild(foreignButton); + const foreignPanel = h.document.createElement('div'); foreignPanel.className = 'tabContent pageTabContent'; foreignPanel.dataset.index = '2'; + h.document.querySelector('#home').appendChild(foreignPanel); + await until(() => h.button().dataset.index === '3'); + assert.equal(h.panel(), original); assert.equal(h.mountCounts.requests, 1); assertAligned(h); +}); + +test('header-only external tab claims get placeholders to preserve positional selection', async t => { + const h = setup(t); const external = h.document.createElement('a'); external.href = '#/home?tab=4'; h.document.body.appendChild(external); + h.register(); await until(() => h.panel()); + assert.equal(h.button().dataset.index, '5'); assertAligned(h); +}); + +test('stable JE deep link rewrites stale index and clears partially selected host panels', async t => { + const h = setup(t, { count: 2, hash: '#/home?tab=2&jeTab=requests' }); h.register(); + await until(() => h.panel()?.classList.contains('is-active')); + assert.equal(h.window.location.hash, '#/home?tab=4&jeTab=requests'); + assert.equal(h.tabs.selectedIndex(), 4); + assert.equal(h.document.querySelectorAll('.tabContent.is-active').length, 1); + assert.equal(h.document.querySelectorAll('.emby-tab-button-active').length, 1); +}); + +test('user selection consumes deep link so later mutations do not steal the chosen tab', async t => { + const h = setup(t, { hash: '#/home?tab=2&jeTab=requests' }); h.register(); + await until(() => h.panel()?.classList.contains('is-active')); + h.tabs.selectedIndex(1); + h.panel().appendChild(h.document.createElement('span')); + await until(() => h.tabs.selectedIndex() === 1); + // Let the actual DOM observer and after-paint reconciliation complete. + await new Promise(resolve => h.JE.core.dom.afterNextPaint(() => h.JE.core.dom.afterNextPaint(resolve))); + assert.equal(h.tabs.selectedIndex(), 1); assert.equal(h.selections, 2); +}); + +test('unregister removes button, panel and allows a clean future mount', async t => { + const h = setup(t); h.register(); await until(() => h.panel()); + h.JE.nativeTabs.unregister('requests'); + assert.equal(h.panel(), null); assert.equal(h.button(), null); + h.register(); await until(() => h.panel()); + assert.equal(h.mountCounts.requests, 2); assertAligned(h); +}); + +test('unknown Custom Tabs state waits for response and recovers from provider failure', async t => { + const response = deferred(); const h = setup(t, { configRequest: () => response.promise }); h.register(); + await new Promise(resolve => h.JE.core.dom.afterNextPaint(() => h.JE.core.dom.afterNextPaint(resolve))); + assert.equal(h.panel(), null); + response.reject(new Error('plugin unavailable')); + await until(() => h.panel()); assert.equal(h.button().dataset.index, '2'); assertAligned(h); +}); diff --git a/tests/frontend/bootstrap/bootstrap-settings-panel.test.mjs b/tests/frontend/bootstrap/bootstrap-settings-panel.test.mjs new file mode 100644 index 000000000..a3a8008ba --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-settings-panel.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +const toggles = ['autoPauseToggle', 'autoResumeToggle', 'autoPipToggle', 'autoSkipIntroToggle', 'autoSkipOutroToggle', + 'randomButtonToggle', 'randomUnwatchedOnly', 'randomScopeCurrentContainer', 'showWatchProgressToggle', 'showFileSizesToggle', + 'showAudioLanguagesToggle', 'removeContinueWatchingToggle', 'qualityTagsToggle', 'genreTagsToggle', 'pauseScreenToggle', + 'languageTagsToggle', 'ratingTagsToggle', 'ageRatingTagsToggle', 'peopleTagsToggle', 'tagsHideOnHoverToggle', + 'disableCustomSubtitleStyles', 'longPress2xEnabled', 'nativePosterTagsToggle']; +function setup(t, config = {}) { + const writes = [], toasts = []; + const h = createHarness({ html: toggles.map(id => ``).join('') + '', + JE: { pluginConfig: config, userConfig: { settings: {}, shortcuts: { Shortcuts: [] } }, currentSettings: {}, + t: key => key, toast: message => toasts.push(message), pauseScreenInstance: {} }, apiClient: { ajax: async request => { writes.push(request); } } }); + t.after(() => h.close()); + h.load('enhanced/config.js'); h.JE.currentSettings = h.JE.loadSettings(); + h.load('enhanced/settingspanel/ui-panel-settings.js'); + h.JE.internals.enhancedUi.wireSettingsListeners({ createToast: (key, enabled) => `${key}:${enabled}`, resetAutoCloseTimer() {} }); + return { ...h, writes, toasts, async change(id, value) { + const input = h.document.getElementById(id); + if (input.type === 'checkbox') input.checked = value; else input.value = value; + input.dispatchEvent(new h.window.Event('change', { bubbles: true })); + await new Promise(resolve => setImmediate(resolve)); + } }; +} + +for (const [input, expected] of [['0', 5], ['-3', 1], ['200', 60], ['12', 12], ['invalid', 5]]) { + test(`pause delay ${input} persists clamped value ${expected} to the real settings endpoint`, async t => { + const h = setup(t); await h.change('pauseScreenDelayInput', input); + assert.equal(h.JE.pauseScreenInstance.pauseScreenDelayMs, expected * 1000); + assert.equal(h.writes.length, 1); + assert.ok(h.writes[0].url.endsWith('/user-a/settings.json')); + assert.equal(JSON.parse(h.writes[0].data).pauseScreenDelaySeconds, expected); + }); +} + +test('pause delay digit keys do not bubble into host playback shortcuts', t => { + const h = setup(t); let keys = 0; h.document.addEventListener('keydown', () => keys++); + h.document.getElementById('pauseScreenDelayInput').dispatchEvent(new h.window.KeyboardEvent('keydown', { key: '5', bubbles: true })); + assert.equal(keys, 0); +}); + +for (const enabled of [false, true]) test(`native poster preference is editable only with server feature enabled=${enabled}`, async t => { + const h = setup(t, { NativePosterTagsEnabled: enabled }); await h.change('nativePosterTagsToggle', false); + assert.equal(h.writes.length, enabled ? 1 : 0); + assert.equal(h.JE.userConfig.settings.useNativePosterTags, enabled ? false : undefined); + if (enabled) assert.equal(JSON.parse(h.writes[0].data).useNativePosterTags, false); +}); + +test('quality toggle initializes on enable and removes overlays on disable while persisting each choice', async t => { + const h = setup(t); let initialized = 0; h.JE.initializeQualityTags = () => initialized++; + await h.change('qualityTagsToggle', true); + assert.equal(initialized, 1); assert.equal(h.JE.currentSettings.qualityTagsEnabled, true); + h.document.body.insertAdjacentHTML('beforeend', '
    '); + await h.change('qualityTagsToggle', false); + assert.equal(h.document.querySelector('.quality-overlay-container'), null); + assert.ok(h.document.querySelector('.genre-overlay-container')); + assert.equal(h.writes.length, 2); + assert.equal(JSON.parse(h.writes[1].data).qualityTagsEnabled, false); +}); diff --git a/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs b/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs new file mode 100644 index 000000000..0fdad461c --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +function setup(t, disabled = false) { + const actions = [], saves = []; + const h = createHarness({ html: '
    A
    ', + JE: { pluginConfig: { DisableAllShortcuts: disabled }, currentSettings: {}, state: { activeShortcuts: { CycleAudioTracks: 'A', CycleSubtitleTracks: 'S', GoToHome: 'H', JumpToPercentage: '' } }, + userConfig: { shortcuts: { Shortcuts: [] } }, t: key => key, saveUserSettings: (...args) => saves.push(args), + isVideoPage: () => true, cycleAudioTrack: () => actions.push('audio'), cycleSubtitleTrack: () => actions.push('subtitle'), + showEnhancedPanel: () => actions.push('panel'), + injectGlobalStyles() {}, addPluginMenuButton() {}, addUserMenuLink() {}, addRandomButton() {}, applySavedStylesWhenReady() {}, + helpers: { createObserver() {}, throttle: fn => fn, onBodyMutation() {} } } }); + t.after(() => h.close()); + h.load('enhanced/events.js'); h.load('enhanced/settingspanel/ui-panel-shortcut-editor.js'); + h.JE.internals.enhancedUi.wireShortcutEditor({ help: h.document.getElementById('help'), pluginShortcuts: [{ Name: 'CycleAudioTracks', Key: 'A' }], primaryAccentColor: 'blue', kbdBackground: 'black' }); + const key = h.document.querySelector('kbd'); + const press = (target, name, modifiers = {}) => { const event = new h.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true, ...modifiers }); target.dispatchEvent(event); return event; }; + return { ...h, actions, saves, key, press }; +} + +for (const [key, modifiers] of [['k', {}], ['ArrowLeft', {}], [' ', {}], ['Enter', {}], ['n', { shiftKey: true }]]) { + test(`shortcut editor rejects reserved player key ${key} ${JSON.stringify(modifiers)}`, t => { + const h = setup(t); h.key.focus(); h.press(h.key, key, modifiers); + assert.equal(h.JE.state.activeShortcuts.CycleAudioTracks, 'A'); assert.equal(h.saves.length, 0); + assert.ok(h.key.classList.contains('shake-error')); + }); +} + +test('editor rejects conflicts and modifier-only input, disables with Delete and restores with Backspace', t => { + const h = setup(t); h.key.focus(); h.press(h.key, 's'); h.press(h.key, 'Control'); + assert.equal(h.saves.length, 0); + h.press(h.key, 'Delete'); assert.equal(h.JE.state.activeShortcuts.CycleAudioTracks, ''); + assert.equal(h.JE.userConfig.shortcuts.Shortcuts[0].Key, ''); + h.key.focus(); h.press(h.key, 'Backspace'); + assert.equal(h.JE.state.activeShortcuts.CycleAudioTracks, 'A'); assert.equal(h.JE.userConfig.shortcuts.Shortcuts.length, 0); +}); + +test('actual player capture listener suppresses host defaults and ignores track auto-repeat', t => { + const h = setup(t); let hostCalls = 0; + h.document.addEventListener('keydown', () => hostCalls++); + h.JE.initializeEnhancedScript(); + assert.equal(h.press(h.document.body, 'a').defaultPrevented, true); + h.press(h.document.body, 'a', { repeat: true }); + assert.deepEqual(h.actions, ['audio']); assert.equal(hostCalls, 0); + h.document.getElementById('typing').focus(); h.press(h.document.getElementById('typing'), 'a'); + assert.equal(hostCalls, 1); assert.deepEqual(h.actions, ['audio']); +}); + +test('editor-created combined-modifier shortcut executes through actual playback handler', t => { + const h = setup(t); h.key.focus(); h.press(h.key, 'z', { ctrlKey: true, shiftKey: true }); + assert.equal(h.saves.length, 1); + h.JE.initializeEnhancedScript(); h.document.body.focus(); + h.press(h.document.body, 'z', { ctrlKey: true, shiftKey: true }); + assert.deepEqual(h.actions, ['audio']); +}); + +test('administrator disabling shortcuts leaves panel key available but player bindings inactive', t => { + const h = setup(t, true); h.JE.initializeEnhancedScript(); + h.press(h.document.body, 'a'); h.press(h.document.body, '?'); + assert.deepEqual(h.actions, ['panel']); + h.key.focus(); h.press(h.key, 'z'); assert.equal(h.saves.length, 0); +}); diff --git a/tests/frontend/bootstrap/bootstrap.test.mjs b/tests/frontend/bootstrap/bootstrap.test.mjs new file mode 100644 index 000000000..524a9206b --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap.test.mjs @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, plain, deferred } from '../helpers/harness.mjs'; + +const modules = ['core/navigation.js', 'core/session.js', 'enhanced/config.js']; +const payload = (extra = {}) => ({ UserId: 'user-a', Version: '1.2.3', PublicConfig: {}, PrivateConfig: null, + HasCustomTabs: false, HasPluginPages: false, UserSettings: {}, ComponentScripts: modules, ...extra }); +async function until(predicate) { + const deadline = performance.now() + 3000; + while (performance.now() < deadline) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + assert.ok(predicate(), 'bootstrap did not reach expected state'); +} +// Script transport is simulated; plugin.js, config.js and session.js execute unchanged. +// Optional feature callbacks are spies to verify the bootstrap dispatch contract. +function setup(t, { data = payload(), ajax, bundle = 'ok', dev = false, storage = {}, expectedConsoleErrors = [] } = {}) { + let userId = 'user-a'; + const calls = [], scripts = [], initialized = []; + let translationLoads = 0; + const h = createHarness({ expectedConsoleErrors, html: ``, + apiClient: { getCurrentUserId: () => userId, ajax: async request => { + const path = new URL(request.url).pathname; calls.push(path); + if (ajax) return ajax(path, request); + if (path.endsWith('/bootstrap')) return typeof data === 'function' ? data(userId) : data; + throw new Error(`Unexpected endpoint ${path}`); + } } }); + t.after(() => h.close()); + for (const [key, value] of Object.entries(storage)) h.window.localStorage.setItem(key, value); + h.window.requestIdleCallback = callback => { queueMicrotask(() => callback({ didTimeout: false, timeRemaining: () => 100 })); return 1; }; + const append = h.document.head.appendChild.bind(h.document.head); + const executeModule = name => { + h.load(name); + if (name === 'enhanced/config.js') { + for (const name of ['QualityTags', 'HiddenContent', 'CalendarPage', 'JellyseerrScript', 'ReviewsScript']) + h.window.JellyfinEnhanced[`initialize${name}`] = () => initialized.push(name); + h.window.JellyfinEnhanced.hideSplashScreen = () => initialized.push('hideSplash'); + } + }; + h.document.head.appendChild = node => { + const result = append(node); + if (node.tagName !== 'SCRIPT') return result; + scripts.push(node); + queueMicrotask(() => { + const path = new URL(node.src).pathname; + if (path.endsWith('/translations.js')) h.window.JellyfinEnhanced.loadTranslations = async () => { translationLoads++; return { greeting: 'Hello' }; }; + else if (path.endsWith('/bundle.js')) { + if (bundle === 'error') { node.onerror(); return; } + if (bundle === 'ok') { + h.window.__JE_BUNDLE_TOTAL = modules.length; + h.window.__JE_BUNDLE_MODULES = modules.map(name => () => executeModule(name)); + } + } else { + const name = path.split('/js/')[1]; + if (modules.includes(name)) executeModule(name); + } + node.onload?.(); + }); + return result; + }; + h.load('plugin.js'); + return { ...h, calls, scripts, initialized, get translationLoads() { return translationLoads; }, get plugin() { return h.window.JellyfinEnhanced; }, + switchUser(id) { userId = id; h.window.JellyfinEnhanced.session.checkNow('test'); } }; +} + +test('bootstrap shares one request and loads actual settings before enabled feature initialization', async t => { + const h = setup(t, { data: payload({ PublicConfig: { QualityTagsEnabled: true, HiddenContentEnabled: true, CalendarPageEnabled: true, + JellyseerrEnabled: true, JellyseerrShowSearchResults: false, ShowReviews: true }, UserSettings: { Settings: { QualityTagsEnabled: false } } }) }); + await until(() => h.plugin.initialized); + assert.deepEqual(h.calls, ['/JellyfinEnhanced/bootstrap']); + assert.equal(h.plugin.pluginVersion, '1.2.3'); + assert.equal(h.plugin.currentSettings.qualityTagsEnabled, false); + assert.deepEqual(h.initialized, ['ReviewsScript', 'HiddenContent', 'CalendarPage', 'hideSplash']); + assert.equal(h.window.__JE_BUNDLE_PROGRESS, 3); + assert.equal(h.window.__JE_BUNDLE_MODULES, undefined); + assert.ok(h.scripts.every(s => s.src.includes('v=1.2.3-build123'))); +}); + +for (const bundle of ['error', 'malformed']) test(`bundle ${bundle} falls back to ordered real modules`, async t => { + const h = setup(t, { bundle, data: payload({ ComponentScripts: [' // comment', null, 'core/navigation.js', ' core/session.js ', '', 'enhanced/config.js'] }) }); + await until(() => h.plugin.initialized); + const components = h.scripts.filter(s => modules.some(name => s.src.includes(name))); + assert.equal(components.length, 3); + assert.ok(components[0].src.includes('core/navigation.js')); + assert.ok(components.every(s => s.async === false)); + assert.equal(h.plugin.session.getUserId(), 'user-a'); +}); + +test('development mode bypasses bundle and executes manifest modules', async t => { + const h = setup(t, { dev: true }); await until(() => h.plugin.initialized); + assert.equal(h.scripts.some(s => s.src.includes('/bundle.js')), false); + assert.ok(h.plugin.loadSettings); +}); + +test('wrong-user bootstrap is rejected and endpoint failures retain safe defaults', async t => { + const h = setup(t, { expectedConsoleErrors: ['Failed to fetch public config', 'Failed to fetch version'], ajax: async path => { + if (path.endsWith('/bootstrap')) return payload({ UserId: 'other-user', PrivateConfig: { SonarrUrl: 'secret' }, UserSettings: { Settings: { QualityTagsEnabled: true } } }); + if (path.endsWith('/component-scripts.json')) return modules; + if (path === '/Plugins') return []; + throw new Error('offline'); + } }); + await until(() => h.plugin.initialized); + assert.equal(h.plugin.pluginConfig.SonarrUrl, undefined); + assert.equal(h.plugin.currentSettings.qualityTagsEnabled, false); + assert.equal(h.plugin.pluginVersion, 'unknown'); + assert.deepEqual(plain(h.plugin.userConfig.bookmark), { bookmarks: {} }); + assert.equal(h.calls.filter(p => p.includes('/user-settings/user-a/')).length, 5); +}); + +for (const installed of [true, false]) test(`delivery plugin flags reflect installed=${installed}`, async t => { + const h = setup(t, { data: payload({ HasCustomTabs: installed, HasPluginPages: installed, + PublicConfig: { BookmarksUseCustomTabs: true, CalendarUsePluginPages: true } }) }); + await until(() => h.plugin.initialized); + assert.equal(h.plugin.pluginConfig.BookmarksUseCustomTabs, installed); + assert.equal(h.plugin.pluginConfig.CalendarUsePluginPages, installed); +}); + +test('identity transition immediately removes private admin data and replaces user documents', async t => { + const h = setup(t, { data: id => payload({ UserId: id, PrivateConfig: id === 'user-a' ? { SonarrUrl: 'secret' } : null, + UserSettings: { Settings: { QualityTagsEnabled: id === 'user-b' }, Bookmark: { Bookmarks: { [id]: { Name: id } } } } }) }); + await until(() => h.plugin.initialized); + assert.equal(h.plugin.pluginConfig.SonarrUrl, 'secret'); + const loaded = new Promise(resolve => h.document.addEventListener('je:user-data-loaded', resolve, { once: true })); + h.switchUser('user-b'); + assert.equal(h.plugin.pluginConfig.SonarrUrl, undefined); + assert.deepEqual(plain(h.plugin.currentSettings), {}); + assert.deepEqual(plain(h.plugin.userConfig.bookmark), { bookmarks: {} }); + await loaded; + assert.equal(h.plugin.currentSettings.qualityTagsEnabled, true); + assert.deepEqual(plain(h.plugin.userConfig.bookmark.bookmarks), { 'user-b': { name: 'user-b' } }); + assert.ok(h.initialized.includes('QualityTags')); +}); + +for (const saved of [null, 'fr']) test(`default display language respects existing preference ${saved}`, async t => { + const h = setup(t, { data: payload({ PublicConfig: { DefaultLanguage: 'pt-br' } }), storage: saved ? { 'user-a-language': saved } : {} }); + await until(() => h.plugin.initialized); + assert.equal(h.window.localStorage.getItem('user-a-language'), saved || 'pt-BR'); +}); + +test('translation cache clear removes stale locales but preserves newer entries and unrelated storage', async t => { + const h = setup(t, { data: payload({ PublicConfig: { ClearTranslationCacheTimestamp: 2000 } }), storage: { + JE_translation_en: 'old', JE_translation_ts_en: '1000', JE_translation_fr: 'new', JE_translation_ts_fr: '3000', unrelated: 'keep' + } }); + await until(() => h.plugin.initialized); + assert.equal(h.window.localStorage.getItem('JE_translation_en'), null); + assert.equal(h.window.localStorage.getItem('JE_translation_ts_en'), null); + assert.equal(h.window.localStorage.getItem('JE_translation_fr'), 'new'); + assert.equal(h.window.localStorage.getItem('JE_translation_ts_fr'), '3000'); + assert.equal(h.window.localStorage.getItem('unrelated'), 'keep'); + assert.equal(h.window.localStorage.getItem('JE_translation_clear_ts'), '2000'); + assert.equal(h.translationLoads, 2); +}); + +test('translation cache already newer than invalidation avoids redundant reload', async t => { + const h = setup(t, { data: payload({ PublicConfig: { ClearTranslationCacheTimestamp: 2000 } }), storage: { + JE_translation_en: 'new', JE_translation_ts_en: '3000' + } }); + await until(() => h.plugin.initialized); + assert.equal(h.translationLoads, 1); + assert.equal(h.window.localStorage.getItem('JE_translation_en'), 'new'); +}); + +for (const expired of [false, true]) test(`maintenance banner treats message as text and suppresses expired=${expired}`, async t => { + const h = setup(t, { data: payload({ PublicConfig: { MaintenanceModeEnabled: true, + MaintenanceModeMessage: '', MaintenanceModeEndsAt: expired ? '2000-01-01T00:00:00Z' : null, + EnableLoginImage: true } }) }); + await until(() => h.plugin.initialized); + const banner = h.document.querySelector('#je-maintenance-banner'); + if (expired) assert.equal(banner, null); + else { assert.ok(banner.textContent.includes(' s.src.includes('/extras/login-image.js')).length, 1); +}); + +test('late bootstrap for previous identity cannot restore private config or bookmarks', async t => { + const stale = deferred(); + let bStarted = false; + const h = setup(t, { data: id => { + if (id === 'user-b') { bStarted = true; return stale.promise; } + return payload({ UserId: id, UserSettings: { Bookmark: { Bookmarks: { [id]: { Name: id } } } } }); + } }); + await until(() => h.plugin.initialized); + h.switchUser('user-b'); + await until(() => bStarted); + const loaded = new Promise(resolve => h.document.addEventListener('je:user-data-loaded', resolve, { once: true })); + h.switchUser('user-c'); + await loaded; + stale.resolve(payload({ UserId: 'user-b', PrivateConfig: { SonarrUrl: 'secret' }, UserSettings: { Bookmark: { Bookmarks: { stolen: {} } } } })); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.plugin.pluginConfig.SonarrUrl, undefined); + assert.deepEqual(plain(h.plugin.userConfig.bookmark.bookmarks), { 'user-c': { name: 'user-c' } }); +}); diff --git a/tests/frontend/core/core-letterboxd.test.mjs b/tests/frontend/core/core-letterboxd.test.mjs new file mode 100644 index 000000000..bf1debfac --- /dev/null +++ b/tests/frontend/core/core-letterboxd.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, deferred } from '../helpers/harness.mjs'; +async function setup(t,item,config={}){ + const idle=[],views=[];let disconnects=0,calls=0; + const h=createHarness({url:'http://jellyfin.test/web/#!/details?id=one',html:'
    ',globals:{requestIdleCallback:fn=>idle.push(fn)},JE:{pluginConfig:{LetterboxdEnabled:true,ShowLetterboxdLinkAsText:true,...config},cdn:{selfhst:path=>`/cdn/${path}`},helpers:{getItemCached:async()=>{calls++;return typeof item==='function'?item():item;},getExternalLinkIconSize:()=>25,createObserver:()=>({disconnect:()=>disconnects++}),onViewPage:fn=>views.push(fn)}}}); + t.after(()=>h.close());h.load('others/letterboxd-links.js');await h.JE.initializeLetterboxdLinksScript(); + return Object.assign(h,{idle,views,calls:()=>calls,disconnects:()=>disconnects}); +} +test('Letterboxd disabled integration does not register observers or fetch',async t=>{const h=await setup(t,{}, {LetterboxdEnabled:false});assert.equal(h.idle.length,0);assert.equal(h.views.length,0);assert.equal(h.calls(),0);}); +for(const [item,path] of [[{Type:'Movie',ProviderIds:{Imdb:'tt123'}},'imdb/tt123'],[{Type:'Person',Name:'Léa Seydoux'},'actor/lea-seydoux']])test(`Letterboxd ${item.Type} links use safe external attributes and correct identifiers`,async t=>{ + const h=await setup(t,item);await h.idle.shift()();const a=h.document.querySelector('.letterboxd-link');assert.equal(a.href,`https://letterboxd.com/${path}`);assert.equal(a.rel,'noopener noreferrer');assert.equal(a.target,'_blank');assert.equal(a.textContent,'Letterboxd'); + h.views[0]();await h.idle.shift()();assert.equal(h.document.querySelectorAll('.letterboxd-link').length,1);assert.equal(h.calls(),1); +}); +test('Letterboxd icon mode and fresh same-item DOM rerender remain supported',async t=>{ + const h=await setup(t,{Type:'Movie',ProviderIds:{Imdb:'tt1'}},{ShowLetterboxdLinkAsText:false});await h.idle.shift()();assert.equal(h.document.querySelector('a').style.getPropertyValue('--je-icon-size'),'25px'); + h.document.querySelector('#itemDetailPage').innerHTML='';const added=new Promise(resolve=>{const observer=new h.window.MutationObserver(()=>{if(h.document.querySelector('.letterboxd-link')){observer.disconnect();resolve();}});observer.observe(h.document.body,{childList:true,subtree:true});});h.views[0]();h.idle.shift()();await added;assert.ok(h.document.querySelector('.letterboxd-link'));assert.equal(h.calls(),2); +}); +for(const item of [{Type:'Series'},{Type:'Movie',ProviderIds:{}},{Type:'Person',Name:'!!!'}])test(`Letterboxd unsupported ${JSON.stringify(item)} is cached without markup`,async t=>{ + const h=await setup(t,item);await h.idle.shift()();h.views[0]();await h.idle.shift()();assert.equal(h.document.querySelector('a'),null);assert.equal(h.calls(),1); +}); +test('Letterboxd response for previous route cannot decorate a reused current detail page',async t=>{ + const pending=deferred();const h=await setup(t,()=>pending.promise);const render=h.idle.shift()(); + h.window.history.replaceState({},'','#!/details?id=two');pending.resolve({Type:'Movie',ProviderIds:{Imdb:'tt-old'}});await render; + assert.equal(h.document.querySelector('.letterboxd-link'),null); +}); diff --git a/tests/frontend/core/core-media-language.test.mjs b/tests/frontend/core/core-media-language.test.mjs new file mode 100644 index 000000000..cdd67dd9a --- /dev/null +++ b/tests/frontend/core/core-media-language.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; +function setup(t){const h=createHarness({JE:{cdn:{flagSvg:code=>`/flags/${code}.svg`},t:key=>key==='audio_language_no_dialogue'?'No dialogue':key}});t.after(()=>h.close());h.load('core/media-language.js');return h.JE.core.mediaLanguage;} +test('audio flag resolution honors explicit regions, scripts, aliases and unknown languages',t=>{ + const m=setup(t); + for(const [input,expected] of [['en','gb'],['en-US','us'],['en-UK','gb'],['pt','pt'],['pt-BR','br'],['pob','br'],['es-419','mx'],['en-419','gb'],['zh-Hant','tw'],['zh-Hans','cn'],['ja-Hans','jp'],['pt-XA','pt'],['und-US',null],['eo-FR',null],['ca-ES','es-ct'],['zxx-US','zxx'],[null,null],[{Code:'fra'},'fr'],[{name:'Japanese'},'jp']]) assert.equal(m.resolveFlag(input),expected,JSON.stringify(input)); +}); +test('audio preference matching canonicalizes ISO names while preserving region constraints',t=>{ + const m=setup(t); + for(const [stream,wanted,options,expected] of [['deu','de',{},true],['ger','deu',{},true],['nno','nb',{},true],['tgl','fil',{},true],['en-UK','en-GB',{},true],['pt','pt-BR',{},true],['pt','pt-BR',{requireRegion:true},false],['pt-PT','pt-BR',{},false],['pt-BR','pt',{},true],['ja','en',{},false],['','en',{},false]])assert.equal(m.matchesLanguage(stream,wanted,options),expected,`${stream}/${wanted}`); +}); +test('no-dialogue flags remain self-contained and localized; regular flags use CDN',t=>{ + const m=setup(t);assert.match(m.flagSrc('zxx'),/^data:image\/svg\+xml,/);assert.equal(m.flagSrc('br'),'/flags/br.svg');assert.equal(m.displayName('zxx'),'No dialogue');assert.equal(m.displayName('en'),'English'); +}); diff --git a/tests/frontend/core/core-tag-pipeline.test.mjs b/tests/frontend/core/core-tag-pipeline.test.mjs new file mode 100644 index 000000000..52e34bf48 --- /dev/null +++ b/tests/frontend/core/core-tag-pipeline.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setImmediate as settle } from 'node:timers/promises'; +import { createHarness, deferred } from '../helpers/harness.mjs'; +const card='
    '; +function setup(t,html=card){const idle=[],timers=new Map(),changes=new Map(),calls=[],rendered=[];let seq=0;const response=deferred(); + const h=createHarness({html,globals:{requestIdleCallback:fn=>idle.push(fn),setTimeout:(fn,ms)=>{timers.set(++seq,{fn,ms});return seq;},clearTimeout:id=>timers.delete(id)},apiClient:{ajax:options=>{calls.push(options);return response.promise;}},JE:{pluginConfig:{TagCacheServerMode:false},currentSettings:{},core:{tagRenderer:{applyCornerStacking(){},scheduleCornerStacking(){}}},session:{onUserChange:(id,fn)=>changes.set(id,fn)}}});t.after(()=>h.close());h.load('tags/tag-pipeline.js'); + h.JE.tagPipeline.registerRenderer('probe',{isEnabled:()=>true,render:(host,item)=>{rendered.push({host,item});const chip=h.document.createElement('span');chip.className='probe-tag';host.append(chip);}}); + return Object.assign(h,{idle,timers,response,calls,rendered,changes,scan(){while(idle.length)idle.shift()({timeRemaining:()=>50});},fetch(){for(const[id,timer]of[...timers])if(timer.ms===150){timers.delete(id);timer.fn();}}});} +test('tag pipeline re-resolves a detached host before rendering a delayed response',async t=>{ + const h=setup(t);h.scan();h.fetch();assert.equal(h.calls.length,1);assert.deepEqual(JSON.parse(h.calls[0].data),['aabb']); + const old=h.document.querySelector('.je-tag-host');old.remove();h.response.resolve({Items:[{Id:'aabb',Type:'Movie'}]});await settle(); + assert.equal(h.rendered.length,1);assert.notEqual(h.rendered[0].host,old);assert.equal(h.rendered[0].host.isConnected,true);assert.equal(h.document.querySelectorAll('.probe-tag').length,1); + assert.equal(h.document.querySelector('.cardImageContainer .probe-tag'),null); +}); +test('tag pipeline renders duplicate IDs to every card with one batch item lookup',async t=>{ + const h=setup(t,card+card);h.scan();h.fetch();assert.deepEqual(JSON.parse(h.calls[0].data),['aabb']);h.response.resolve({Items:[{Id:'aabb',Type:'Movie'}]});await settle();assert.equal(h.document.querySelectorAll('.probe-tag').length,2); +}); +test('tag pipeline drops removed cards while a lookup is pending',async t=>{ + const h=setup(t);h.scan();h.fetch();h.document.querySelector('.card').remove();h.response.resolve({Items:[{Id:'aabb',Type:'Movie'}]});await settle();assert.equal(h.rendered.length,0); +}); +test('tag pipeline rejects a late previous-user batch after identity reset',async t=>{ + const h=setup(t);h.scan();h.fetch();h.changes.get('tag-pipeline')({userId:'b'});h.response.resolve({Items:[{Id:'aabb',Type:'Movie',Genres:['private']} ]});await settle();assert.equal(h.rendered.length,0);assert.equal(h.JE.tagPipeline.peekReviewRatings(),null); +}); +test('tag pipeline skips hidden/admin/nonmedia cards without transport',t=>{ + const h=setup(t,`
    ${card}
    ${card.replace('class="card"','class="card je-hidden"')}${card.replace('data-type="Movie"','data-type="Person"')}`);h.scan();h.fetch();assert.equal(h.calls.length,0);assert.equal(h.rendered.length,0); +}); diff --git a/tests/frontend/core/core-tag-renderer.test.mjs b/tests/frontend/core/core-tag-renderer.test.mjs new file mode 100644 index 000000000..d8fce1267 --- /dev/null +++ b/tests/frontend/core/core-tag-renderer.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; +function setup(t,config={}){ + const saved=[], renderers=new Map(), changes=new Map();let scans=0; + const h=createHarness({html:'
    ',JE:{currentSettings:{tags:true},pluginConfig:config,session:{getUserId:()=> 'a',getServerId:()=> 's',onUserChange:(name,fn)=>changes.set(name,fn)},_cacheManager:{register:fn=>saved.push(fn),markDirty(){}},tagPipeline:{registerRenderer:(name,r)=>renderers.set(name,r),getRenderer:name=>renderers.get(name),clearProcessed(){},scheduleScan:()=>scans++}}}); + t.after(()=>h.close());h.load('core/ui-kit.js');h.load('core/tag-renderer-base.js'); + const spec={logPrefix:'test',settingKey:'tags',containerClass:'test-overlay',taggedAttr:'jeTestTagged',styleId:'test-tag-css',buildCss:()=>'.test-overlay{color:red}',position:{userKey:'position',pluginKey:'Position',fallback:'top-right'},cache:{key:'test-v2',legacyPrefix:'test',hotBucket:'test',pruneOnSave:true},pipeline:{render(ctx,el,item){const overlay=h.document.createElement('div');overlay.className='test-overlay';overlay.innerHTML='';ctx.commitOverlay(el,overlay);}}}; + return Object.assign(h,{spec,saved,renderers,changes,scans:()=>scans,register:()=>h.JE.core.tagRenderer.register('test',spec),host:h.document.querySelector('.je-tag-host')}); +} +test('tag registration preserves enabled contract, hooks and real overlay DOM',t=>{ + const h=setup(t,{TagCacheServerMode:false});const ctx=h.register();h.register();assert.equal(h.saved.length,1); + const r=h.renderers.get('test');assert.equal(r.isEnabled(),true);r.render(h.host,{});assert.equal(ctx.isTagged(h.host),true);assert.equal(h.host.firstElementChild.dataset.jeCorner,'top-right'); + h.host.firstElementChild.remove();assert.equal(ctx.isTagged(h.host),false,'stale tagged attribute must permit rerender'); + r.render(h.host,{});h.JE.currentSettings.tags=false;h.JE.core.tagRenderer.reinitialize('test',h.spec); + assert.equal(h.document.querySelector('.test-overlay'),null);assert.equal(h.document.querySelector('.card').dataset.jeTestTagged,undefined);assert.equal(h.scans(),0); + h.JE.currentSettings.tags=true;h.JE.core.tagRenderer.reinitialize('test',h.spec);assert.equal(h.scans(),1);assert.equal(h.document.querySelectorAll('#test-tag-css').length,1); +}); +test('tag position resolves user then administrator then fallback',t=>{ + const h=setup(t,{Position:'bottom-left'});let p=h.JE.core.tagRenderer.resolvePosition('position','Position','top-right');assert.equal(p.bottomVal,'6px');assert.equal(p.leftVal,'6px'); + h.JE.currentSettings.position='top-right';p=h.JE.core.tagRenderer.resolvePosition('position','Position','bottom-left');assert.equal(p.needsTopRightOffset,true); + delete h.JE.currentSettings.position;delete h.JE.pluginConfig.Position;assert.equal(h.JE.core.tagRenderer.resolvePosition('position','Position','bottom-right').pos,'bottom-right'); +}); +test('tag cache rejects previous-owner data and clears hot/persistent state on user change',t=>{ + const h=setup(t,{TagCacheServerMode:false});const store=h.window.localStorage;store.setItem('test-v2',JSON.stringify({secret:{owner:'previous'}}));store.setItem('test-v2:identity-owner','s:previous'); + const ctx=h.register();assert.equal(ctx.getPersistent('secret'),undefined);ctx.setPersistent('new',{timestamp:Date.now()});ctx.hot.set('new',1);h.saved[0]();assert.ok(store.getItem('test-v2').includes('new')); + h.changes.get('tag-cache-test')({serverId:'s',userId:'b'});assert.equal(ctx.getPersistent('new'),undefined);assert.equal(ctx.hot.size,0);assert.equal(store.getItem('test-v2'),null);assert.equal(store.getItem('test-v2:identity-owner'),'s:b'); +}); +test('tag cache honors TTL pruning, legacy cleanup and server clear timestamp',t=>{ + const h=setup(t,{TagCacheServerMode:false,TagsCacheTtlDays:1});const store=h.window.localStorage; + store.setItem('test-old','{}');store.setItem('test-v2:identity-owner','s:a');store.setItem('test-v2',JSON.stringify({old:{timestamp:0},fresh:{timestamp:Date.now()}})); + const ctx=h.register();h.saved[0]();assert.equal(ctx.getPersistent('old'),undefined);assert.ok(ctx.getPersistent('fresh'));assert.equal(store.getItem('test-old'),null); + h.JE.pluginConfig.ClearLocalStorageTimestamp=Date.now();h.register();assert.equal(ctx.getPersistent('fresh'),undefined);assert.equal(ctx.hot.size,0); +}); +test('tag search/admin exclusions apply to sibling render hosts',t=>{ + const h=setup(t,{DisableTagsOnSearchPage:true});const ctx=h.register();assert.equal(ctx.shouldIgnore(h.host),false); + const section=h.document.createElement('section');section.id='searchPage';h.document.body.append(section);section.append(h.document.querySelector('.card'));assert.equal(ctx.shouldIgnore(h.host),true); + section.id='pluginsPage';assert.equal(ctx.shouldIgnore(h.host),true); +}); +test('empty tag overlays are not committed or marked',t=>{ + const h=setup(t);const ctx=h.register();assert.equal(ctx.commitOverlay(h.host,h.document.createElement('div')),false);assert.equal(ctx.isTagged(h.host),false); +}); +test('corner stacking separates top and bottom overlays and clears stale transforms',t=>{ + const h=setup(t);for(const corner of ['top-right','bottom-left'])for(let i=0;i<2;i++){const el=h.document.createElement('div');el.dataset.jeCorner=corner;el.getBoundingClientRect=()=>({top:10,bottom:30,height:20});h.host.append(el);} + h.JE.core.tagRenderer.applyCornerStacking(h.host);assert.equal(h.host.children[1].style.transform,'translateY(24px)');assert.equal(h.host.children[3].style.transform,'translateY(-24px)'); + h.host.children[0].remove();h.JE.core.tagRenderer.applyCornerStacking(h.host);assert.equal(h.host.children[0].style.transform,''); +}); +test('corrupt same-owner tag cache recovers without preventing renderer registration',t=>{ + const h=setup(t,{TagCacheServerMode:false});h.window.localStorage.setItem('test-v2:identity-owner','s:a');h.window.localStorage.setItem('test-v2','{broken'); + const ctx=h.register();assert.equal(ctx.getPersistent('anything'),undefined);assert.ok(h.renderers.get('test')); +}); diff --git a/tests/frontend/core/core-theme.test.mjs b/tests/frontend/core/core-theme.test.mjs new file mode 100644 index 000000000..89b8f46a6 --- /dev/null +++ b/tests/frontend/core/core-theme.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; +function setup(t){const timers=new Map();let seq=0,changed,unsubscribed=0;const h=createHarness({html:'
    User
    ',globals:{setTimeout:(fn,ms)=>{timers.set(++seq,{fn,ms});return seq;},clearTimeout:id=>timers.delete(id)},JE:{cdn:{url:()=>'/JellyfinEnhanced/cdn/jellyfish/colors/'},helpers:{onBodyMutation:(id,fn)=>{changed=fn;return{unsubscribe:()=>unsubscribed++};}}}});t.after(()=>h.close());h.load('extras/theme-selector.js');return Object.assign(h,{timers,unsubscribed:()=>unsubscribed,flush:ms=>{for(const[id,timer]of[...timers])if(timer.ms===ms){timers.delete(id);timer.fn();}},change:()=>changed()});} +test('theme selector mounts once, reflects current user CSS and replaces old monitoring',t=>{ + const h=setup(t);h.window.localStorage.setItem('user-a-customCss','@import url("/JellyfinEnhanced/cdn/jellyfish/colors/ocean.css");');h.JE.initializeThemeSelector();h.change();h.flush(100); + assert.equal(h.document.querySelector('select').value,'Ocean');assert.equal(h.document.querySelector('select').getAttribute('aria-label'),'Select theme'); + h.change();h.flush(100);assert.equal(h.document.querySelectorAll('#jellyfin-theme-selector').length,1); + h.JE.initializeThemeSelector();assert.equal(h.unsubscribed(),1);assert.equal(h.document.querySelectorAll('#jellyfin-theme-selector-css').length,1); +}); +test('theme selection stores local CDN CSS only for current user and default removes it',t=>{ + const h=setup(t);h.window.localStorage.setItem('other-customCss','keep');h.JE.initializeThemeSelector();h.change();h.flush(100);const select=h.document.querySelector('select'); + select.value='Forest';select.dispatchEvent(new h.window.Event('change'));assert.equal(h.window.localStorage.getItem('user-a-customCss'),'@import url("/JellyfinEnhanced/cdn/jellyfish/colors/forest.css");');assert.equal(h.window.localStorage.getItem('other-customCss'),'keep');assert.equal(h.window.sessionStorage.getItem('jellyfin-theme-applied'),'Forest');assert.equal(select.disabled,true); + select.value='Default';select.dispatchEvent(new h.window.Event('change'));assert.equal(h.window.localStorage.getItem('user-a-customCss'),null); +}); +test('daily-theme preference toggles accessibly without rerolling an already selected day',t=>{ + const h=setup(t);h.window.localStorage.setItem('user-a-lastRandomThemeDate',new Date().toISOString().split('T')[0]);h.JE.initializeThemeSelector();h.change();h.flush(100);const button=h.document.querySelector('#random-theme-button'); + assert.equal(button.getAttribute('aria-pressed'),'false');button.click();assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(h.window.localStorage.getItem('user-a-randomThemeEnabled'),'true');button.click();assert.equal(button.getAttribute('aria-pressed'),'false'); +}); diff --git a/tests/frontend/core/core-translations.test.mjs b/tests/frontend/core/core-translations.test.mjs new file mode 100644 index 000000000..093478b43 --- /dev/null +++ b/tests/frontend/core/core-translations.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, jsonResponse, plain } from '../helpers/harness.mjs'; +function setup(t, fetch, apiClient={}) { + const h=createHarness({JE:{pluginVersion:'test-v1',cdn:{url:(source,path)=>`http://jellyfin.test/cdn/${source}/${path}`}},apiClient:{getCurrentUser:()=>({Id:'a'}),...apiClient},fetch}); + t.after(()=>h.close());h.load('enhanced/translations.js');return h; +} +test('translations normalize per-user language and cache the bundled Unicode response',async t=>{ + const calls=[]; const h=setup(t,async url=>{calls.push(url);return jsonResponse({greeting:'こんにちは {name}'});}); + h.window.localStorage.setItem('a-language','JA-jp'); + assert.deepEqual(plain(await h.JE.loadTranslations()),{greeting:'こんにちは {name}'}); + assert.match(calls[0],/JA|ja-JP/);assert.match(calls[0],/\/locales\/ja-JP.json$/); + await h.JE.loadTranslations();assert.equal(calls.length,1); +}); +test('translations use Jellyfin document language when no local preference exists',async t=>{ + const calls=[];const h=setup(t,async url=>{calls.push(url);return jsonResponse({yes:'Oui'});}); + h.document.documentElement.lang='fr';await h.JE.loadTranslations();assert.match(calls[0],/\/fr.json$/); +}); +test('corrupt current-version cache refetches and old-version entries are removed',async t=>{ + let calls=0;const h=setup(t,async()=>{calls++;return jsonResponse({ok:'OK'});});const store=h.window.localStorage; + store.setItem('JE_translation_en_test-v1','{broken');store.setItem('JE_translation_ts_en_test-v1',String(Date.now())); + store.setItem('JE_translation_en_old','{}');store.setItem('unrelated','keep'); + assert.equal((await h.JE.loadTranslations()).ok,'OK');assert.equal(calls,1); + assert.equal(store.getItem('JE_translation_en_old'),null);assert.equal(store.getItem('unrelated'),'keep'); +}); +test('translation fallback stays on the local proxy and supports English fallback',async t=>{ + const calls=[];const h=setup(t,async url=>{calls.push(url);return url.endsWith('/en.json')?jsonResponse({ok:'OK'}):jsonResponse({},404);}); + h.window.localStorage.setItem('a-language','zz');assert.equal((await h.JE.loadTranslations()).ok,'OK'); + assert.ok(calls.every(url=>url.startsWith('http://jellyfin.test/')));assert.ok(calls.some(url=>url.includes('/cdn/locales/'))); +}); +test('unavailable translation sources resolve to an empty dictionary without poisoning cache',async t=>{ + const h=setup(t,async()=>{throw new Error('offline');});h.expectConsoleError(/Failed to load translations from any source/);assert.deepEqual(plain(await h.JE.loadTranslations()),{}); + assert.equal(h.window.localStorage.length,0); +}); diff --git a/tests/frontend/core/core.test.mjs b/tests/frontend/core/core.test.mjs new file mode 100644 index 000000000..052ef0e02 --- /dev/null +++ b/tests/frontend/core/core.test.mjs @@ -0,0 +1,224 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, deferred, jsonResponse, plain } from '../helpers/harness.mjs'; + +function setup(t, options = {}, modules = ['navigation', 'session', 'api-client']) { + const h = createHarness(options); t.after(() => h.close()); + for (const name of modules) h.load(`core/${name}.js`); + return h; +} + +test('navigation reports changed URLs once across push, replace and duplicate events', t => { + const { window, JE } = setup(t, {}, ['navigation']); + let calls = 0; const off = JE.core.navigation.onNavigate(() => calls++); + window.history.pushState({}, '', '#!/details?id=1'); + window.dispatchEvent(new window.Event('popstate')); + window.dispatchEvent(new window.Event('hashchange')); + window.history.pushState({}, '', window.location.href); + assert.equal(calls, 1); + window.history.replaceState({}, '', '#!/details?id=2'); assert.equal(calls, 2); + off(); window.history.pushState({}, '', '#!/home'); assert.equal(calls, 2); +}); + +test('view fallback preserves event target, filters pages and fetches the URL item', async t => { + const { window, JE, document } = setup(t, { html: '
    ', + url: 'http://jellyfin.test/web/#!/details?id=abc', apiClient: { getItem: async (user,id) => ({ user,id }) } }, ['navigation']); + let value, wrong = 0; + const off = JE.core.navigation.onViewPage((...args) => { value = args; }, { pages: ['item'], fetchItem: true }); + JE.core.navigation.onViewPage(() => wrong++, { pages: ['home'] }); + const el = document.querySelector('#view'); + el.dispatchEvent(new window.CustomEvent('viewshow', { bubbles: true, detail: { type: 'item' } })); + assert.equal(value[0], 'item'); assert.equal(value[1], el); + assert.deepEqual(await value[3], { user: 'user-a', id: 'abc' }); assert.equal(wrong, 0); + off(); assert.equal(JE.core.navigation.getViewHandlerCount(), 1); +}); + +test('Emby hook preserves original handler and emits one view notification', t => { + let original = 0, seen = 0; + const { window, JE } = setup(t, { globals: { Emby: { Page: { onViewShow() { original++; } } } } }, ['navigation']); + JE.core.navigation.onViewPage(() => seen++); + window.document.dispatchEvent(new window.CustomEvent('viewshow')); + window.Emby.Page.onViewShow('home'); assert.equal(original, 1); assert.equal(seen, 1); +}); + +test('lifecycle releases all resource kinds, listeners, and preserves hooks across remounts', t => { + const { JE, window } = setup(t, {}, ['navigation','lifecycle']); + const handle = JE.core.lifecycle.register('feature'); const calls = []; + assert.equal(JE.core.lifecycle.register('feature'), handle); + for (const method of ['abort', 'disconnect', 'unsubscribe']) handle.track({ [method]: () => calls.push(method) }); + handle.track(() => calls.push('cleanup')); + const ignored = () => calls.push('ignored'); handle.track(ignored); handle.untrack(ignored); + handle.addListener(window, 'probe', () => calls.push('event')); + handle.onTeardown(() => calls.push('hook')); handle.teardownOn('navigate'); + window.history.pushState({}, '', '#!/one'); window.dispatchEvent(new window.Event('probe')); + assert.deepEqual(calls, ['abort','disconnect','unsubscribe','cleanup','hook']); + window.history.pushState({}, '', '#!/two'); assert.equal(calls.at(-1), 'hook'); assert.equal(calls.length, 6); + assert.equal(JE.core.lifecycle.get('missing'), null); +}); + +test('session resets synchronously before credentials change and invalidates stale epochs', t => { + let user = 'a', token = 'a-token'; const order = []; + const { JE, window } = setup(t, { apiClient: { + getCurrentUserId: () => user, accessToken: () => token, + setAuthenticationInfo(nextToken, nextUser) { order.push('auth'); token = nextToken; user = nextUser; return 42; } + } }, ['navigation','session']); + const epoch = JE.session.getEpoch(); + JE.session.onUserChange('test', change => { order.push(`reset:${token}:${change.userId}`); }); + window.document.addEventListener('je:user-changed', () => order.push('event')); + assert.equal(window.ApiClient.setAuthenticationInfo('b-token','b'), 42); + assert.deepEqual(order, ['reset:a-token:b','event','auth']); + assert.equal(JE.session.isCurrent(epoch), false); assert.equal(JE.session.getUserId(), 'b'); + window.ApiClient.setAuthenticationInfo(null,null); assert.equal(JE.session.getUserId(), null); +}); + +test('server changes reset same-user identity; removed handlers stay removed', t => { + let server = 'one'; let count = 0; + const { JE } = setup(t, { apiClient: { serverId: () => server } }, ['navigation','session']); + const off = JE.session.onUserChange('feature', () => count++); + server = 'two'; JE.session.checkNow('test'); assert.equal(count,1); + off(); server = 'three'; JE.session.checkNow('test'); assert.equal(count,1); +}); + +test('authenticated requests resolve plugin paths, JSON bodies, and compatible headers', async t => { + let request; + const { JE } = setup(t, { fetch: async (...args) => { request = args; return jsonResponse({ ok: true }); } }); + assert.deepEqual(plain(await JE.core.api.plugin('/preferences', { method: 'POST', body: { language: '日本語' } })), { ok: true }); + assert.equal(request[0], 'http://jellyfin.test/JellyfinEnhanced/preferences'); + assert.equal(request[1].headers.Authorization, 'MediaBrowser Token="test-token"'); + assert.equal(request[1].headers['X-Jellyfin-User-Id'], 'user-a'); + assert.equal(request[1].headers['X-Emby-Token'], 'test-token'); + assert.equal(request[1].body, '{"language":"日本語"}'); +}); + +test('plain concurrent GETs deduplicate transport but isolate mutable results', async t => { + const pending = deferred(); let calls = 0; + const { JE } = setup(t, { fetch: () => { calls++; return pending.promise; } }); + const a = JE.core.api.fetch('/same'), b = JE.core.api.fetch('/same'); + assert.equal(calls, 1); pending.resolve(jsonResponse({ nested: { value: 1 } })); + const [one,two] = await Promise.all([a,b]); one.nested.value = 2; assert.equal(two.nested.value,1); +}); + +test('custom headers and writes do not accidentally share requests', async t => { + let calls = 0; const { JE } = setup(t, { fetch: async () => { calls++; return jsonResponse({}); } }); + await Promise.all([JE.core.api.fetch('/x',{headers:{variant:'a'}}),JE.core.api.fetch('/x',{headers:{variant:'b'}}), + JE.core.api.fetch('/x',{method:'POST'}),JE.core.api.fetch('/x',{method:'POST'})]); + assert.equal(calls,4); +}); + +test('response cache expires, evicts least-recently-used entries, and selectively clears', t => { + const { JE, window } = setup(t); const m = JE.core.api.manager; + let now = 100; window.Date.now = () => now; m.CONFIG.cache.maxEntries = 2; m.CONFIG.cache.ttlMs = 10; + m.setCache('a',1); m.setCache('b',2); assert.equal(m.getCached('a'),1); m.setCache('c',3); + assert.equal(m.getCached('b'),null); m.clearCacheMatching('c'); assert.equal(m.getCached('c'),null); + now = 110; assert.equal(m.getCached('a'),null); +}); + +test('late previous-user response cannot repopulate cleared cache', async t => { + let user = 'a'; const pending = deferred(); + const { JE } = setup(t, { apiClient: { getCurrentUserId: () => user }, fetch: () => pending.promise }); + JE.core.api.manager.setCache('old',{private:true}); + const request = JE.core.api.fetch('/private',{cacheKey:'private'}); + user = 'b'; JE.session.checkNow('test'); pending.resolve(jsonResponse({owner:'a'})); await request; + assert.equal(JE.core.api.manager.getCached('private'),null); assert.equal(JE.core.api.manager.getCached('old'),null); +}); + +test('user switch cancels queued writes before they can use new credentials', async t => { + let user = 'a', calls = 0; const pending = deferred(); + const { JE } = setup(t, { apiClient: { getCurrentUserId: () => user }, fetch: () => { calls++; return pending.promise; } }); + JE.core.api.manager.CONFIG.concurrency.maxConcurrent = 1; + const running = JE.core.api.fetch('/one'); + const queued = JE.core.api.fetch('/write',{method:'POST'}); + const rejected = assert.rejects(queued,{name:'AbortError'}); + user = 'b'; JE.session.checkNow('test'); await rejected; + pending.resolve(jsonResponse({})); await running; assert.equal(calls,1); +}); + +test('aborted queued requests release their place without consuming transport', async t => { + const pending = deferred(); let calls = 0; + const { JE,window } = setup(t,{fetch:()=>{calls++; return pending.promise;}}); + JE.core.api.manager.CONFIG.concurrency.maxConcurrent=1; + const running=JE.core.api.fetch('/one'); const controller=new window.AbortController(); + const queued=JE.core.api.fetch('/two',{signal:controller.signal}); + const rejected=assert.rejects(queued,{name:'AbortError'}); controller.abort(); await rejected; + pending.resolve(jsonResponse({})); await running; assert.equal(calls,1); +}); + +for (const status of [400,401,403,404]) test(`HTTP ${status} does not retry`,async t=>{ + let calls=0; const {JE}=setup(t,{fetch:async()=>{calls++;return jsonResponse({message:'denied'},status);}}); + await assert.rejects(JE.core.api.fetch('/denied')); assert.equal(calls,1); +}); + +test('retryable failures recover with bounded retry count',async t=>{ + let calls=0; const {JE}=setup(t,{fetch:async()=>++calls===1?jsonResponse({},503):jsonResponse({ok:true})}); + Object.assign(JE.core.api.manager.CONFIG.retry,{baseDelayMs:0,jitterFactor:0}); + assert.equal((await JE.core.api.fetch('/retry')).ok,true); assert.equal(calls,2); +}); + +test('empty response is supported while malformed JSON fails explicitly',async t=>{ + let body=''; const {JE}=setup(t,{fetch:async()=>new Response(body)}); + assert.deepEqual(plain(await JE.core.api.fetch('/empty')),{}); + body='broken'; await assert.rejects(JE.core.api.fetch('/broken'),{name:'SyntaxError'}); +}); + +test('navigation aborts page signals and replacing a page signal cancels the previous request',t=>{ + const {JE,window}=setup(t); const m=JE.core.api.manager; + const old=m.getAbortSignal('page'),next=m.getAbortSignal('page'); assert.equal(old.aborted,true); + window.history.pushState({},'','#!/next'); assert.equal(next.aborted,true); +}); + +test('simultaneous DOM waits resolve independently and remove subscriptions',async t=>{ + const {JE,document}=setup(t,{},['dom-observer']); const dom=JE.core.dom; + const a=dom.waitForElement('.target'),b=dom.waitForElement('.target'); assert.equal(dom.getBodySubscriberCount(),2); + const el=document.createElement('div');el.className='target';document.body.append(el); + assert.equal(await a,el);assert.equal(await b,el);assert.equal(dom.getBodySubscriberCount(),0); +}); + +test('DOM waits inspect all candidates and support root/predicate matching',async t=>{ + const {JE,document}=setup(t,{html:'
    old
    new
    '},['dom-observer']); + const el=await JE.core.dom.waitForElement('.item',{root:document.querySelector('section'),predicate:el=>el.textContent==='new'}); + assert.equal(el.textContent,'new');assert.equal(JE.core.dom.getBodySubscriberCount(),0); +}); + +test('DOM observers disconnect on teardown and do not see later changes',async t=>{ + const {JE,document}=setup(t,{},['dom-observer']);let calls=0; + JE.core.dom.onBodyMutation('test',()=>calls++,{priority:1}); + document.body.append(document.createElement('div'));await Promise.resolve();assert.equal(calls,1); + JE.core.dom.disconnectAllObservers();document.body.append(document.createElement('div'));await Promise.resolve(); + assert.equal(calls,1);assert.equal(JE.core.dom.getBodySubscriberCount(),0); +}); + +test('replacing a priority subscriber with a normal one retains other pre-paint subscribers',async t=>{ + const {JE,document}=setup(t,{},['dom-observer']);const seen=[]; + JE.core.dom.onBodyMutation('first',()=>seen.push('old'),{priority:10}); + JE.core.dom.onBodyMutation('second',()=>seen.push('second'),{priority:5}); + JE.core.dom.onBodyMutation('first',()=>seen.push('normal')); + document.body.append(document.createElement('div'));await Promise.resolve(); + assert.deepEqual(seen,['second']); +}); + +test('stale subscriber handles cannot remove their replacement',async t=>{ + const {JE,document}=setup(t,{},['dom-observer']);let seen=0; + const old=JE.core.dom.onBodyMutation('same',()=>{}, {priority:1}); + JE.core.dom.onBodyMutation('same',()=>seen++, {priority:1});old.unsubscribe(); + document.body.append(document.createElement('div'));await Promise.resolve();assert.equal(seen,1); +}); + +test('replacing dedicated observer with shared observer disconnects the old observer',async t=>{ + const {JE,document}=setup(t,{html:'
    '},['dom-observer']);let oldCalls=0; + const target=document.querySelector('#target'); + JE.core.dom.createObserver('same',()=>oldCalls++,target,{attributes:true}); + JE.core.dom.createObserver('same',()=>{},document.body,{childList:true,subtree:true}); + target.setAttribute('data-value','changed');await Promise.resolve();assert.equal(oldCalls,0); +}); + +test('refreshing an existing cache key does not evict an unrelated response',t=>{ + const {JE}=setup(t);const m=JE.core.api.manager;m.CONFIG.cache.maxEntries=2; + m.setCache('a',1);m.setCache('b',2);m.getCached('a');m.setCache('a',3); + assert.equal(m.getCached('b'),2);assert.equal(m.getCached('a'),3); +}); + +test('subscribing during priority dispatch does not revisit the current subscriber',async t=>{ + const {JE,document}=setup(t,{},['dom-observer']);let calls=0; + JE.core.dom.onBodyMutation('first',()=>{calls++;if(calls===1)JE.core.dom.onBodyMutation('new',()=>{}, {priority:2});},{priority:1}); + document.body.append(document.createElement('div'));await Promise.resolve();assert.equal(calls,1); +}); diff --git a/tests/frontend/features/features-autoskip.test.mjs b/tests/frontend/features/features-autoskip.test.mjs new file mode 100644 index 000000000..e619af829 --- /dev/null +++ b/tests/frontend/features/features-autoskip.test.mjs @@ -0,0 +1,46 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t){const h=createHarness();t.after(()=>h.close());h.load('enhanced/player/auto-skip.js');return {...h,api:h.JE.internals.autoSkip};} +for(const [src,expected] of [['/Videos/a/stream?StartTimeTicks=100000000',100000000],['/Videos/a/stream?starttimeticks=100&CopyTimestamps=True',0],['/Videos/a/master.m3u8?StartTimeTicks=100',0],['/Videos/a/stream?StartTimeTicks=100&Static=true',0],['blob:test',0],['/Videos/a/stream?StartTimeTicks=-10',0]]){ + test(`auto-skip transcode offset ${src}`,t=>{const h=setup(t);assert.equal(h.api.parseTranscodeOffsetTicksFromSrc(src),expected);}); +} +async function engine(t,{segments=[{Id:'intro',Type:'Intro',StartTicks:100000000,EndTicks:200000000}],fetchSegments,offset=0,duration=100}={}){ + const h=setup(t);const skipped=[];let item='first';let loads=0; + const video=h.document.createElement('video');Object.defineProperty(video,'duration',{value:duration}); + const e=h.api.createAutoSkipEngine({shouldSkipType:type=>type==='Intro',resolveItemId:()=>item,fetchSegments:fetchSegments|| (async()=>{loads++;return segments;}),getPositionOffsetTicks:()=>offset,onSkipped:s=>skipped.push(s.Id)}); + e.attach(video);await new Promise(done=>setImmediate(done)); + return {...h,e,video,skipped,loads:()=>loads,setItem:id=>{item=id;},tick(time){video.currentTime=time;video.dispatchEvent(new h.window.Event('timeupdate'));}}; +} +test('auto-skip honors exact boundaries, clamps duration and ignores unsupported segments',async t=>{ + const h=await engine(t,{duration:18});h.tick(9);assert.equal(h.video.currentTime,9);h.tick(10);assert.equal(h.video.currentTime,18);assert.deepEqual(h.skipped,['intro']); +}); +test('auto-skip backward entry is ignored, while a fresh replay skips again',async t=>{ + const h=await engine(t);h.tick(9);h.tick(10);assert.equal(h.video.currentTime,20); + h.tick(21);h.tick(12);assert.equal(h.video.currentTime,12);h.tick(13);assert.equal(h.video.currentTime,13); + h.e.detach();h.e.attach(h.video);await new Promise(done=>setImmediate(done)); + h.tick(9);h.tick(10);assert.equal(h.video.currentTime,20);assert.equal(h.skipped.length,2); +}); +test('auto-skip repeated attach fetches once and detach removes timeupdate behavior',async t=>{ + const h=await engine(t);h.e.attach(h.video);h.e.attach(h.video);assert.equal(h.loads(),1); + h.e.detach();h.e.detach();h.tick(10);assert.equal(h.video.currentTime,10);assert.deepEqual(h.skipped,[]); +}); +test('auto-skip converts absolute segment end to progressive element clock',async t=>{ + const h=await engine(t,{offset:100000000});h.tick(0);assert.equal(h.video.currentTime,10); +}); +test('auto-skip ignores missing end, short segments, unsupported types and reversed boundaries',async t=>{ + const h=await engine(t,{segments:[{Type:'Intro',StartTicks:0},{Type:'Intro',StartTicks:0,EndTicks:5000000},{Type:'Recap',StartTicks:10000000,EndTicks:50000000},{Type:'Intro',StartTicks:60000000,EndTicks:50000000}]}); + for(const pos of [0,1,2,6]){h.tick(pos);assert.equal(h.video.currentTime,pos);}assert.deepEqual(h.skipped,[]); +}); +test('old-item segment response cannot seek after playback identity changes',async t=>{ + const first=deferred();const h=await engine(t,{fetchSegments:id=>id==='first'?first.promise:Promise.resolve([])}); + h.setItem('second');h.tick(0);first.resolve([{Type:'Intro',StartTicks:0,EndTicks:200000000}]);await new Promise(done=>setImmediate(done)); + h.tick(1);assert.equal(h.video.currentTime,1);assert.deepEqual(h.skipped,[]); +}); +test('session resolver coalesces source probes and discards old-source results',async t=>{ + const h=setup(t);const first=deferred();let calls=0; + const resolve=h.api.createSessionItemResolver({parseFromSrc:()=>null,fallbackId:()=>null,probeNowPlayingId:()=>{calls++;return calls===1?first.promise:Promise.resolve('new');}}); + const video={currentSrc:'blob:first'};assert.equal(resolve(video),null);assert.equal(resolve(video),null);assert.equal(calls,1); + video.currentSrc='blob:second';resolve(video);first.resolve('old');await new Promise(done=>setImmediate(done)); + assert.equal(resolve(video),null);await new Promise(done=>setImmediate(done));assert.equal(resolve(video),'new');assert.equal(calls,2); +}); diff --git a/tests/frontend/features/features-bookmarks.test.mjs b/tests/frontend/features/features-bookmarks.test.mjs new file mode 100644 index 000000000..f0fbc6efb --- /dev/null +++ b/tests/frontend/features/features-bookmarks.test.mjs @@ -0,0 +1,117 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, plain } from '../helpers/harness.mjs'; + +function setup(t, bookmarks = {}, save = async()=>{}) { + const events=[]; + const h=createHarness({JE:{pluginConfig:{BookmarksEnabled:true},userConfig:{bookmark:{bookmarks}},saveUserSettings:save,t:key=>key,toast:()=>{}}}); + t.after(()=>h.close()); + h.document.addEventListener('je-bookmarks-updated',e=>events.push(e.detail.reason)); + h.load('enhanced/bookmarks/bookmarks.js'); + return {...h,events,api:h.JE.bookmarks}; +} +test('bookmarks remain inactive when feature is disabled',t=>{ + const h=createHarness({JE:{pluginConfig:{BookmarksEnabled:false}}});t.after(()=>h.close()); + h.load('enhanced/bookmarks/bookmarks.js'); + assert.equal(h.JE.bookmarks,undefined); + assert.equal(h.JE.initializeBookmarks,undefined); +}); +test('bookmark lookup prefers exact item IDs and ignores corrupt entries',t=>{ + const h=setup(t,{exact:{itemId:'current',tmdbId:'42'},other:{itemId:'old',tmdbId:'42'},bad:null,legacy:34}); + const result=h.api.findForItem('current','42'); + assert.deepEqual(Array.from(result.bookmarks,b=>b.id),['exact']); + assert.equal(result.hasIdMismatch,false); + assert.equal(result.providerMatches.length,1); +}); +test('episode provider fallback excludes other episodes and seasons but retains legacy records',t=>{ + const h=setup(t,{same:{itemId:'old',tmdbId:'42',seasonNumber:2,episodeNumber:3},episode:{tmdbId:'42',seasonNumber:2,episodeNumber:4},season:{tmdbId:'42',seasonNumber:1,episodeNumber:3},legacy:{tvdbId:'tv42'}}); + const result=h.api.findForItem('new','42','tv42',2,3); + assert.deepEqual(Array.from(result.bookmarks,b=>b.id),['same','legacy']); + assert.equal(result.hasIdMismatch,true); +}); +test('bookmark update/delete persist and emit only successful changes',async t=>{ + const saved=[]; const h=setup(t,{one:{itemId:'item',label:'old'}},async(file,data)=>saved.push([file,plain(data)])); + assert.equal(await h.api.update('missing',{}),false); + assert.equal(await h.api.delete('missing'),false); + assert.equal(saved.length,0); + assert.equal(await h.api.update('one',{label:'new'}),true); + assert.equal(saved[0][0],'bookmark.json'); + assert.equal(saved[0][1].bookmarks.one.label,'new'); + assert.equal(await h.api.delete('one'),true); + assert.deepEqual(h.events,['update','delete']); + assert.equal(Object.keys(h.JE.userConfig.bookmark.bookmarks).length,0); +}); +for(const operation of ['update','delete']){ + test(`failed bookmark ${operation} restores in-memory record without emitting success`,async t=>{ + const original={itemId:'item',label:'keep',updatedAt:'old'}; + const h=setup(t,{one:{...original}},async()=>{throw new Error('offline');}); + h.expectConsoleError(new RegExp(`Failed to ${operation} bookmark`)); + assert.equal(await h.api[operation]('one',{label:'lost'}),false); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks.one),original); + assert.deepEqual(h.events,[]); + }); +} +test('bookmark sync preserves originals, offsets timestamps and rolls back failed copies',async t=>{ + let fail=false; + const h=setup(t,{old:{itemId:'old-item',timestamp:5,label:'scene'}},async()=>{if(fail)throw new Error('offline');}); + const result=await h.api.syncBookmarks([{id:'old',...h.JE.userConfig.bookmark.bookmarks.old}],{itemId:'new-item',tmdbId:'42'},-10); + assert.equal(result[0].timestamp,0); + assert.equal(result[0].syncedFrom,'old-item'); + assert.equal(h.JE.userConfig.bookmark.bookmarks.old.itemId,'old-item'); + const before=plain(h.JE.userConfig.bookmark.bookmarks); + fail=true; + h.expectConsoleError(/Failed to sync bookmarks/); + await assert.rejects(h.api.syncBookmarks([result[0]],{itemId:'another'},2),/offline/); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),before); + assert.deepEqual(h.events,['sync']); +}); +test('adding bookmark without active item fails gracefully',async t=>{ + const h=setup(t); assert.equal(await h.api.add(12),null);assert.deepEqual(h.events,[]); +}); +for(const operation of ['update','delete']) { + test(`old-user failed bookmark ${operation} cannot restore data into new-user store`,async t=>{ + let reject; + const h=setup(t,{one:{itemId:'secret',label:'old-user'}},()=>new Promise((_,r)=>{reject=r;})); + const pending=h.api[operation]('one',{label:'new'}); + h.JE.userConfig={bookmark:{bookmarks:{}}}; + h.expectConsoleError(new RegExp(`Failed to ${operation} bookmark`)); + reject(new Error('offline')); + assert.equal(await pending,false); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),{}); + }); +} +test('bookmark item lookup resolving after a user switch cannot add old-user data',async t=>{ + let resolve;let saves=0; + const h=setup(t,{},async()=>{saves++;}); + h.document.body.innerHTML='
    '; + h.window.ApiClient.ajax=()=>new Promise(r=>{resolve=r;}); + const pending=h.api.add(5); + h.JE.userConfig={bookmark:{bookmarks:{}}}; + resolve({Items:[{Id:'secret',Name:'Secret movie',Type:'Movie',ProviderIds:{Tmdb:'42'}}]}); + assert.equal(await pending,null); + assert.equal(saves,0);assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),{}); +}); +for(const operation of ['add','update','delete','syncBookmarks'])for(const failure of [false,true]){ + test(`bookmark ${operation} ${failure?'failure':'success'} after logout leaves absent config untouched`,async t=>{ + let resolve,reject;const h=setup(t,{one:{itemId:'old',timestamp:5}},()=>new Promise((a,b)=>{resolve=a;reject=b;})); + h.document.body.innerHTML='
    '; + h.window.ApiClient.ajax=async()=>({Items:[{Id:'item',Name:'Movie',Type:'Movie'}]}); + const pending=operation==='add'?h.api.add(5):operation==='syncBookmarks'?h.api.syncBookmarks([{itemId:'old',timestamp:5}],{itemId:'new'}):h.api[operation]('one',{label:'updated'}); + await new Promise(done=>setImmediate(done)); + h.JE.userConfig={}; + if(failure){h.expectConsoleError(new RegExp(`Failed to ${operation==='add'?'save':operation==='syncBookmarks'?'sync':operation} bookmark`));reject(new Error('offline'));if(operation==='add'||operation==='syncBookmarks')await assert.rejects(pending,/offline/);else assert.equal(await pending,false);}else{resolve();await pending;} + assert.deepEqual(h.JE.userConfig,{});assert.deepEqual(h.events,[]); + }); +} +test('bookmark metadata cached for one user is fetched again after account switch',async t=>{ + const h=setup(t);let calls=0; + h.document.body.innerHTML='
    '; + h.window.ApiClient.ajax=async()=>{calls++;return {Items:[{Id:'item',Name:calls===1?'Private name':'Redacted',Type:'Movie'}]};}; + assert.equal((await h.api.add(1)).name,'Private name'); + h.JE.userConfig={bookmark:{bookmarks:{}}}; + assert.equal((await h.api.add(2)).name,'Redacted');assert.equal(calls,2); +}); +test('stale episode metadata cannot start a secondary series lookup as the old user',async t=>{ + let resolve,calls=0;const h=setup(t);h.document.body.innerHTML='
    '; + h.window.ApiClient.ajax=()=>{calls++;return new Promise(r=>{resolve=r;});};const pending=h.api.add(5);h.JE.userConfig={};resolve({Items:[{Id:'episode',Type:'Episode',SeriesId:'private-series'}]});assert.equal(await pending,null);assert.equal(calls,1); +}); diff --git a/tests/frontend/features/features-hidden.test.mjs b/tests/frontend/features/features-hidden.test.mjs new file mode 100644 index 000000000..07e8faacf --- /dev/null +++ b/tests/frontend/features/features-hidden.test.mjs @@ -0,0 +1,61 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,{items={},settings={},html='',ajax=async()=>({Items:[]})}={}){ + let epoch=0; const handlers=new Map();const frames=[]; + const h=createHarness({html,apiClient:{ajax},globals:{requestAnimationFrame:fn=>frames.push(fn)},JE:{userConfig:{hiddenContent:{items,settings}},session:{getEpoch:()=>epoch,isCurrent:e=>e===epoch,onUserChange:(key,fn)=>handlers.set(key,fn)}}}); + t.after(()=>h.close()); + h.load('enhanced/hiddencontent/hidden-content-data.js');h.load('enhanced/hiddencontent/hidden-content-filter.js'); + const api=h.JE.internals.hiddenContent;api.resetFromUserConfig(); + return {...h,api,flush(){while(frames.length)frames.shift()();},switchUser(){epoch++;h.JE.userConfig={hiddenContent:{items:{},settings:{}}};for(const fn of handlers.values())fn({userId:'next'});}}; +} +test('hidden scope filters home rows without hiding same title in the library',t=>{ + const h=setup(t,{items:{global:{itemId:'AA-BB'},next:{itemId:'CC-DD',hideScope:'nextup'}},html:'

    Next Up

    '}); + h.api.filterNativeCards();h.flush();const cards=h.document.querySelectorAll('.card'); + assert.equal(cards[0].classList.contains('je-hidden'),true); + assert.equal(cards[1].classList.contains('je-hidden'),true); + assert.equal(cards[2].classList.contains('je-hidden'),false); + h.api.restoreNativeCardsForIds(new Set(['AA-BB'])); + assert.equal(cards[0].classList.contains('je-hidden'),false); +}); +test('home-section scopes merge without weakening an existing global hide',t=>{ + const h=setup(t,{items:{a:{itemId:'a',hideScope:'nextup'},b:{itemId:'b',hideScope:'global'}}}); + h.api.markScopedHidden('a','continuewatching');h.api.markScopedHidden('b','nextup'); + assert.equal(h.api.isHiddenOnSurface('a','nextup'),true); + assert.equal(h.api.isHiddenOnSurface('a','continuewatching'),true); + assert.equal(h.api.isHiddenOnSurface('a','library'),false); + assert.equal(h.api.isHiddenOnSurface('b','library'),true); +}); +test('disabled filtering and search default preserve cards; image-editor cards are ignored',t=>{ + const h=setup(t,{items:{a:{itemId:'a'}},html:'
    '}); + h.window.location.hash='!/search';h.api.filterNativeCards();h.flush();assert.equal(h.document.querySelectorAll('.je-hidden').length,0); + h.window.location.hash='!/home';h.api.filterNativeCards();h.flush();assert.equal(h.document.querySelectorAll('.je-hidden').length,1); + h.api.getHiddenData().settings.enabled=false;h.api.refreshNativeCardVisibility();h.flush();assert.equal(h.document.querySelectorAll('.je-hidden').length,0); +}); +test('hidden requests/calendar normalize IDs and respect surface settings',t=>{ + const h=setup(t,{items:{a:{itemId:'AA-BB',tmdbId:42,name:'Show (US)'},scoped:{itemId:'scoped',tmdbId:55,hideScope:'nextup'}}}); + assert.deepEqual(Array.from(h.api.filterRequestItems([{tmdbId:42},{jellyfinMediaId:'aabb'},{tmdbId:55}]),x=>x.tmdbId),[55]); + assert.equal(h.api.filterCalendarEvents([{title:'Show'},{title:'Visible'},{itemId:'AA-BB'}]).length,1); + h.api.getHiddenData().settings.filterRequests=false; + assert.equal(h.api.filterRequestItems([{tmdbId:42}]).length,1); +}); +test('switching users clears previous hidden DOM marks and cached policy',t=>{ + const h=setup(t,{items:{a:{itemId:'a'}},html:'
    '}); + h.api.filterNativeCards();h.flush();h.switchUser(); + const card=h.document.querySelector('.card');assert.equal(card.classList.contains('je-hidden'),false);assert.equal(card.hasAttribute('data-je-hidden-checked'),false);assert.equal(h.api.hiddenIdSet.size,0); +}); +test('old-user hidden-content refresh cannot overwrite incoming preferences',async t=>{ + const pending=deferred();const h=setup(t,{ajax:()=>pending.promise}); + const refresh=h.api.refresh();h.switchUser();pending.resolve({items:{secret:{itemId:'secret'}},settings:{}});await refresh; + assert.equal(h.api.hiddenIdSet.size,0);assert.equal(Object.keys(h.JE.userConfig.hiddenContent.items).length,0); +}); +test('parent-series API IDs are normalized before cascading hidden policy',async t=>{ + const response=deferred();const h=setup(t,{items:{series:{itemId:'AA-BB'}},html:'
    ',ajax:()=>response.promise}); + h.api.filterNativeCards();response.resolve({Items:[{Id:'CC-DD',SeriesId:'AA-BB'}]});await response.promise;await new Promise(resolve=>setImmediate(resolve));h.flush(); + assert.equal(h.document.querySelector('.card').classList.contains('je-hidden'),true); +}); +test('parent-series response after disabling filtering cannot hide cards',async t=>{ + const response=deferred();const h=setup(t,{items:{series:{itemId:'series'}},html:'
    ',ajax:()=>response.promise}); + h.api.filterNativeCards();h.api.getHiddenData().settings.enabled=false;response.resolve({Items:[{Id:'episode',SeriesId:'series'}]});await response.promise;await new Promise(resolve=>setImmediate(resolve));h.flush(); + assert.equal(h.document.querySelector('.card').classList.contains('je-hidden'),false); +}); diff --git a/tests/frontend/features/features-media-info.test.mjs b/tests/frontend/features/features-media-info.test.mjs new file mode 100644 index 000000000..7cdacc93d --- /dev/null +++ b/tests/frontend/features/features-media-info.test.mjs @@ -0,0 +1,37 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,request=async()=>({size:1073741824,progress:50,totalPlaybackTicks:600000000,totalRuntimeTicks:1200000000})){ + const calls=[],saved=[],handlers=[];let epoch=0;const h=createHarness({html:'
    ',globals:{requestAnimationFrame:fn=>{fn();return 1;}},JE:{currentSettings:{},t:key=>key,saveUserSettings:async(file,data)=>saved.push([file,{...data}]),core:{api:{plugin:(...args)=>{calls.push(args);return request(...args);}}},session:{getEpoch:()=>epoch,isCurrent:e=>e===epoch,onUserChange:(key,fn)=>handlers.push(fn)}}}); + t.after(()=>h.close());h.load('enhanced/itemdetails/features-details-media-info.js');return {...h,calls,saved,api:h.JE.internals.features,container:h.document.getElementById('info'),async settle(){await new Promise(done=>setImmediate(done));},switchUser(){epoch++;for(const fn of handlers)fn();}}; +} +test('watch progress and file-size chips share request and deduplicate repeated rendering',async t=>{ + const h=setup(t);h.api.displayWatchProgress('item',h.container);h.api.displayItemSize('item',h.container);h.api.displayWatchProgress('item',h.container);h.api.displayItemSize('item',h.container);await h.settle(); + assert.equal(h.calls.length,1);assert.equal(h.container.classList.contains('hide'),false);assert.equal(h.container.children.length,2);assert.match(h.container.textContent,/50%/);assert.match(h.container.textContent,/1.*GB/); +}); +test('watch progress cycles percentage/time/remaining and persists preference',async t=>{ + const h=setup(t);h.api.displayWatchProgress('item',h.container);await h.settle();const chip=h.container.firstChild; + chip.click();assert.equal(h.JE.currentSettings.watchProgressMode,'time');assert.match(chip.textContent,/1m.*2m/);chip.click();assert.equal(h.JE.currentSettings.watchProgressMode,'remaining');assert.match(chip.textContent,/-1m/);chip.click();assert.equal(h.JE.currentSettings.watchProgressMode,'percentage');assert.equal(h.saved.length,3); +}); +test('file-size cache distinguishes media source and failure keeps unavailable chip',async t=>{ + let fail=false;const h=setup(t,async()=>{if(fail)throw new Error('offline');return {size:100};});h.api.displayItemSize('item',h.container,'first');await h.settle();h.api.displayItemSize('item',h.container,'second');await h.settle();assert.equal(h.calls.length,2);assert.match(h.calls[1][0],/mediaSourceId=second/); + fail=true;h.expectConsoleError(/Error fetching item size/);h.api.displayItemSize('other',h.container);await h.settle();assert.match(h.container.textContent,/-/); +}); +test('old-user watch progress cannot repopulate cache after user switch',async t=>{ + const pending=deferred();let first=true;const h=setup(t,()=>{if(first){first=false;return pending.promise;}return Promise.resolve({progress:10});});h.api.displayWatchProgress('item',h.container);h.switchUser();pending.resolve({progress:99});await h.settle();h.container.replaceChildren();h.api.displayWatchProgress('item',h.container);await h.settle();assert.equal(h.calls.length,2);assert.match(h.container.textContent,/10%/);assert.doesNotMatch(h.container.textContent,/99/); +}); +test('old-user file-size result cannot repopulate cache after user switch',async t=>{ + const pending=deferred();let first=true;const h=setup(t,()=>{if(first){first=false;return pending.promise;}return Promise.resolve({size:1024});});h.api.displayItemSize('item',h.container);h.switchUser();pending.resolve({size:1073741824});await h.settle();h.container.replaceChildren();h.api.displayItemSize('item',h.container);await h.settle();assert.equal(h.calls.length,2);assert.match(h.container.textContent,/1.*KB/); +}); +test('audio language projection deduplicates tracks, omits undetermined and non-audio streams',async t=>{ + const h=setup(t);h.JE.cdn={flagSvg:code=>`/flags/${code}.svg`};h.load('core/media-language.js');h.window.ApiClient.ajax=async()=>({Items:[{Type:'Movie',MediaStreams:[{Type:'Audio',Language:'en-US'},{Type:'Audio',Language:'en-US'},{Type:'Audio',Language:'und'},{Type:'Subtitle',Language:'fr'}]}]}); + h.api.displayAudioLanguages('item',h.container);await h.settle();const languages=h.container.querySelectorAll('.audio-language-item');assert.equal(languages.length,1);assert.equal(languages[0].dataset.lang,'en-US'); +}); +test('audio language selected native source fallback excludes other versions',async t=>{ + const h=setup(t);h.JE.cdn={flagSvg:code=>`/flags/${code}.svg`};h.load('core/media-language.js');h.window.ApiClient.ajax=async()=>{throw new Error('not available');};h.window.ApiClient.getItem=async()=>({Type:'Movie',MediaSources:[{Id:'a',MediaStreams:[{Type:'Audio',Language:'en'}]},{Id:'b',MediaStreams:[{Type:'Audio',Language:'fr'}]}]}); + h.api.displayAudioLanguages('item',h.container,'b');await h.settle();assert.deepEqual(Array.from(h.container.querySelectorAll('.audio-language-item'),e=>e.dataset.lang),['fr']); +}); +test('old-user audio languages cannot repopulate cache after user switch',async t=>{ + const h=setup(t);h.JE.cdn={flagSvg:code=>`/flags/${code}.svg`};h.load('core/media-language.js');const pending=deferred();let calls=0;h.window.ApiClient.ajax=()=>{calls++;return calls===1?pending.promise:Promise.resolve({Items:[{Type:'Movie',MediaStreams:[{Type:'Audio',Language:'fr'}]}]});}; + h.api.displayAudioLanguages('item',h.container);h.switchUser();pending.resolve({Items:[{Type:'Movie',MediaStreams:[{Type:'Audio',Language:'en'}]}]});await h.settle();h.container.replaceChildren();h.api.displayAudioLanguages('item',h.container);await h.settle();assert.equal(calls,2);assert.deepEqual(Array.from(h.container.querySelectorAll('.audio-language-item'),e=>e.dataset.lang),['fr']); +}); diff --git a/tests/frontend/features/features-pause.test.mjs b/tests/frontend/features/features-pause.test.mjs new file mode 100644 index 000000000..0d32dc836 --- /dev/null +++ b/tests/frontend/features/features-pause.test.mjs @@ -0,0 +1,36 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,enabled=true){ + let subscriptions=0,releases=0;const handlers=[];const h=createHarness({JE:{session:{onUserChange:(key,fn)=>handlers.push(fn)},currentSettings:{pauseScreenEnabled:enabled},t:key=>key,helpers:{onBodyMutation:()=>{subscriptions++;return {unsubscribe:()=>releases++};}}}}); + h.window.localStorage.setItem('jellyfin_credentials',JSON.stringify({Servers:[{Id:'wrong',UserId:'other',AccessToken:'wrong'},{Id:'server-a',UserId:'user-a',AccessToken:'test'}]})); + h.load('enhanced/player/pausescreen.js');t.after(()=>h.close());return {...h,switchUser:()=>handlers.forEach(fn=>fn()),counts:()=>({subscriptions,releases})}; +} +test('disabled pause screen creates no DOM or observers',t=>{const h=setup(t,false);h.JE.initializePauseScreen();assert.equal(h.JE.pauseScreenInstance,undefined);assert.deepEqual(h.counts(),{subscriptions:0,releases:0});}); +test('pause screen uses active server credentials and has one instance after repeated initialization',t=>{ + const h=setup(t);h.JE.initializePauseScreen();const first=h.JE.pauseScreenInstance;assert.equal(first.userId,'user-a');assert.equal(first.token,'test'); + h.JE.initializePauseScreen();assert.equal(h.document.querySelectorAll('#pause-screen-overlay').length,1);assert.deepEqual(h.counts(),{subscriptions:1,releases:0}); + h.JE.pauseScreenInstance.destroy();assert.equal(h.document.querySelector('#pause-screen-overlay'),null);assert.deepEqual(h.counts(),{subscriptions:1,releases:1}); +}); +test('pause overlay restores focus and destroy removes keyboard capture',t=>{ + const h=setup(t);h.document.body.innerHTML='';h.JE.initializePauseScreen();const p=h.JE.pauseScreenInstance;const prior=h.document.getElementById('prior');prior.focus();p.showOverlay(); + assert.equal(p.overlay.getAttribute('aria-hidden'),'false');assert.equal(h.document.activeElement,p.overlayContent);p.hideOverlay();assert.equal(h.document.activeElement,prior); + p.showOverlay();p.destroy();p.overlay.setAttribute('aria-hidden','false');const event=new h.window.KeyboardEvent('keydown',{code:'Space',bubbles:true,cancelable:true});h.document.dispatchEvent(event);assert.equal(event.defaultPrevented,false); +}); +test('pause metadata renders untrusted rating and overview as text',async t=>{ + const h=setup(t);h.JE.initializePauseScreen();const p=h.JE.pauseScreenInstance;p.firstAvailableBlobURL=async()=>null; + const malicious='';await p.displayItemInfo({OfficialRating:malicious,Overview:malicious,RunTimeTicks:54000000000},'http://local','item'); + assert.equal(p.overlayPlot.textContent,malicious);assert.equal(p.overlayDetails.querySelector('img'),null);assert.match(p.overlayDetails.textContent,/1h 30m/);assert.ok(p.overlayDetails.textContent.includes(malicious)); +}); +test('pause metadata failure shows translated fallback and clears previous text',async t=>{ + const h=setup(t);h.JE.initializePauseScreen();const p=h.JE.pauseScreenInstance;p.overlayPlot.textContent='old';p.fetchWithRetry=async()=>{throw new Error('offline');};h.expectConsoleError(/Error fetching item info/);await p.fetchItemInfo('item');assert.equal(p.overlayPlot.textContent,'pausescreen_fetch_error'); +}); + +test('pause initialization after external overlay removal releases original observers',t=>{ + const h=setup(t);h.JE.initializePauseScreen();h.JE.pauseScreenInstance.overlay.remove();h.JE.initializePauseScreen();assert.deepEqual(h.counts(),{subscriptions:2,releases:1}); +}); +test('pause metadata arriving after user switch cannot restore old-user data',async t=>{ + const h=setup(t);h.JE.initializePauseScreen();const p=h.JE.pauseScreenInstance;const pending=deferred();p.fetchWithRetry=()=>pending.promise;p.firstAvailableBlobURL=async()=>null; + h.window.ApiClient.serverAddress=()=> 'http://local';p.showOverlay();const request=p.fetchItemInfo('secret');h.switchUser();pending.resolve({Overview:'old-user secret'});await request; + assert.equal(p.overlay.getAttribute('aria-hidden'),'true');assert.equal(p.overlayPlot.textContent,'');assert.equal(p.itemCache.size,0); +}); diff --git a/tests/frontend/features/features-playback.test.mjs b/tests/frontend/features/features-playback.test.mjs new file mode 100644 index 000000000..bb2c72f32 --- /dev/null +++ b/tests/frontend/features/features-playback.test.mjs @@ -0,0 +1,18 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness} from '../helpers/harness.mjs'; +function setup(t,html=''){ + const toasts=[];const h=createHarness({html,JE:{currentSettings:{},t:(key,args)=>args?`${key}:${JSON.stringify(args)}`:key,toast:text=>toasts.push(text)}});t.after(()=>h.close());h.load('enhanced/player/playback.js');return {...h,toasts,video:h.document.querySelector('video')}; +} +test('playback speed steps, clamps at supported limits and resets',t=>{ + const h=setup(t);h.JE.adjustPlaybackSpeed('increase');assert.equal(h.video.playbackRate,1.25);h.JE.adjustPlaybackSpeed('decrease');assert.equal(h.video.playbackRate,1);h.video.playbackRate=2;h.JE.adjustPlaybackSpeed('increase');assert.equal(h.video.playbackRate,2);h.video.playbackRate=0.25;h.JE.adjustPlaybackSpeed('decrease');assert.equal(h.video.playbackRate,0.25);h.JE.resetPlaybackSpeed();assert.equal(h.video.playbackRate,1); +}); +test('playback controls without a video show translated feedback',t=>{ + const h=setup(t,'');h.JE.adjustPlaybackSpeed('increase');h.JE.resetPlaybackSpeed();h.JE.jumpToPercentage(50);assert.deepEqual(h.toasts,['toast_no_video_found','toast_no_video_found','toast_no_video_found']); +}); +test('percentage seek uses actual duration and last-position tracker is consumed once',t=>{ + const h=setup(t);Object.defineProperty(h.video,'duration',{value:200});h.JE.jumpToPercentage(25);assert.equal(h.video.currentTime,50);h.JE.attachSeekTracker(h.video);h.JE.attachSeekTracker(h.video);h.video.dispatchEvent(new h.window.Event('timeupdate'));h.video.currentTime=100;h.video.dispatchEvent(new h.window.Event('seeking'));h.JE.jumpToLastPosition();assert.equal(h.video.currentTime,50);h.video.currentTime=80;h.JE.jumpToLastPosition();assert.equal(h.video.currentTime,80); +}); +test('player item identity follows video route then OSD item hint',t=>{ + const h=setup(t);h.window.location.hash='#!/video?id=route-item';assert.equal(h.JE.internals.player.getCurrentVideoItemId(),'route-item'); +}); diff --git a/tests/frontend/features/features-random.test.mjs b/tests/frontend/features/features-random.test.mjs new file mode 100644 index 000000000..9d42744a3 --- /dev/null +++ b/tests/frontend/features/features-random.test.mjs @@ -0,0 +1,24 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,{settings={},ajax=async()=>({Items:[{Id:'picked',Type:'Movie'}]}),getItem=async()=>null}={}){ + const paths=[],toasts=[],timers=[],intervals=new Set();const h=createHarness({html:'
    ',apiClient:{ajax:options=>{paths.push(options.url);return ajax(options);},getItem},globals:{setInterval:fn=>{intervals.add(fn);return fn;},clearInterval:fn=>intervals.delete(fn),setTimeout:fn=>{timers.push(fn);return fn;}},JE:{currentSettings:{randomButtonEnabled:true,randomIncludeMovies:true,randomIncludeShows:true,...settings},t:key=>key,toast:text=>toasts.push(text),icon:()=>'',IconName:{ERROR:'error'},escapeHtml:text=>text,helpers:{}}}); + h.JE.helpers.getHeaderButtonTray=()=>h.document.getElementById('tray');h.load('enhanced/features-random-button.js');t.after(()=>h.close()); + return {...h,paths,toasts,intervals,async click(){h.document.getElementById('randomItemButton').click();await new Promise(done=>setImmediate(done));while(timers.length)timers.shift()();}}; +} +test('random button repeated mounting stays single and disabling removes it',t=>{ + const h=setup(t);h.JE.addRandomButton();h.JE.addRandomButton();assert.equal(h.document.querySelectorAll('#randomItemButton').length,1);h.JE.currentSettings.randomButtonEnabled=false;h.JE.addRandomButton();assert.equal(h.document.querySelector('#randomItemButton'),null); +}); +test('random library pick navigates to details and clears loading/animation state',async t=>{ + const h=setup(t);h.JE.addRandomButton();await h.click();assert.match(h.paths[0],/IncludeItemTypes=Movie,Series&Recursive=true/);assert.match(h.window.location.hash,/id=picked/);assert.equal(h.document.querySelector('button').disabled,false);assert.equal(h.intervals.size,0);assert.deepEqual(h.toasts,['toast_random_item_loaded']); +}); +test('random pinned collection includes episodes and falls back when unwatched filter empties it',async t=>{ + const h=setup(t,{settings:{randomSourceId:'collection',randomUnwatchedOnly:true},getItem:async()=>({Id:'collection',Type:'BoxSet',Name:'Collection'}),ajax:async({url})=>url.includes('ParentId=')?{Items:[{Id:'watched',Type:'Movie',UserData:{Played:true}}]}:{Items:[{Id:'eligible',Type:'Series',UserData:{UnplayedItemCount:1}},{Id:'watched-series',Type:'Series',UserData:{UnplayedItemCount:0}}]}}); + h.JE.addRandomButton();await h.click();assert.equal(h.paths.length,2);assert.match(h.paths[0],/Movie,Series,Episode&ParentId=collection/);assert.match(h.window.location.hash,/id=eligible/);assert.deepEqual(h.toasts,['toast_random_source_empty']); +}); +test('random missing pinned source falls back with a single explanatory toast',async t=>{ + const h=setup(t,{settings:{randomSourceId:'missing'},getItem:async()=>{throw new Error('404');}});h.JE.addRandomButton();await h.click();assert.match(h.window.location.hash,/id=picked/);assert.deepEqual(h.toasts,['toast_random_source_missing']); +}); +test('random metadata failure restores button and stops dice timer',async t=>{ + const h=setup(t,{ajax:async()=>{throw new Error('offline');}});h.expectConsoleError(/Error fetching random item/);h.JE.addRandomButton();await h.click();assert.equal(h.document.querySelector('button').disabled,false);assert.equal(h.intervals.size,0);assert.match(h.toasts[0],/offline/); +}); diff --git a/tests/frontend/features/features-release-dates.test.mjs b/tests/frontend/features/features-release-dates.test.mjs new file mode 100644 index 000000000..9dfe7d509 --- /dev/null +++ b/tests/frontend/features/features-release-dates.test.mjs @@ -0,0 +1,28 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred,jsonResponse} from '../helpers/harness.mjs'; +function setup(t,{item={Type:'Movie',ProviderIds:{Tmdb:'42'}},data={},fetch,getItem}={}){ + let itemCalls=0,fetchCalls=0;const h=createHarness({html:'
    ',apiClient:{getItem:async(...args)=>{itemCalls++;return getItem?getItem(...args):item;}},fetch:async(...args)=>{fetchCalls++;return fetch?fetch(...args):jsonResponse(data);},globals:{requestAnimationFrame:fn=>{fn();return 1;}},JE:{pluginConfig:{DEFAULT_REGION:'AU'},t:key=>key,helpers:{addCSS:()=>{}}}}); + t.after(()=>h.close());h.load('enhanced/itemdetails/features-release-dates.js');return {...h,api:h.JE.internals.features,container:h.document.getElementById('info'),counts:()=>({itemCalls,fetchCalls}),async settle(){await new Promise(done=>setImmediate(done));}}; +} +test('release chips deduplicate in-flight requests and resolve regional theatrical/digital/physical dates',async t=>{ + const pending=deferred();const h=setup(t,{fetch:()=>pending.promise});h.api.displayReleaseDate('movie',h.container);h.api.displayReleaseDate('movie',h.container); + pending.resolve(jsonResponse({results:[{iso_3166_1:'AU',release_dates:[{type:3,release_date:'2025-03-01'},{type:2,release_date:'2025-02-01'}]},{iso_3166_1:'US',release_dates:[{type:4,release_date:'2025-04-01'}]},{iso_3166_1:'GB',release_dates:[{type:5,release_date:'2025-05-01'}]}]}));await h.settle(); + assert.equal(h.container.querySelectorAll('.mediaInfoItem-releaseDate').length,3);assert.match(h.container.querySelector('.je-release-date-cinema').textContent,/Feb/);assert.deepEqual(h.counts(),{itemCalls:1,fetchCalls:1}); + h.container.replaceChildren();h.api.displayReleaseDate('movie',h.container);assert.equal(h.container.children.length,3);assert.deepEqual(h.counts(),{itemCalls:1,fetchCalls:1}); +}); +test('episode premiere date bypasses external TMDB request',async t=>{ + const h=setup(t,{getItem:async(_user,id)=>id==='series'?{Type:'Series',ProviderIds:{Tmdb:'42'}}:{Type:'Episode',SeriesId:'series',PremiereDate:'2025-02-03T00:00:00Z'}});h.api.displayReleaseDate('episode',h.container);await h.settle();assert.equal(h.container.children.length,1);assert.equal(h.counts().fetchCalls,0); +}); +test('missing metadata produces no placeholder or external request',async t=>{ + const h=setup(t,{item:{Type:'Movie'}});h.api.displayReleaseDate('movie',h.container);await h.settle();assert.equal(h.container.children.length,0);assert.equal(h.counts().fetchCalls,0); +}); +test('release-date network failure removes placeholder and logs expected diagnostic',async t=>{ + const h=setup(t,{fetch:async()=>jsonResponse({},503)});h.expectConsoleError(/TMDB request failed/);h.api.displayReleaseDate('movie',h.container);await h.settle();assert.equal(h.container.children.length,0); +}); +test('release response arriving after navigation does not recreate removed chips',async t=>{ + const response=deferred();const h=setup(t,{fetch:()=>response.promise});h.api.displayReleaseDate('movie',h.container);await Promise.resolve();h.container.replaceChildren();response.resolve(jsonResponse({results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:'2025-01-01'}]}]}));await h.settle();assert.equal(h.container.children.length,0); +}); +test('malformed external release date cannot inject HTML into details',async t=>{ + const malicious='';const h=setup(t,{data:{results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:malicious}]}]}});h.api.displayReleaseDate('movie',h.container);await h.settle();assert.equal(h.container.querySelector('img'),null);assert.ok(h.container.textContent.includes(malicious)); +}); diff --git a/tests/frontend/features/features-requests.test.mjs b/tests/frontend/features/features-requests.test.mjs new file mode 100644 index 000000000..8dda61aa3 --- /dev/null +++ b/tests/frontend/features/features-requests.test.mjs @@ -0,0 +1,30 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness} from '../helpers/harness.mjs'; +function setup(t,downloads=[]){ + const state={downloads,downloadsActiveTab:'all',downloadsSearchQuery:''}; + const h=createHarness({JE:{internals:{requestsPage:{state}}}});t.after(()=>h.close());h.load('arr/requests/requests-page-render-helpers.js'); + return {...h,state,api:h.JE.internals.requestsPage}; +} +const episode=(number,overrides={})=>({source:'Sonarr',title:'Show',seasonNumber:1,episodeNumber:number,progress:0.5,instanceName:'primary',status:'Downloading',...overrides}); +test('download grouping collapses packs, preserves singles and separates instances, seasons and progress',t=>{ + const h=setup(t);const entries=[episode(3),episode(1),episode(2),episode(4,{instanceName:'other'}),episode(5,{progress:0.8}),episode(1,{seasonNumber:2}),{source:'Radarr',title:'Film'}]; + const result=h.api.groupDownloads(entries);const packs=result.filter(e=>e.type==='seasonPack'); + assert.equal(packs.length,1);assert.equal(packs[0].episodeRange,'E01-E03');assert.equal(packs[0].episodeCount,3);assert.equal(result.length,5);assert.equal(entries.length,7); +}); +test('download statuses count collapsed packs once',t=>{ + const h=setup(t,[episode(1),episode(2),episode(3),episode(5,{status:'Queued',progress:0})]); + assert.deepEqual(Array.from(h.api.getDownloadStatuses(),pair=>Array.from(pair)),[['Downloading',1],['Queued',1]]); +}); +test('request filtering applies hidden policy before status and case-insensitive search',t=>{ + const h=setup(t,[{title:'Hidden',status:'Downloading'},{title:'Movie',subtitle:'Special EDITION',status:'Downloading'},{title:'Other',instanceName:'Remote',status:'Queued'}]); + h.JE.hiddenContent={filterRequestItems:items=>items.filter(item=>item.title!=='Hidden')}; + assert.equal(h.api.getFilteredDownloads().length,2); + h.state.downloadsActiveTab='Downloading';h.state.downloadsSearchQuery='edition'; + assert.deepEqual(Array.from(h.api.getFilteredDownloads(),x=>x.title),['Movie']); + h.state.downloadsActiveTab='all';h.state.downloadsSearchQuery='REMOTE'; + assert.deepEqual(Array.from(h.api.getFilteredDownloads(),x=>x.title),['Other']); +}); +for(const [value,expected] of [[null,''],['00:00:05','5s'],['00:02:05','2m 5s'],['01:02:03','1h 2m 3s'],['unknown','unknown']]){ + test(`download remaining time ${value}`,t=>{const h=setup(t);assert.equal(h.api.formatTimeRemaining(value),expected);}); +} diff --git a/tests/frontend/features/features-spoiler-ui.test.mjs b/tests/frontend/features/features-spoiler-ui.test.mjs new file mode 100644 index 000000000..587cd7735 --- /dev/null +++ b/tests/frontend/features/features-spoiler-ui.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +function setup(t, html = '') { + const h = createHarness({html}); + t.after(() => h.close()); + h.window.Date.now = () => 100000000; + return h; +} + +test('spoiler disable snooze is isolated per user and expires at exact boundary', t => { + const h = setup(t); let user = 'a'; + h.window.ApiClient.getCurrentUserId = () => user; + h.load('enhanced/spoilerguard/snooze.js'); + const api = h.JE.internals.spoilerGuard; + assert.equal(api.isDisableSnoozed(), false); + api.setDisableSnooze(); assert.equal(api.isDisableSnoozed(), true); + user = 'b'; assert.equal(api.isDisableSnoozed(), false); + user = null; api.setDisableSnooze(); assert.equal(h.window.localStorage.length, 1); + user = 'a'; h.window.Date.now = () => 100900000; + assert.equal(api.isDisableSnoozed(), false); + assert.equal(h.window.localStorage.length, 0); +}); + +for (const value of ['NaN', 'Infinity', '-1', '0', '999999999999']) { + test(`spoiler snooze rejects corrupt or implausible expiry ${value}`, t => { + const h = setup(t); h.load('enhanced/spoilerguard/snooze.js'); + h.window.localStorage.setItem('je-spoiler-disable-snooze:user-a', value); + assert.equal(h.JE.internals.spoilerGuard.isDisableSnoozed(), false); + assert.equal(h.window.localStorage.length, 0); + }); +} + +test('spoiler image refresh preserves URL parameters and replaces prior cache buster', t => { + const h = setup(t); h.load('enhanced/spoilerguard/image-refresh.js'); + const bust = h.JE.internals.spoilerGuard.bustSpoilerImageUrl; + for (const url of ['/Items/aa/Images/Primary?tag=abc&_sbcb=123&quality=90', '/Items/aa/Images/Primary?_sbcb=123&tag=abc&quality=90']) { + const actual = new URL(bust(url, '_sbcb=456'), h.window.location.href); + assert.equal(actual.searchParams.get('tag'), 'abc'); + assert.equal(actual.searchParams.get('quality'), '90'); + assert.deepEqual(actual.searchParams.getAll('_sbcb'), ['456']); + } + assert.equal(bust('/branding/logo.png', '_sbcb=456'), '/branding/logo.png'); +}); + +test('spoiler refresh updates responsive image width and density candidates and backgrounds', t => { + const h = setup(t, ` + + +
    `); + h.load('enhanced/spoilerguard/image-refresh.js'); + h.JE.internals.spoilerGuard.refreshSpoilerableImages(); + for (const selector of ['#density', '#width', 'source']) { + const srcset = h.document.querySelector(selector).getAttribute('srcset'); + for (const candidate of srcset.split(',')) { + const [url, descriptor] = candidate.trim().split(/\s+/); + assert.equal(new URL(url, h.window.location.href).searchParams.get('_sbcb'), '100000000'); + assert.match(descriptor, /^\d+[wx]$/); + } + } + assert.equal(h.document.querySelector('#width').getAttribute('src'), '/branding/logo.png'); + assert.match(h.document.querySelector('#background').getAttribute('style'), /tag=keep&_sbcb=100000000/); +}); diff --git a/tests/frontend/features/features-spoiler.test.mjs b/tests/frontend/features/features-spoiler.test.mjs new file mode 100644 index 000000000..5dea51cb6 --- /dev/null +++ b/tests/frontend/features/features-spoiler.test.mjs @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +function setup(t, plugin = async () => ({})) { + let epoch = 0; + const listeners = new Map(); + const calls = []; + const h = createHarness({ JE: { + pluginConfig: { SpoilerBlurEnabled: true }, + core: { api: { plugin: (...args) => { calls.push(args); return plugin(...args); } } }, + session: { getEpoch: () => epoch, isCurrent: e => e === epoch, onUserChange: (key, fn) => listeners.set(key, fn) }, + }}); + t.after(() => h.close()); + h.load('enhanced/spoilerguard/ids.js'); + h.load('enhanced/spoilerguard/state.js'); + return { ...h, calls, state: h.JE.internals.spoilerGuard, switchUser(userId = 'new-user') { epoch++; for (const fn of listeners.values()) fn({userId}); } }; +} + +for (const [input, expected] of [['AA-BB','aabb'], [null,''], [{},''], [42,'42']]) { + test(`spoiler IDs normalize ${JSON.stringify(input)}`, t => { + const {state} = setup(t); + assert.equal(state.normalizeId(input), expected); + }); +} +test('spoiler state coalesces loading, normalizes IDs, copies preferences and resets on user change', async t => { + const h = setup(t, async () => ({Series:{'AA-BB':{}},Movies:{'CC-DD':{}},Collections:{'EE-FF':{}},PendingTmdb:{'TV:42':{}},Prefs:{hideRatings:true}})); + assert.equal(h.state.loadState(), h.state.loadState()); + await h.state.whenLoaded(); + assert.equal(h.calls.length,1); + assert.equal(h.state.isEnabledFor('AABB'),true); + assert.equal(h.state.isEnabledForKind('movie','CC-DD'),true); + assert.equal(h.state.isEnabledForKind('collection','eeff'),true); + assert.equal(h.state.isTmdbEnabled('tv',42),true); + h.state.getUserPrefs().hideRatings = false; + assert.equal(h.state.getUserPrefs().hideRatings,true); + h.switchUser(); + assert.equal(h.state.hasAnyState(),false); + assert.equal(h.state.isLoaded(),false); + await h.state.whenLoaded(); + assert.equal(h.calls.length,2); +}); +test('spoiler load failure settles fail-closed and disabled feature makes no request', async t => { + const h = setup(t, async () => {throw new Error('offline');}); + h.expectConsoleError(/state load failed/); + await h.state.whenLoaded(); + assert.equal(h.state.isLoaded(),true); + assert.equal(h.state.isLoadOk(),false); + h.switchUser(); h.JE.pluginConfig.SpoilerBlurEnabled=false; + await h.state.whenLoaded(); + assert.equal(h.calls.length,1); +}); +test('old-user spoiler load cannot populate the new session', async t => { + let resolve; + const h = setup(t, () => new Promise(r => {resolve=r;})); + const pending = h.state.loadState(); h.switchUser(); + resolve({Series:{secret:{}}}); await pending; + assert.equal(h.state.hasAnyState(),false); + assert.equal(h.state.isLoaded(),false); +}); +for (const kind of ['Series','Movie','Collection']) { + test(`spoiler ${kind} mutation updates after success and preserves state on rejection`, async t => { + let fail=false; + const h=setup(t,async()=>{if(fail)throw new Error('denied'); return {};}); + await h.state[`enableFor${kind}`]('AA-BB','Title'); + const check=kind==='Series'?'isEnabledFor':`is${kind}EnabledFor`; + assert.equal(h.state[check]('aabb'),true); + assert.equal(h.calls[0][1].skipRetry,true); + fail=true; await assert.rejects(h.state[`disableFor${kind}`]('aabb'),/denied/); + assert.equal(h.state[check]('aabb'),true); + fail=false; await h.state[`disableFor${kind}`]('aabb'); + assert.equal(h.state[check]('aabb'),false); + }); + test(`old-user ${kind} mutation cannot populate the new session`, async t => { + let resolve; + const h=setup(t,()=>new Promise(r=>{resolve=r;})); + const pending=h.state[`enableFor${kind}`]('secret'); + h.switchUser(); resolve({}); await pending; + assert.equal(h.state.hasAnyState(),false); + }); +} +test('TMDB pending promotion, lookup without Jellyfin ID and removal remain consistent',async t=>{ + let response={promoted:'pending'}; + const h=setup(t,async()=>response); + await h.state.enableForTmdb('TV',42,'A & B'); + assert.match(h.calls[0][0],/displayName=A%20%26%20B/); + assert.equal(h.state.isTmdbEnabled('tv',42),true); + response={promoted:'series',jellyfinId:'AA-BB'}; + await h.state.enableForTmdb('tv',42); + assert.equal(h.state.isTmdbEnabled('tv',42),true); + response={removedFrom:'series',jellyfinId:'AA-BB'}; + await h.state.disableForTmdb('tv',42); + assert.equal(h.state.isTmdbEnabled('tv',42),false); + await assert.rejects(h.state.enableForTmdb('person',42),/invalid/); + await assert.rejects(h.state.disableForTmdb('tv',''),/invalid/); +}); +for (const operation of ['enableForTmdb','setUserPrefs']) { + test(`old-user ${operation} response cannot populate the new session`,async t=>{ + let resolve; + const h=setup(t,()=>new Promise(r=>{resolve=r;})); + const pending=operation==='enableForTmdb'?h.state.enableForTmdb('movie',42):h.state.setUserPrefs({secret:true}); + h.switchUser(); resolve({promoted:'movie',jellyfinId:'secret'}); await pending; + assert.equal(h.state.hasAnyState(),false); + assert.equal(Object.keys(h.state.getUserPrefs()).length,0); + }); +} +test('spoiler identity cookie follows login, switch and logout',t=>{ + const h=setup(t); + h.window.ApiClient={getCurrentUserId:()=> 'first'}; + h.load('enhanced/spoilerguard/identity.js'); + assert.match(h.window.document.cookie,/je-spoiler-uid=first/); + h.switchUser('second'); assert.match(h.window.document.cookie,/je-spoiler-uid=second/); + h.switchUser(null); assert.equal(h.window.document.cookie,''); +}); diff --git a/tests/frontend/features/features-subtitles.test.mjs b/tests/frontend/features/features-subtitles.test.mjs new file mode 100644 index 000000000..eb22a508f --- /dev/null +++ b/tests/frontend/features/features-subtitles.test.mjs @@ -0,0 +1,48 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness, plain} from '../helpers/harness.mjs'; +function setup(t,html=''){ + let callback;let subscriptions=0;let releases=0; + const h=createHarness({html,JE:{currentSettings:{},helpers:{onBodyMutation:(id,fn)=>{callback=fn;subscriptions++;return {unsubscribe(){releases++;}};}}}}); + t.after(()=>h.close());h.load('enhanced/player/subtitles.js'); + return {...h, mutate:node=>callback([{addedNodes:[node]}]),counts:()=>({subscriptions,releases})}; +} +for(const [color,expected] of [['transparent',{swatch:'#ffffff',alphaValue:0}],['#123456',{swatch:'#123456',alphaValue:255}],['#12345680',{swatch:'#123456',alphaValue:128}],[null,{swatch:'#ffffff',alphaValue:10}],['bad',{swatch:'#ffffff',alphaValue:10}]]){ + test(`subtitle color decoding ${color}`,t=>{const h=setup(t);assert.deepEqual(plain(h.JE.decodeSubtitleColor(color,{fallbackSwatch:'#ffffff',fallbackAlphaValue:10})),expected);}); +} +test('subtitle effects fall back to Auto for stale selections and honor explicit None',t=>{ + const h=setup(t); + assert.notEqual(h.JE.getSubtitleTextShadow('transparent',999),'none'); + assert.equal(h.JE.getSubtitleTextShadow('#000000ff',999),'none'); + assert.equal(h.JE.getSubtitleTextShadow('transparent',1),'none'); + assert.match(h.JE.getSubtitleTextShadow('transparent',3),/#000/); +}); +test('subtitle styling covers primary/secondary tracks and restores exact native styles when disabled',t=>{ + const h=setup(t,'
    Primary
    Secondary
    '); + const untouched=h.document.getElementById('untouched');untouched.remove(); + const elements=[...h.document.querySelectorAll('.videoSubtitles,.videoSubtitlesInner,.videoSecondarySubtitlesInner')]; + const originals=elements.map(e=>e.getAttribute('style')); + h.JE.applySavedStylesWhenReady(); + assert.equal(elements[0].style.bottom,'5%'); + assert.equal(elements[1].style.marginBottom,'0px'); + assert.equal(elements[2].style.marginBottom,'8px'); + assert.equal(elements[1].style.getPropertyPriority('color'),'important'); + h.JE.applySavedStylesWhenReady(); + assert.deepEqual(h.counts(),{subscriptions:2,releases:1}); + h.document.body.append(untouched); + h.JE.currentSettings.disableCustomSubtitleStyles=true; + h.JE.applySavedStylesWhenReady(); + assert.deepEqual(elements.map(e=>e.getAttribute('style')),originals); + assert.equal(untouched.getAttribute('style'),'font-style: italic;'); + assert.deepEqual(h.counts(),{subscriptions:2,releases:2}); +}); +test('new subtitle nodes inherit chosen style and leaving playback releases observer',t=>{ + const h=setup(t,''); + h.JE.applySubtitleStyles('#ffff00','transparent',1.8,'sans-serif','none'); + const container=h.document.createElement('div');container.className='videoSubtitles';container.innerHTML='
    Late cue
    '; + h.document.body.append(container);h.mutate(container); + assert.equal(container.firstChild.style.fontSize,'1.8vw'); + assert.equal(container.style.left,'50%'); + h.document.querySelector('video').remove();h.JE.applySavedStylesWhenReady(); + assert.deepEqual(h.counts(),{subscriptions:1,releases:1}); +}); diff --git a/tests/frontend/helpers/coverage.mjs b/tests/frontend/helpers/coverage.mjs new file mode 100644 index 000000000..a0bcd050f --- /dev/null +++ b/tests/frontend/helpers/coverage.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process'; +import { readdirSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import coverage from 'istanbul-lib-coverage'; +import { createInstrumenter } from 'istanbul-lib-instrument'; +import { createContext } from 'istanbul-lib-report'; +import reports from 'istanbul-reports'; +import { discoverTests, repositoryRoot } from './discovery.mjs'; +process.chdir(repositoryRoot); +const out = resolve('artifacts/frontend-coverage'); +const raw = join(out, 'raw'); +rmSync(out, { recursive: true, force: true }); +mkdirSync(raw, { recursive: true }); +const files = discoverTests(); +const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', env: { ...process.env, JE_COVERAGE_DIR: raw } }); +const map = coverage.createCoverageMap({}); +// Include every production JS module, including those never loaded by a test. +function inventory(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = join(dir, entry.name); + if (entry.isDirectory()) inventory(file); + else if (file.endsWith('.js')) { + const instrumenter = createInstrumenter(); + instrumenter.instrumentSync(readFileSync(file, 'utf8'), file); + map.addFileCoverage(instrumenter.lastFileCoverage()); + } + } +} +inventory(resolve('Jellyfin.Plugin.JellyfinEnhanced/js')); +for (const file of readdirSync(raw)) map.merge(JSON.parse(readFileSync(join(raw, file), 'utf8'))); +const context = createContext({ dir: out, coverageMap: map }); +for (const format of ['text-summary', 'json-summary', 'lcovonly', 'html']) reports.create(format).execute(context); +// Baseline floors deliberately include all modules: they prevent regression without +// implying that this small percentage is comprehensive behavioral coverage. +const totals = map.getCoverageSummary(); +let failed = false; +for (const [metric, minimum] of Object.entries({ statements: 20, branches: 15, functions: 21, lines: 21 })) { + if (totals[metric].pct < minimum) { console.error(`Global ${metric}: ${totals[metric].pct}% < ${minimum}%`); failed = true; } +} +for (const name of ['api-client', 'dom-observer', 'lifecycle', 'navigation', 'session']) { + const summary = map.fileCoverageFor(resolve(`Jellyfin.Plugin.JellyfinEnhanced/js/core/${name}.js`)).toSummary(); + for (const [metric, minimum] of Object.entries({ statements: 60, branches: 45 })) { + if (summary[metric].pct < minimum) { console.error(`${name} ${metric}: ${summary[metric].pct}% < ${minimum}%`); failed = true; } + } +} +process.exitCode = result.status === null || result.error || result.signal ? 1 : (result.status || (failed ? 1 : 0)); diff --git a/tests/frontend/helpers/discovery.mjs b/tests/frontend/helpers/discovery.mjs new file mode 100644 index 000000000..7132084e0 --- /dev/null +++ b/tests/frontend/helpers/discovery.mjs @@ -0,0 +1,19 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url)); +export const frontendRoot = fileURLToPath(new URL('../', import.meta.url)); + +/** Discover every nested behavioral spec; an empty suite is an error. */ +export function discoverTests(directory = frontendRoot) { + function walk(folder) { + return readdirSync(folder, { withFileTypes: true }).flatMap(entry => { + const path = join(folder, entry.name); + return entry.isDirectory() ? walk(path) : entry.name.endsWith('.test.mjs') ? [path] : []; + }); + } + const files = walk(directory).sort(); + if (!files.length) throw new Error(`No frontend tests discovered under ${directory}`); + return files; +} diff --git a/tests/frontend/helpers/harness.mjs b/tests/frontend/helpers/harness.mjs new file mode 100644 index 000000000..86411dcfa --- /dev/null +++ b/tests/frontend/helpers/harness.mjs @@ -0,0 +1,71 @@ +import { JSDOM, VirtualConsole } from 'jsdom'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createInstrumenter } from 'istanbul-lib-instrument'; +import coverage from 'istanbul-lib-coverage'; +import { repositoryRoot } from './discovery.mjs'; + +export const jsRoot = resolve(repositoryRoot, 'Jellyfin.Plugin.JellyfinEnhanced/js'); +const collected = coverage.createCoverageMap({}); +if (process.env.JE_COVERAGE_DIR) process.on('exit', () => { + mkdirSync(process.env.JE_COVERAGE_DIR, { recursive: true }); + writeFileSync(resolve(process.env.JE_COVERAGE_DIR, `${process.pid}.json`), JSON.stringify(collected)); +}); + +/** Load real classic-script modules into a fresh browser realm; no network is permitted by default. */ +export function createHarness({ html = '', url = 'http://jellyfin.test/web/index.html#!/home', JE = {}, apiClient = {}, fetch, globals = {}, expectedConsoleErrors = [] } = {}) { + const errors = []; + const allowedErrors = [...expectedConsoleErrors]; + const virtualConsole = new VirtualConsole(); + virtualConsole.on('error', (...args) => { + const message = args.map(String).join(' '); + if (!allowedErrors.some(pattern => typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message))) { + errors.push(new Error(`Unexpected console.error: ${message}`)); + } + }); + virtualConsole.on('jsdomError', error => errors.push(error)); + const dom = new JSDOM(`${html}`, { + url, runScripts: 'outside-only', pretendToBeVisual: true, virtualConsole + }); + const { window } = dom; + // Modules loaded by the host bundle run after the document is ready. + Object.defineProperty(window.document, 'readyState', { configurable: true, value: 'complete' }); + window.JellyfinEnhanced = JE; + window.ApiClient = { + getCurrentUserId: () => 'user-a', accessToken: () => 'test-token', serverId: () => 'server-a', + getUrl: path => `http://jellyfin.test${path.startsWith('/') ? '' : '/'}${path}`, ...apiClient + }; + window.fetch = fetch || ((url) => { + const error = new Error(`Unexpected network request: ${url}; provide a fetch fixture`); + errors.push(error); + throw error; + }); + window.structuredClone = structuredClone; + Object.assign(window, globals); + window.addEventListener('error', event => errors.push(event.error || new Error(event.message))); + window.addEventListener('unhandledrejection', event => errors.push(event.reason)); + return { + window, document: window.document, JE, errors, + expectConsoleError(pattern) { allowedErrors.push(pattern); }, + load(path) { + const file = resolve(jsRoot, path); + let source = readFileSync(file, 'utf8'); + if (process.env.JE_COVERAGE_DIR) source = createInstrumenter().instrumentSync(source, file); + window.eval(`${source}\n//# sourceURL=${file}`); + return JE; + }, + close() { + if (window.__coverage__) collected.merge(window.__coverage__); + window.close(); + if (errors.length) throw new AggregateError(errors, 'Unexpected browser errors'); + } + }; +} + +export function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} +export const jsonResponse = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +export const plain = value => JSON.parse(JSON.stringify(value)); diff --git a/tests/frontend/pages/pages-activity.test.mjs b/tests/frontend/pages/pages-activity.test.mjs new file mode 100644 index 000000000..b9f021fa3 --- /dev/null +++ b/tests/frontend/pages/pages-activity.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setImmediate as nextTurn} from 'node:timers/promises'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,{config={},api=async()=>({items:[]})}={}){ + const calls=[];const timers=new Map();let timerId=0; + const h=createHarness({html:'
    ',JE:{pluginConfig:{ActivityFeedEnabled:true,ActivityFeedShowWatched:true,ActivityFeedShowReviewed:true,...config},t:()=>'',core:{api:{plugin:async path=>{calls.push(path);return api(path);}}}},apiClient:{getImageUrl:(id,options)=>`http://jellyfin.test/Images/${id}/${options.type}`}}); + h.window.setInterval=fn=>{timers.set(++timerId,fn);return timerId;};h.window.clearInterval=id=>timers.delete(id); + t.after(()=>h.close());h.load('extras/activity-page.js'); + return {...h,calls,timers,host:h.document.querySelector('main'),P:h.JE.activityPage}; +} +const entry=(type,name,timestamp=0)=>({ActivityType:type,UserId:'u',UserName:'',Timestamp:timestamp,Item:{Id:name,Name:name},Content:'',Rating:3.5}); + +test('activity disabled module registers nothing and makes no requests',t=>{ + const h=setup(t,{config:{ActivityFeedEnabled:false}});assert.equal(h.P,undefined);assert.equal(h.calls.length,0); +}); +test('activity renders separated watch and chronological review/favorite sections with escaped data',async t=>{ + const h=setup(t,{api:async()=>({items:[{...entry('Watched','Partial'),Progress:0.42},{...entry('Watched','Complete'),Completed:true},entry('Reviewed','Older',100),entry('Favorited','Newer',200),entry('Ignored','Ignore')]})}); + await h.P.renderForCustomTab(h.host); + assert.deepEqual([...h.document.querySelectorAll('.je-activity-item-name')].map(n=>n.textContent),['Partial','Complete','Newer','Older']); + assert.equal(h.document.querySelector('script'),null);assert.equal(h.document.querySelector('.je-activity-user').textContent,''); + assert.equal(h.document.querySelectorAll('.je-activity-progress-fill').length,1); + assert.equal(h.document.querySelector('.je-activity-progress-fill').style.width,'42%'); + h.document.querySelector('.je-activity-item-name').click();assert.equal(h.window.location.hash,'#!/details?id=Partial'); +}); +test('activity empty and failed responses show explicit state and refresh recovers',async t=>{ + let failing=true;const h=setup(t,{api:async()=>{if(failing)throw Error('offline');return {items:[]};}}); + await h.P.renderForCustomTab(h.host);assert.equal(h.document.querySelectorAll('.je-activity-error').length,2); + failing=false;h.document.querySelector('.je-activity-refresh-btn').click();await nextTurn(); + assert.equal(h.document.querySelectorAll('.je-activity-error').length,0);assert.equal(h.document.querySelectorAll('.je-activity-empty').length,2); + assert.equal(h.document.querySelector('.je-activity-refresh-btn').disabled,false); +}); +test('activity refresh coalesces repeated clicks while response is pending',async t=>{ + const pending=deferred();let loads=0;const h=setup(t,{api:async()=>{loads++;return loads===1?{items:[]}:pending.promise;}}); + await h.P.renderForCustomTab(h.host);const button=h.document.querySelector('.je-activity-refresh-btn');button.click();button.click(); + assert.equal(loads,2);assert.equal(button.disabled,true);pending.resolve({items:[]});await nextTurn();assert.equal(button.disabled,false); +}); +test('activity active stream polling ignores idle sessions and cleans up when remounted or detached',async t=>{ + const h=setup(t,{config:{ActiveStreamsEnabled:true,ActivityFeedShowActiveStreams:true,ActivityFeedShowWatched:false,ActivityFeedShowReviewed:false},api:async()=>[{UserName:'Idle'},{UserId:'u',UserName:'Viewer',PlayState:{IsPaused:true,PositionTicks:200},NowPlayingItem:{Id:'movie',Name:'Movie',RunTimeTicks:100}}]}); + await h.P.renderForCustomTab(h.host);assert.equal(h.document.querySelectorAll('.je-activity-row').length,1);assert.match(h.host.textContent,/paused watching/);assert.equal(h.document.querySelector('.je-activity-progress-fill').style.width,'100%'); + assert.equal(h.timers.size,1);await h.P.renderForCustomTab(h.host);assert.equal(h.timers.size,1); + h.host.remove();[...h.timers.values()][0]();assert.equal(h.timers.size,0); +}); +test('activity stopped pending initial load cannot restart its poller',async t=>{ + const pending=deferred();const h=setup(t,{config:{ActiveStreamsEnabled:true,ActivityFeedShowActiveStreams:true,ActivityFeedShowWatched:false,ActivityFeedShowReviewed:false},api:()=>pending.promise}); + const render=h.P.renderForCustomTab(h.host);h.P.stopPolling();pending.resolve([]);await render;assert.equal(h.timers.size,0); +}); +test('activity unauthorized active streams removes section and never polls',async t=>{ + const h=setup(t,{config:{ActiveStreamsEnabled:true,ActivityFeedShowActiveStreams:true,ActivityFeedShowWatched:false,ActivityFeedShowReviewed:false},api:async()=>{throw {status:403};}}); + await h.P.renderForCustomTab(h.host);assert.equal(h.document.querySelector('.je-activity-section'),null);assert.equal(h.timers.size,0); +}); diff --git a/tests/frontend/pages/pages-awards.test.mjs b/tests/frontend/pages/pages-awards.test.mjs new file mode 100644 index 000000000..7a8696955 --- /dev/null +++ b/tests/frontend/pages/pages-awards.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setImmediate as nextTurn} from 'node:timers/promises'; +import {createHarness} from '../helpers/harness.mjs'; +async function setup(t,{item={Type:'Movie',ProviderIds:{Tmdb:7}},data={Found:true,Wins:1,Nominations:1,Awards:[{Name:'Academy Award for ',Result:'Won',Year:2025,Recipients:['']},{Name:'Nomination',Result:'Nominated',Year:2024}]},enabled=true,api,parent={Type:'Series',ProviderIds:{Tmdb:9}},html='
    '}={}){ + let hook;const calls=[]; + const h=createHarness({html:`
    ${html}
    `,url:'http://jellyfin.test/web/index.html#!/details?id=item',apiClient:{getItem:async(_user,id)=>id==='series'?parent:item},JE:{pluginConfig:{ShowAwards:enabled},escapeHtml:value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),cdn:{url:(_source,path)=>`https://cdn.test/${path}`},t:(key,args)=>args?`${key} ${args.count??args.work??''}`:key,helpers:{onViewPage:fn=>{hook=fn;return()=>{};}},core:{api:{plugin:async path=>{calls.push(path);return api?api():data;}}}}}); + t.after(()=>h.close());h.window.setTimeout=fn=>{queueMicrotask(fn);return 1;};h.load('awards/awards.js');h.JE.initializeAwardsScript(); + const navigate=async()=>{if(hook){await hook(null,h.document.querySelector('#itemDetailPage'),h.window.location.hash);await nextTurn();}}; + await navigate();return {...h,calls,navigate}; +} +test('awards normalize nested server fields, escape upstream strings and replace rather than duplicate banners',async t=>{ + const h=await setup(t);const banner=h.document.querySelector('.je-awards-section');assert.ok(banner);assert.match(banner.textContent,/Academy Award for "}; + let data = {events:[null,{}, {id:'ok',releaseDate:'2026-02-01'}],errors:[error]}; + const h = setup(t,{core:{api:{plugin:async()=>data}}}); + const start = new Date('2026-02-01'); const end = new Date('2026-02-28'); + await h.P.fetchCalendarEvents(start,end); await h.P.fetchCalendarEvents(start,end); + assert.equal(h.P.state.events.length,1); assert.equal(h.notices.length,1); + assert.ok(!h.notices[0].includes('',source:'Shoko',instanceName:'anime',alsoInInstances:['Other'],type:'Series',releaseType:'Anime',releaseDate:'2026-02-01',shokoSeriesId:42,itemId:'safe-id'}; + h.document.body.innerHTML=h.P.renderCardItems([event]); + assert.equal(h.document.querySelector('script'),null);assert.equal(h.document.querySelector('[onerror]'),null); + assert.equal(h.document.querySelector('.je-calendar-card').getAttribute('data-event-id'),event.id); + assert.equal(h.document.querySelector('.je-calendar-card-title-text').textContent,event.title); + assert.equal(h.document.querySelector('.je-calendar-shoko-link').href,'https://public.test/shoko/webui/collection/series/42'); + assert.match(h.document.body.innerHTML,/Items\/safe-id\/Images\/Primary/); + assert.equal(h.document.querySelector('.je-arr-badge').textContent,'anime, Other'); +}); + +function actions(t){ + const h=rendering(t);const saved=[];h.JE.saveUserSettings=(file,settings)=>saved.push({file,settings:{...settings}}); + h.P.renderPage=()=>{};h.P.syncPageModeClasses=()=>{};h.P.updateDisplayModeButtons=()=>{};h.P.toggleSidebarCollapsed=()=>{}; + h.load('arr/calendar/calendar-page-actions.js');return {...h,saved}; +} +test('calendar view and display controls persist user preferences without redundant saves',async t=>{ + const h=actions(t);h.P.setDisplayMode('grid');h.P.setDisplayMode('grid');assert.equal(h.saved.length,1);assert.equal(h.saved[0].settings.calendarDisplayMode,'grid'); + h.P.setViewMode('week');h.P.setViewMode('week');await new Promise(resolve=>setImmediate(resolve)); + assert.equal(h.saved.length,2);assert.equal(h.saved[1].settings.calendarDefaultViewMode,'week');assert.equal(h.P.state.isLoading,false); + h.P.toggleShowUnmonitored();assert.equal(h.window.localStorage.getItem('je.calendar.showUnmonitored:user-0'),'true'); +}); +test('calendar month navigation clamps anchor before shifting from month end',async t=>{ + const h=actions(t);h.P.state.viewMode='month';h.P.state.currentDate=new Date(2024,0,31); + h.P.shiftPeriod('next');await new Promise(resolve=>setImmediate(resolve));assert.equal(h.P.state.currentDate.getMonth(),1);assert.equal(h.P.state.currentDate.getDate(),1); + h.P.shiftPeriod('previous');await new Promise(resolve=>setImmediate(resolve));assert.equal(h.P.state.currentDate.getMonth(),0); +}); +test('calendar card navigates to series while play button targets episode and unavailable movie stays put',t=>{ + const h=actions(t);h.P.state.events=[{id:'episode',type:'Series',hasFile:true,itemId:'series-id',itemEpisodeId:'episode-id'},{id:'movie',type:'Movie',hasFile:false,itemId:'movie-id'}]; + h.document.body.innerHTML='
    '; + h.document.body.addEventListener('click',h.P.handleEventClick); + h.document.querySelector('.je-calendar-card').click();assert.equal(h.window.location.hash,'#/details?id=series-id'); + h.document.querySelector('button').click();assert.equal(h.window.location.hash,'#/details?id=episode-id'); + h.document.querySelector('[data-event-id="movie"]').click();assert.equal(h.window.location.hash,'#/details?id=episode-id'); +}); diff --git a/tests/frontend/pages/pages-downloads.test.mjs b/tests/frontend/pages/pages-downloads.test.mjs new file mode 100644 index 000000000..64de64269 --- /dev/null +++ b/tests/frontend/pages/pages-downloads.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred,plain} from '../helpers/harness.mjs'; +function setup(t,{api=async()=>({}),ajax=async()=>({results:[]}),config={}}={}){ + let epoch=0,changed;const notices=[],calls=[]; + const h=createHarness({JE:{pluginConfig:{JellyseerrEnabled:true,DownloadsPageShowIssues:true,DownloadsShowHistory:true,...config},helpers:{},t:key=>key,toast:message=>notices.push(message),session:{getEpoch:()=>epoch,isCurrent:value=>value===epoch,onUserChange:(_key,fn)=>{changed=fn;}},core:{api:{plugin:async(...args)=>{calls.push(args);return api(...args);}}}},apiClient:{ajax,getUrl:(path,params)=>'http://jellyfin.test'+path+(params?'?'+new URLSearchParams(params):'')}}); + t.after(()=>h.close());h.load('arr/requests/requests-page-data.js');const P=h.JE.internals.requestsPage;const rendered=[];P.renderPage=()=>rendered.push(P.state.isLoading); + return {...h,P,notices,calls,rendered,switchUser:()=>{epoch++;changed();}}; +} +test('downloads page loads queue, paginated requests/history and issues while exposing loading lifecycle',async t=>{ + const h=setup(t,{api:async path=>path==='/arr/queue'?{items:[{id:'download'}]}:path.startsWith('/arr/requests')?{requests:[{id:'request'}],totalPages:4,canApproveRequests:true}:{items:[{id:'history'}],visible:true,totalPages:3}}); + h.P.state.requestsPage=2;h.P.state.requestsFilter='pending';h.P.state.historyPage=3; + await h.P.loadAllData();assert.deepEqual(h.rendered,[true,false]);assert.equal(h.P.state.downloads[0].id,'download');assert.equal(h.P.state.requests[0].id,'request');assert.equal(h.P.state.history[0].id,'history');assert.equal(h.P.state.canApproveRequests,true); + const request=new URL(h.calls.find(([p])=>p.startsWith('/arr/requests'))[0],'http://test');assert.equal(request.searchParams.get('skip'),'20');assert.equal(request.searchParams.get('filter'),'pending'); + const history=new URL(h.calls.find(([p])=>p.startsWith('/arr/history'))[0],'http://test');assert.equal(history.searchParams.get('skip'),'40'); +}); +test('downloads pending queue, request and history responses cannot restore previous account data or permissions',async t=>{ + const pending=deferred();const h=setup(t,{api:()=>pending.promise,config:{DownloadsPageShowIssues:false}}); + const run=h.P.loadAllData();h.switchUser();pending.resolve({items:[{id:'old'}],requests:[{id:'old'}],canApproveRequests:true});await run; + assert.equal(h.P.state.downloads.length,0);assert.equal(h.P.state.requests.length,0);assert.equal(h.P.state.history.length,0);assert.equal(h.P.state.canApproveRequests,false); +}); +test('downloads issue permission denial is sticky for current user and resets on account switch',async t=>{ + let count=0;const h=setup(t,{ajax:async()=>{count++;throw {status:403};}});h.expectConsoleError('Requests Page: Failed to fetch issues:'); + await h.P.fetchIssues();await h.P.fetchIssues();assert.equal(count,1);assert.equal(h.notices.length,1);assert.equal(h.P.state.issuesPermissionDenied,true); + h.switchUser();await h.P.fetchIssues();assert.equal(count,2);assert.equal(h.notices.length,2); +}); +test('downloads stale issue denial does not deny the new account',async t=>{ + const pending=deferred();const h=setup(t,{ajax:()=>pending.promise});h.expectConsoleError('Requests Page: Failed to fetch issues:'); + const run=h.P.fetchIssues();h.switchUser();pending.reject({status:403});await run;assert.equal(h.P.state.issuesPermissionDenied,false);assert.equal(h.notices.length,0); +}); +test('downloads issues hydrate alternate metadata shapes and reuse cache only within account',async t=>{ + const calls=[];const h=setup(t,{ajax:async options=>{calls.push(options);return options.url.includes('/issue')?{results:[{id:1,media:{mediaType:'tv',tmdbId:42}}],pageInfo:{pages:2}}:{id:42,name:'Series',poster_path:'/poster.jpg',first_air_date:'2020-01-01'};}}); + await h.P.fetchIssues();await h.P.fetchIssues();assert.equal(calls.length,3); + assert.equal(h.P.state.issues[0].media.title,'Series');assert.equal(h.P.state.issues[0].media.posterPath,'/poster.jpg');assert.equal(h.P.state.issuesTotalPages,2); + assert.equal(calls[0].headers['X-Jellyfin-User-Id'],'user-a'); + h.switchUser();await h.P.fetchIssues();assert.equal(calls.length,5); +}); +test('downloads disabled optional sources clear stale state without network',async t=>{ + const h=setup(t,{config:{DownloadsShowHistory:false,DownloadsPageShowIssues:false}});h.P.state.history=[{}];h.P.state.issues=[{}];await h.P.fetchHistory();await h.P.fetchIssues(); + assert.equal(h.P.state.history.length,0);assert.equal(h.P.state.historyVisible,false);assert.equal(h.P.state.issues.length,0);assert.equal(h.calls.length,0); +}); +test('downloads approval uses a single non-retried mutation then refreshes permission and data',async t=>{ + const h=setup(t,{api:async()=>({requests:[],canApproveRequests:false})});h.document.body.innerHTML=''; + await h.P.handleRequestAction(h.document.querySelector('button'),'approve');assert.equal(h.calls[0][0],'/arr/requests/17/approve');assert.deepEqual(plain(h.calls[0][1]),{method:'POST',skipRetry:true});assert.equal(h.calls.length,2);assert.equal(h.P.state.canApproveRequests,false); +}); diff --git a/tests/frontend/pages/pages-moreinfo.test.mjs b/tests/frontend/pages/pages-moreinfo.test.mjs new file mode 100644 index 000000000..bccccb61a --- /dev/null +++ b/tests/frontend/pages/pages-moreinfo.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setImmediate as nextTurn} from 'node:timers/promises'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t,{details=async id=>({id}),ratings=async()=>({}),withSession=false}={}){ + const timers=[];const actions=[],errors=[];const state={currentModal:null}; + const internal={state,fetchMediaDetails:details,fetchRatings:ratings,showError:message=>errors.push(message),buildModalContent:()=>'
    ',renderActions:data=>actions.push(data.id),enrichSeasonCardsWithJellyfinLinks:()=>{},backfillSeasonMetadata:()=>{},buildRatingLogos:value=>String(value.label||'')}; + const h=createHarness({JE:{internals:{moreInfoModal:internal}}});t.after(()=>h.close()); + h.window.setTimeout=(fn,ms)=>{timers.push({fn,ms});return timers.length;}; + if(withSession){h.JE.core={navigation:{onNavigate:()=>{}}};h.load('core/session.js');} + h.load('jellyseerr/moreinfo/more-info-modal-init.js'); + return {...h,state,actions,errors,internal,P:h.JE.jellyseerrMoreInfo,runClosing:()=>{for(const timer of timers.splice(0))timer.fn();}}; +} +test('more-info replacing a modal cannot let old close timeout remove its successor',async t=>{ + const h=setup(t);await h.P.open(1,'movie');await h.P.open(2,'movie');h.runClosing(); + assert.equal(h.document.querySelectorAll('.je-more-info-modal').length,1);assert.equal(h.state.currentModal?.dataset.tmdbId,'2'); +}); +test('more-info late ratings do not replace ratings of a newer item',async t=>{ + const old=deferred();const h=setup(t,{ratings:id=>id===1?old.promise:Promise.resolve({label:'new'})}); + await h.P.open(1,'movie');await h.P.open(2,'movie');old.resolve({label:'old'});await nextTurn(); + assert.equal(h.state.currentModal.querySelector('[data-mount="ratings"]').textContent,'new'); +}); +test('more-info close removes TV request listeners and navigation dismisses modal',async t=>{ + const h=setup(t);await h.P.open(1,'tv');const count=h.actions.length; + h.document.dispatchEvent(new h.window.Event('viewshow'));h.runClosing(); + h.document.dispatchEvent(new h.window.CustomEvent('jellyseerr-tv-requested',{detail:{tmdbId:1}}));await nextTurn(); + assert.equal(h.state.currentModal,null);assert.equal(h.actions.length,count); +}); +test('more-info failed data returns a visible error without mounting modal',async t=>{ + const h=setup(t,{details:async()=>null});await h.P.open(1,'movie');assert.equal(h.errors.length,1);assert.equal(h.state.currentModal,null); +}); +test('more-info late initial details cannot replace a newer user selection',async t=>{ + const old=deferred();const h=setup(t,{details:id=>id===1?old.promise:Promise.resolve({id})}); + const first=h.P.open(1,'movie');await h.P.open(2,'movie');old.resolve({id:1});await first; + assert.equal(h.state.currentModal.dataset.tmdbId,'2'); +}); +test('more-info closing while details are pending cancels the pending open',async t=>{ + const pending=deferred();const h=setup(t,{details:()=>pending.promise});const opening=h.P.open(1,'movie');h.P.close();pending.resolve({id:1});await opening; + assert.equal(h.state.currentModal,null); +}); +test('more-info stale refresh cannot update replacement modal actions',async t=>{ + const pending=deferred();let oldCalls=0;const h=setup(t,{details:id=>id===1&&oldCalls++>0?pending.promise:Promise.resolve({id})}); + await h.P.open(1,'movie');h.state.currentModal.querySelector('.modal-refresh').click();await h.P.open(2,'movie');pending.resolve({id:1});await nextTurn(); + assert.deepEqual(h.actions,[1,2]); +}); +test('more-info stale TV request completion cannot render into replacement',async t=>{ + const pending=deferred();let firstCalls=0;const h=setup(t,{details:id=>id===1&&firstCalls++>0?pending.promise:Promise.resolve({id})}); + await h.P.open(1,'tv');h.document.dispatchEvent(new h.window.CustomEvent('jellyseerr-tv-requested',{detail:{tmdbId:1}}));await h.P.open(2,'movie'); + pending.resolve({id:1,mediaInfo:{status:2}});await nextTurn();assert.deepEqual(h.actions,[1,2]); +}); +test('more-info ratings from an earlier opening of the same item cannot overwrite fresh ratings',async t=>{ + const pending=deferred();let calls=0;const h=setup(t,{ratings:()=>++calls===1?pending.promise:Promise.resolve({label:'fresh'})}); + await h.P.open(1,'movie');await h.P.open(1,'movie');pending.resolve({label:'stale'});await nextTurn(); + assert.equal(h.state.currentModal.querySelector('[data-mount="ratings"]').textContent,'fresh'); +}); +test('more-info navigation cancels pending open and suppresses stale fetch errors',async t=>{ + const pending=deferred();const h=setup(t,{details:()=>pending.promise});const opening=h.P.open(1,'movie');h.document.dispatchEvent(new h.window.Event('viewshow'));pending.reject(Error('offline'));await opening; + assert.equal(h.state.currentModal,null);assert.deepEqual(h.errors,[]); +}); +test('more-info actual session transition cancels prior-user pending open without navigation',async t=>{ + const pending=deferred();const h=setup(t,{withSession:true,details:()=>pending.promise});const opening=h.P.open(1,'movie'); + h.window.ApiClient.getCurrentUserId=()=> 'user-b';h.JE.session.checkNow('regression-user-switch');pending.resolve({id:1});await opening; + assert.equal(h.state.currentModal,null);assert.equal(h.document.querySelector('.je-more-info-modal'),null); +}); +test('more-info actual session transition closes existing modal and invalidates pending ratings',async t=>{ + const pending=deferred();const h=setup(t,{withSession:true,ratings:()=>pending.promise});await h.P.open(1,'movie');const previous=h.state.currentModal; + h.window.ApiClient.getCurrentUserId=()=> 'user-b';h.JE.session.checkNow('regression-user-switch');pending.resolve({label:'old user'});await nextTurn(); + assert.equal(previous._isClosing,true);assert.equal(previous.querySelector('[data-mount="ratings"]').textContent,'');h.runClosing();assert.equal(h.state.currentModal,null); +}); +test('more-info same-ID replacement rejects stale refresh by modal identity',async t=>{ + const pending=deferred();let calls=0;const h=setup(t,{details:id=>++calls===2?pending.promise:Promise.resolve({id})}); + await h.P.open(1,'movie');h.state.currentModal.querySelector('.modal-refresh').click();await h.P.open(1,'movie');pending.resolve({id:1});await nextTurn(); + assert.deepEqual(h.actions,[1,1]); +}); diff --git a/tests/frontend/pages/pages-recommendations.test.mjs b/tests/frontend/pages/pages-recommendations.test.mjs new file mode 100644 index 000000000..5da757a03 --- /dev/null +++ b/tests/frontend/pages/pages-recommendations.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness, deferred, plain} from '../helpers/harness.mjs'; +function setup(t, fetch, genre = async()=>[]) { + const h=createHarness({JE:{discoveryFilter:{fetchWithManagedRequest:fetch},jellyseerrAPI:{fetchGenreSlider:genre}}}); + t.after(()=>h.close()); h.load('jellyseerr/recommendations/recommendations-data.js'); + return {...h,P:h.JE.internals.recommendationsPage}; +} +test('recommendations preserve successful categories when neighboring categories fail or are empty',async t=>{ + const calls=[]; const h=setup(t,async(path,scope,opts)=>{calls.push({path,scope,opts}); if(path.startsWith('/bad')) throw Error('offline'); return path.startsWith('/good')?{results:[{id:1}]}:{};}); + h.P.ROWS=[{path:'/good'},{path:'/bad'},{path:'/empty'}]; const controller=new AbortController(); + const rows=await h.P.fetchAllRows(controller.signal); + assert.deepEqual(plain(rows.map(x=>x.results)),[[{id:1}],[],[]]); + assert.equal(calls.length,3); for(const call of calls){assert.equal(call.scope,'recommendations');assert.equal(call.opts.signal,controller.signal);assert.ok(call.path.endsWith('?page=1'));} +}); +test('recommendations coalesce concurrent logo fetches and separate studio/network identities',async t=>{ + const calls=[]; const pending=deferred(); const h=setup(t,path=>{calls.push(path);return pending.promise;}); + const a=h.P.fetchLogoPath('studio',10); const b=h.P.fetchLogoPath('studio',10); const c=h.P.fetchLogoPath('network',10); + assert.deepEqual(calls,['/JellyfinEnhanced/tmdb/company/10','/JellyfinEnhanced/tmdb/network/10']); + pending.resolve({logo_path:'/logo.png'}); assert.deepEqual(await Promise.all([a,b,c]),['/logo.png','/logo.png','/logo.png']); + assert.equal(await h.P.fetchLogoPath('studio',10),'/logo.png');assert.equal(calls.length,2); +}); +test('recommendations missing or failed logos fall back to text without rejecting',async t=>{ + const h=setup(t,async path=>{if(path.endsWith('/1')) throw Error('unavailable');return {};}); + assert.equal(await h.P.fetchLogoPath('studio',1),null);assert.equal(await h.P.fetchLogoPath('studio',2),null); +}); +test('recommendation genre caches separate media types and gracefully handle failures',async t=>{ + const calls=[];const h=setup(t,async()=>({}),async type=>{calls.push(type);if(type==='tv')throw Error('offline');return [{id:1,name:'Comedy',backdrops:[]}];}); + assert.deepEqual(plain(await h.P.fetchGenreSlider('movie')),[{id:1,name:'Comedy',backdrops:[]}]); + assert.deepEqual(plain(await h.P.fetchGenreSlider('tv')),[]);await h.P.fetchGenreSlider('movie');await h.P.fetchGenreSlider('tv'); + assert.deepEqual(calls,['movie','tv']); +}); +test('recommendations standalone navigation preserves host lifecycle, prevents duplicate mounts and aborts on leave',t=>{ + const h=setup(t,async()=>({}));h.JE.pluginConfig={RecommendationsPageEnabled:true};h.JE.t=key=>key; + h.document.body.innerHTML='
    '; + const home=h.document.querySelector('#home');const events=[];home.addEventListener('viewhide',()=>events.push('hide'));home.addEventListener('viewshow',()=>events.push('show')); + let renders=0;h.P.renderInto=container=>{renders++;container.textContent='Recommendations';};h.P.hideCategoryPage=()=>{}; + h.load('jellyseerr/recommendations/recommendations-page.js'); + h.P.showPage();h.P.showPage();assert.equal(renders,1);assert.equal(home.classList.contains('hide'),true); + assert.equal(h.window.location.hash,'#/recommendations');assert.equal(h.document.querySelectorAll('#je-recommendations-page').length,1); + const controller=new AbortController();h.P.state.currentAbortController=controller;h.P.hidePage(); + assert.equal(controller.signal.aborted,true);assert.equal(home.classList.contains('hide'),false);assert.deepEqual(events,['hide','show']); + assert.equal(h.P.state.pageVisible,false); +}); +test('recommendations disabled standalone page leaves current host untouched',t=>{ + const h=setup(t,async()=>({}));h.JE.pluginConfig={RecommendationsPageEnabled:false};h.JE.t=key=>key;h.P.renderInto=()=>{throw Error('must not render');}; + h.load('jellyseerr/recommendations/recommendations-page.js');h.P.showPage();assert.equal(h.document.querySelector('#je-recommendations-page'),null); +}); diff --git a/tests/frontend/pages/pages-reviews.test.mjs b/tests/frontend/pages/pages-reviews.test.mjs new file mode 100644 index 000000000..7c341a437 --- /dev/null +++ b/tests/frontend/pages/pages-reviews.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setImmediate as nextTurn} from 'node:timers/promises'; +import {createHarness} from '../helpers/harness.mjs'; + +async function setup(t,{item={Id:'item',Type:'Movie',ProviderIds:{Tmdb:'7'}},config={},spoilerBlur={},reviews=[],tmdb=[],admin=false,parent={Type:'Series',ProviderIds:{Tmdb:'9'}}}={}) { + let hook;const calls=[]; + const h=createHarness({html:'
    ',url:'http://jellyfin.test/web/index.html#!/details?id=item',apiClient:{getItem:async(_user,id)=>id==='series'?parent:item,getCurrentUser:async()=>({Id:'user-a',Policy:{IsAdministrator:admin}})},JE:{ + pluginConfig:{ShowReviews:true,TmdbEnabled:true,ShowUserReviews:true,...config},spoilerBlur, + escapeHtml:value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])), + t:key=>key,icon:()=>'star',IconName:{STAR:'star'}, + helpers:{onViewPage:fn=>{hook=fn;return ()=>{};}}, + core:{api:{fetch:async url=>{calls.push(url);return {results:tmdb};},plugin:async url=>{calls.push(url);return {reviews};}}} + }}); + t.after(()=>h.close()); + // Advance the page-visibility delay without sleeping; promises still resolve asynchronously. + h.window.setTimeout=fn=>{queueMicrotask(fn);return 1;}; + h.load('elsewhere/reviews.js');h.JE.initializeReviewsScript(); + async function navigate(){if(hook){await hook(null,h.document.querySelector('#itemDetailPage'),h.window.location.hash);await nextTurn();}} + await navigate(); return {...h,calls,navigate}; +} + +for(const Type of ['Movie','Series','Season','Episode']) { + test(`reviews suppress guarded ${Type} pages before fetching content`,async t=>{ + const h=await setup(t,{item:{Id:'item',Type,SeriesId:'series',ProviderIds:{Tmdb:'7'}},config:{SpoilerBlurEnabled:true},spoilerBlur:{whenLoaded:async()=>{},isLoadOk:()=>true,isEnabledFor:()=>true,isMovieEnabledFor:()=>true}}); + assert.equal(h.calls.length,0);assert.equal(h.document.querySelector('.tmdb-reviews-section'),null); + }); +} +test('reviews fail closed when spoiler state fails to load',async t=>{ + const h=await setup(t,{config:{SpoilerBlurEnabled:true},spoilerBlur:{whenLoaded:async()=>{},isLoadOk:()=>false}}); + assert.equal(h.calls.length,0);assert.equal(h.document.querySelector('.tmdb-reviews-section'),null); +}); +test('reviews honor user opt-out of hiding reviews and render an empty writeable section once',async t=>{ + const h=await setup(t,{config:{SpoilerBlurEnabled:true},spoilerBlur:{getUserPrefs:()=>({HideReviews:false}),isMovieEnabledFor:()=>true}}); + assert.equal(h.calls.length,2);assert.ok(h.document.querySelector('.tmdb-reviews-section'));await h.navigate();assert.equal(h.calls.length,2); +}); +test('episode reviews use series/season/episode key without fetching top-level TMDB reviews',async t=>{ + const h=await setup(t,{item:{Id:'episode',Type:'Episode',SeriesId:'series',ProviderIds:{Tmdb:'episode-tmdb-id'},ParentIndexNumber:0,IndexNumber:2}}); + assert.deepEqual(h.calls,['/reviews/tv/9:s0:e2']);assert.ok(h.document.querySelector('.tmdb-reviews-section')); +}); +test('review cards escape user content and regular viewers cannot moderate other users',async t=>{ + const h=await setup(t,{reviews:[{userId:'other',userName:'',content:' **Bold** [unsafe](javascript:alert(1))',rating:4}],tmdb:[{author:'Author',content:'Hello',author_details:{rating:8}}]}); + assert.equal(h.document.querySelectorAll('.tmdb-review-card').length,2); + assert.equal(h.document.querySelector('.tmdb-review-author').textContent,''); + assert.equal(h.document.querySelector('.tmdb-reviews-section script'),null); + assert.equal(h.document.querySelector('a[href^="javascript:"]'),null); + assert.equal(h.document.querySelector('.je-review-admin-delete-btn'),null); + assert.match(h.document.querySelector('.je-avg-user-rating-chip').textContent,/8/); +}); +test('admin viewers see moderation controls and missing translations use readable fallback',async t=>{ + const h=await setup(t,{admin:true,reviews:[{userId:'other',userName:'Other',content:'Review',rating:3}]}); + assert.equal(h.document.querySelector('.je-review-admin-delete-btn').title,'Delete as admin'); +}); +test('review form rejects empty input, counts characters, saves trimmed content and preserves input on failure',async t=>{ + const h=await setup(t);const mutations=[]; + h.JE.core.api.plugin=async(path,options)=>{mutations.push({path,options});throw Error('offline');}; + h.document.querySelector('.je-review-write-btn').click(); + const textarea=h.document.querySelector('textarea');const submit=h.document.querySelector('.je-review-submit-btn'); + assert.equal(h.document.activeElement,textarea);assert.equal(textarea.maxLength,2000); + submit.click();assert.equal(mutations.length,0);assert.equal(h.document.querySelector('.je-review-form-error').textContent,'reviews_form_error_empty'); + textarea.value=' Test review ';textarea.dispatchEvent(new h.window.Event('input')); + assert.equal(h.document.querySelector('.je-review-char-count').textContent,'15'); + submit.click();await nextTurn(); + assert.equal(mutations.length,1);assert.equal(mutations[0].path,'/reviews/movie/7'); + assert.equal(mutations[0].options.method,'POST');assert.equal(mutations[0].options.skipRetry,true); + assert.equal(mutations[0].options.body.content,'Test review');assert.equal(mutations[0].options.body.rating,null); + assert.equal(submit.disabled,false);assert.equal(textarea.value,' Test review '); + assert.equal(h.document.querySelector('.je-review-form-error').textContent,'reviews_form_error_save'); + h.document.querySelector('.je-review-cancel-btn').click();assert.equal(h.document.querySelector('.je-review-form'),null); +}); +test('review star picker supports half stars and rating-only submissions',async t=>{ + const h=await setup(t);const mutations=[]; + h.JE.core.api.plugin=async(path,options)=>{mutations.push({path,options});throw Error('offline');}; + h.document.querySelector('.je-review-write-btn').click(); + const star=h.document.querySelector('[data-value="4"]');star.getBoundingClientRect=()=>({left:100,width:20}); + star.dispatchEvent(new h.window.MouseEvent('click',{clientX:105})); + assert.equal(h.document.querySelector('.je-star-label').textContent,'3.5/5'); + h.document.querySelector('.je-review-submit-btn').click();await nextTurn(); + assert.equal(mutations[0].options.body.rating,3.5);assert.equal(mutations[0].options.body.content,''); + h.document.querySelector('.je-star-clear-btn').click();assert.equal(h.document.querySelector('.je-star-label').textContent,''); +}); +for(const admin of [false,true]){ + test(`review deletion uses ${admin?'moderation':'own-user'} endpoint with one mutation and refresh`,async t=>{ + const h=await setup(t,{admin,reviews:[{userId:admin?'other-user':'user-a',userName:'Reviewer',content:'Delete me'}]}); + const calls=[];h.window.confirm=()=>true; + h.JE.core.api.plugin=async(path,options)=>{calls.push({path,options});return {reviews:[]};}; + h.document.querySelector('.je-review-delete-btn').click();await nextTurn(); + assert.equal(calls[0].path,admin?'/reviews/admin/otheruser/movie/7':'/reviews/movie/7'); + assert.equal(calls[0].options.method,'DELETE');assert.equal(calls[0].options.skipRetry,true); + assert.equal(calls.filter(c=>c.options?.method==='DELETE').length,1); + assert.equal(h.document.querySelector('.je-user-review-card'),null); + }); +} +test('review canceled deletion makes no mutation',async t=>{ + const h=await setup(t,{reviews:[{userId:'user-a',content:'Keep me'}]});h.window.confirm=()=>false; + const count=h.calls.length;h.document.querySelector('.je-review-delete-btn').click();await nextTurn(); + assert.equal(h.calls.length,count);assert.ok(h.document.querySelector('.je-user-review-card')); +}); +test('review moderation 404 displays actionable error and refreshes actual state',async t=>{ + const h=await setup(t,{admin:true,reviews:[{userId:'other-user',content:'Old review'}]});const alerts=[];const requests=[]; + h.expectConsoleError('Reviews: Delete failed');h.window.confirm=()=>true;h.window.alert=message=>alerts.push(message); + h.JE.core.api.plugin=async(path,options)=>{requests.push({path,options});if(options?.method==='DELETE')throw {status:404};return {reviews:[]};}; + h.document.querySelector('.je-review-delete-btn').click();await nextTurn(); + assert.equal(alerts.length,1);assert.match(alerts[0],/No matching review to delete/); + assert.equal(requests.length,2);assert.equal(h.document.querySelector('.je-user-review-card'),null); +}); +test('successful review creation refreshes persisted content and edit prepopulates and saves changes',async t=>{ + const h=await setup(t);let stored=[];const saved=[]; + h.JE.core.api.plugin=async(path,options)=>{ + if(options?.method==='POST'){saved.push(options.body);stored=[{userId:'user-a',userName:'Me',...options.body}];return {};} + return {reviews:stored}; + }; + h.document.querySelector('.je-review-write-btn').click(); + h.document.querySelector('textarea').value='First version';h.document.querySelector('.je-review-submit-btn').click();await nextTurn(); + assert.equal(h.document.querySelector('.je-user-review-card .tmdb-review-text').textContent,'First version'); + assert.equal(h.document.querySelector('.je-review-write-btn'),null); + h.document.querySelector('.je-review-edit-btn').click();assert.equal(h.document.querySelector('textarea').value,'First version'); + h.document.querySelector('textarea').value='Updated version';h.document.querySelector('.je-review-submit-btn').click();await nextTurn(); + assert.equal(saved.length,2);assert.equal(h.document.querySelector('.je-user-review-card .tmdb-review-text').textContent,'Updated version'); + assert.equal(h.document.querySelector('.je-review-form'),null); +}); diff --git a/tests/frontend/pages/pages-scroll.test.mjs b/tests/frontend/pages/pages-scroll.test.mjs new file mode 100644 index 000000000..e731ab541 --- /dev/null +++ b/tests/frontend/pages/pages-scroll.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {setImmediate as nextTurn} from 'node:timers/promises'; +import {createHarness,deferred} from '../helpers/harness.mjs'; +function setup(t){ + const observers=[];const h=createHarness({html:'
    ',globals:{IntersectionObserver:class{constructor(fn){this.fn=fn;this.disconnected=false;observers.push(this);}observe(){}disconnect(){this.disconnected=true;}}}}); + t.after(()=>h.close());h.load('jellyseerr/seamless-scroll.js');const state={}; + const section=h.document.querySelector('section');section.getClientRects=()=>[{}]; + t.after(()=>h.JE.seamlessScroll.cleanupInfiniteScroll(state)); + return {...h,P:h.JE.seamlessScroll,state,section,observers}; +} +test('sparse scroll historical regression: nonempty pages with no measured growth stop at safety valve',async t=>{ + const h=setup(t);let loads=0;h.P.setupInfiniteScroll(h.state,'#feed',async()=>{loads++;return {pages:1,rendered:1};},()=>true,()=>false); + await nextTurn();assert.equal(loads,h.P.CONFIG.maxStuckFills);assert.ok(h.document.querySelector('.je-retry-row button')); + h.observers[0].fn([{isIntersecting:true}]);await nextTurn();assert.equal(loads,h.P.CONFIG.maxStuckFills); + h.document.querySelector('.je-retry-row button').click();await nextTurn();assert.equal(loads,2*h.P.CONFIG.maxStuckFills); +}); +test('empty page budget pauses without dropping continuation and supplies remaining budget',async t=>{ + const h=setup(t);h.P.CONFIG.maxConsecutiveEmptyPages=4;const budgets=[]; + h.P.setupInfiniteScroll(h.state,'#feed',async hint=>{budgets.push(hint.pageBudget);return {pages:1,rendered:0};},()=>true,()=>false); + await nextTurn();assert.deepEqual(budgets,[4,3,2,1]);assert.ok(h.document.querySelector('.je-retry-row')); +}); +test('scroll zero-progress and exhausted feeds terminate without retries or runaway loads',async t=>{ + const h=setup(t);let loads=0;h.P.setupInfiniteScroll(h.state,'#feed',async()=>{loads++;return {pages:0,rendered:0};},()=>true,()=>false); + await nextTurn();assert.equal(loads,1);assert.equal(h.document.querySelector('.je-retry-row'),null); + h.P.setupInfiniteScroll(h.state,'#feed',async()=>{loads++;return {pages:1,rendered:1};},()=>false,()=>false);await nextTurn();assert.equal(loads,1);assert.equal(h.observers[0].disconnected,true); +}); +test('hidden scroll sections do not fetch and teardown prevents pending load from fetching again',async t=>{ + const h=setup(t);let loads=0;h.section.getClientRects=()=>[]; + h.P.setupInfiniteScroll(h.state,'#feed',async()=>{loads++;},()=>true,()=>false);await nextTurn();assert.equal(loads,0); + h.section.getClientRects=()=>[{}];const pending=deferred();h.P.setupInfiniteScroll(h.state,'#feed',async()=>{loads++;return pending.promise;},()=>true,()=>false); + h.P.cleanupInfiniteScroll(h.state);pending.resolve({pages:1,rendered:1});await nextTurn();assert.equal(loads,1);assert.equal(h.state.fill,null);assert.equal(h.document.querySelector('.je-scroll-sentinel'),null); +}); diff --git a/tests/frontend/regressions/history-early-events.test.mjs b/tests/frontend/regressions/history-early-events.test.mjs new file mode 100644 index 000000000..144147633 --- /dev/null +++ b/tests/frontend/regressions/history-early-events.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness} from '../helpers/harness.mjs'; + +function setup(t) { + const h = createHarness({html:'', JE:{ + pluginConfig:{}, currentSettings:{autoPauseEnabled:false,autoResumeEnabled:true}, + state:{activeShortcuts:{PlayRandomItem:'R',SubtitleMenu:'S',JumpToPercentage:''}}, + isVideoPage:()=>false, attachSeekTracker(){}, injectGlobalStyles(){},addPluginMenuButton(){},addUserMenuLink(){},addRandomButton(){},applySavedStylesWhenReady(){}, + helpers:{createObserver(){},throttle:fn=>fn,onBodyMutation(){}} + }}); + t.after(()=>h.close());h.load('enhanced/events.js'); + h.press=(key,modifiers={})=>{const e=new h.window.KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...modifiers});h.JE.keyListener(e);return e;}; + return h; +} + +test('history issue 55 Command-R preserves browser refresh without selecting random media',t=>{ + const h=setup(t);let clicks=0;h.document.getElementById('randomItemButton').onclick=()=>clicks++; + for(const modifier of [{metaKey:true},{ctrlKey:true},{altKey:true}]) { + assert.equal(h.press('r',modifier).defaultPrevented,false);assert.equal(clicks,0); + } + assert.equal(h.press('r').defaultPrevented,true);assert.equal(clicks,1); +}); + +test('history issue 1 subtitle shortcut closes an existing sheet rather than reopening it',t=>{ + const h=setup(t);h.JE.isVideoPage=()=>true;let opens=0;h.document.querySelector('.btnSubtitles').onclick=()=>opens++; + h.document.body.insertAdjacentHTML('beforeend','
    Subtitles
    '); + h.press('s');assert.equal(opens,0);assert.equal(h.document.querySelector('.dialogContainer'),null);assert.equal(h.document.querySelector('.dialogBackdrop'),null); + h.press('s');assert.equal(opens,1); +}); + +test('history issue 163 disabled auto pause respects tab changes and only resumes its own pause',t=>{ + const h=setup(t);const video=h.document.querySelector('video');let paused=false,hidden=false,pauses=0,plays=0; + Object.defineProperty(video,'paused',{get:()=>paused});Object.defineProperty(h.document,'hidden',{get:()=>hidden}); + video.pause=()=>{pauses++;paused=true;};video.play=()=>{plays++;paused=false;return Promise.resolve();}; + h.JE.initializeEnhancedScript();const visibility=value=>{hidden=value;h.document.dispatchEvent(new h.window.Event('visibilitychange'));}; + visibility(true);assert.equal(pauses,0);visibility(false);assert.equal(plays,0); + h.JE.currentSettings.autoPauseEnabled=true;visibility(true);assert.equal(pauses,1);visibility(false);assert.equal(plays,1); + paused=true;visibility(true);visibility(false);assert.equal(plays,1); + h.JE.currentSettings.autoPauseEnabled=false;paused=false;visibility(true);assert.equal(pauses,1); +}); diff --git a/tests/frontend/regressions/history-middle.test.mjs b/tests/frontend/regressions/history-middle.test.mjs new file mode 100644 index 000000000..797e3694e --- /dev/null +++ b/tests/frontend/regressions/history-middle.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHarness,deferred} from '../helpers/harness.mjs'; + +// #550 / #551: protected avatar requests must retain a Jellyfin reverse-proxy prefix. +test('history551 protected avatars preserve the configured Jellyfin base path and authorization',async t=>{ + const requests=[]; + const h=createHarness({apiClient:{getUrl:path=>`https://media.test/jellyfin${path}`},fetch:async(url,options)=>{requests.push({url,options});return new Response('image');}}); + t.after(()=>h.close());h.window.URL.createObjectURL=()=> 'blob:avatar';h.load('enhanced/helpers.js'); + assert.equal(await h.JE.helpers.resolveProtectedAvatarUrl('/JellyfinEnhanced/proxy/avatar?path=%2Favatar%2F1'),'blob:avatar'); + assert.equal(requests[0].url,'https://media.test/jellyfin/JellyfinEnhanced/proxy/avatar?path=%2Favatar%2F1'); + assert.equal(requests[0].options.headers['X-MediaBrowser-Token'],'test-token'); +}); + +// #504: simultaneous and repeated renders share a single authenticated blob download. +test('history504 repeated and concurrent avatar rendering downloads one blob and releases it',async t=>{ + const response=deferred();let downloads=0,created=0;const revoked=[]; + const h=createHarness({fetch:()=>{downloads++;return response.promise;}});t.after(()=>h.close()); + h.window.URL.createObjectURL=()=>`blob:avatar-${++created}`; + h.window.URL.revokeObjectURL=url=>revoked.push(url);h.load('enhanced/helpers.js'); + const path='/JellyfinEnhanced/proxy/avatar?path=%2Favatar%2F1'; + const first=h.JE.helpers.resolveProtectedAvatarUrl(path),second=h.JE.helpers.resolveProtectedAvatarUrl(path); + response.resolve(new Response('image')); + assert.deepEqual(await Promise.all([first,second]),['blob:avatar-1','blob:avatar-1']); + assert.equal(await h.JE.helpers.resolveProtectedAvatarUrl(path),'blob:avatar-1'); + assert.equal(downloads,1);assert.equal(created,1); + h.JE.helpers.clearAvatarObjectUrlCache(true);assert.deepEqual(revoked,['blob:avatar-1']); +}); + +// #413 / #414: native Jellyfin ::cue styles survive enabling and disabling JE styling. +test('history414 legacy cue stylesheet survives custom subtitle enable and disable',t=>{ + const h=createHarness({html:'',JE:{currentSettings:{},helpers:{onBodyMutation:()=>({unsubscribe(){}})}}}); + t.after(()=>h.close());h.load('enhanced/player/subtitles.js'); + const native=h.document.getElementById('htmlvideoplayer-cuestyle'); + const original=[...native.sheet.cssRules].map(rule=>rule.cssText); + h.JE.applySubtitleStyles('#fff','transparent',2,'sans-serif','none'); + assert.deepEqual([...native.sheet.cssRules].map(rule=>rule.cssText),original); + assert.ok(h.document.getElementById('je-html-videoplayer-cuestyle').sheet.cssRules.length); + h.JE.currentSettings.disableCustomSubtitleStyles=true;h.JE.applySavedStylesWhenReady(); + assert.deepEqual([...native.sheet.cssRules].map(rule=>rule.cssText),original); + assert.equal(h.document.getElementById('je-html-videoplayer-cuestyle').sheet.cssRules.length,0); +}); diff --git a/tests/frontend/regressions/history-recent-regressions.test.mjs b/tests/frontend/regressions/history-recent-regressions.test.mjs new file mode 100644 index 000000000..25961a2b4 --- /dev/null +++ b/tests/frontend/regressions/history-recent-regressions.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +/** Exercise real card and page rendering; only unrelated image hydration is stubbed. */ +function requests(t) { + const actions = [], navigations = []; + const state = { canApproveRequests: true, requestsFilter: 'all', requests: [], downloads: [], history: [], isLoading: false }; + const P = { state, clearAvatarObjectUrlCache() {}, hydrateAvatarImages() {}, hydrateExternalLinks() {}, + handleRequestAction: (button, action) => actions.push([button.dataset.requestId, action]) }; + const h = createHarness({ html: '
    ', + globals: { Emby: { Page: { showItem: id => navigations.push(id) } } }, + JE: { pluginConfig: { JellyseerrEnabled: true, ShowDownloadsInRequests: false }, + internals: { requestsPage: P }, cdn: { selfhst: p => '/fixtures/' + p } } }); + t.after(() => h.close()); + h.load('core/ui-kit.js'); + h.JE.escapeHtml = h.JE.core.ui.escapeHtml; + h.load('arr/requests/requests-page-render-helpers.js'); + h.load('arr/requests/requests-page-render-cards.js'); + h.load('arr/requests/requests-page-render.js'); + return { ...h, P, state, actions, navigations }; +} +const pending = { id: 42, title: 'Existing series, new season', type: 'tv', requestStatus: 1, + mediaStatus: 'Partially Available', jellyfinMediaId: 'series-a' }; + +test('history PR 658 pending season remains approvable on an available series', t => { + const h = requests(t); + for (const mediaStatus of ['Partially Available', 'Available', 'Pending']) { + h.state.requests = [{ ...pending, mediaStatus }]; h.P.renderPage(); + assert.equal(h.document.querySelector('.je-request-approve-btn')?.dataset.requestId, '42'); + assert.equal(h.document.querySelector('.je-request-decline-btn')?.dataset.requestId, '42'); + } + for (const requestStatus of [2, 3, 4]) { + h.state.requests = [{ ...pending, requestStatus }]; h.P.renderPage(); + assert.equal(h.document.querySelector('.je-request-approve-btn'), null); + } + h.state.canApproveRequests = false; h.state.requests = [pending]; h.P.renderPage(); + assert.equal(h.document.querySelector('.je-request-approve-btn'), null); +}); + +test('history PR 658 rerendering requests dispatches one approval or navigation per click', t => { + const h = requests(t); h.state.requests = [pending]; + for (let i = 0; i < 5; i++) h.P.renderPage(); + h.document.querySelector('.je-request-approve-btn .material-icons').click(); + assert.deepEqual(h.actions, [['42', 'approve']]); + h.document.querySelector('.je-request-decline-btn').click(); + assert.deepEqual(h.actions, [['42', 'approve'], ['42', 'decline']]); + h.document.querySelector('.je-request-watch-btn').click(); + assert.deepEqual(h.navigations, ['series-a']); + const custom = h.document.createElement('section'); h.document.body.append(custom); + for (let i = 0; i < 3; i++) h.P.renderPage(custom); + custom.querySelector('.je-request-approve-btn').click(); + assert.deepEqual(h.actions, [['42', 'approve'], ['42', 'decline'], ['42', 'approve']]); +}); + +/** Register the actual quality renderer and its shared overlay implementation. */ +function quality(t) { + const renderers = new Map(); + const h = createHarness({ html: '
    ', + JE: { currentSettings: { qualityTagsEnabled: true, qualityTagsPreferredAudioLanguage: 'none' }, + pluginConfig: { TagCacheServerMode: false }, + session: { getUserId: () => 'a', getServerId: () => 's', onUserChange() {} }, + _cacheManager: { register() {}, markDirty() {} }, + tagPipeline: { registerRenderer: (key, renderer) => renderers.set(key, renderer), clearProcessed() {}, scheduleScan() {} } } }); + t.after(() => h.close()); + h.load('core/ui-kit.js'); h.load('core/tag-renderer-base.js'); h.load('tags/qualitytags.js'); h.JE.initializeQualityTags(); + return { ...h, renderers }; +} + +test('history issue 659 stereo first cannot hide richer audio badges', t => { + const h = quality(t); + const stereo = { Type: 'Audio', Channels: 2, ChannelLayout: 'stereo', DisplayTitle: 'MP2 - Stereo - Standard', IsDefault: true }; + const surround = { Type: 'Audio', Channels: 6, ChannelLayout: '5.1', DisplayTitle: 'Dolby Digital - 5.1' }; + const fullest = { Type: 'Audio', Channels: 8, ChannelLayout: '7.1' }; + for (const [i, streams] of [[stereo, surround], [surround, stereo], [stereo, surround, fullest], [fullest, stereo, surround]].entries()) { + const host = h.document.createElement('div'); host.className = 'je-tag-host'; h.document.body.append(host); + h.renderers.get('quality').render(host, { Id: `movie-${i}`, Type: 'Movie', MediaStreams: streams }, {}); + const badges = Array.from(host.querySelectorAll('[data-quality]'), el => el.dataset.quality); + assert.ok(badges.includes(streams.length === 2 ? '5.1' : '7.1'), JSON.stringify(badges)); + assert.ok(!badges.includes('2.0')); + } +}); diff --git a/tests/frontend/regressions/perf-round2-discovery.test.mjs b/tests/frontend/regressions/perf-round2-discovery.test.mjs new file mode 100644 index 000000000..7558dab7e --- /dev/null +++ b/tests/frontend/regressions/perf-round2-discovery.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness } from '../helpers/harness.mjs'; + +test('person discovery retries an unrendered chunk after a transient card build failure', async () => { + const h = createHarness({ html: '
    ', + expectedConsoleErrors: ['Error loading more items: Error: controlled card build failure'] }); + let failNextCard = false; + let loadMore; + h.JE.jellyseerrUI = { + createJellyseerrCard(item) { + if (failNextCard) { + failNextCard = false; + throw new Error('controlled card build failure'); + } + const card = h.document.createElement('div'); + card.className = 'card'; + card.dataset.id = String(item.id); + return card; + } + }; + h.load('jellyseerr/discovery/discovery-filter-utils.js'); + // The scroll engine is driven explicitly; real production rendering, + // filtering and pagination run normally. No timing or geometry dependency. + h.JE.discoveryFilter.waitForPageReady = async () => h.document.querySelector('#detail'); + h.JE.discoveryFilter.setupInfiniteScroll = (_state, _selector, callback) => { loadMore = callback; }; + h.JE.discoveryFilter.cleanupScrollObserver = () => {}; + h.load('jellyseerr/discovery/discovery-base.js'); + const controller = h.JE.discoveryBase.createDiscovery({ + key: 'person', mode: 'client-paged', logLabel: 'Person Discovery', + configKey: 'JellyseerrShowPersonDiscovery', pageSize: 2, + getIdFromUrl: () => 'person-1', + resolveItems: async () => ({ title: 'Person', items: Array.from({ length: 6 }, (_, i) => ({ id: i + 1, mediaType: 'movie' })) }) + }); + try { + await controller.render(); + const rendered = () => [...h.document.querySelectorAll('.card')].map(card => Number(card.dataset.id)); + assert.deepEqual(rendered(), [1, 2]); + assert.equal(typeof loadMore, 'function'); + failNextCard = true; + await assert.rejects(loadMore(), /controlled card build failure/); + assert.deepEqual(rendered(), [1, 2]); + await loadMore(); + assert.deepEqual(rendered(), [1, 2, 3, 4], 'retry must retain the failed chunk rather than skip its titles'); + await loadMore(); + assert.deepEqual(rendered(), [1, 2, 3, 4, 5, 6]); + } finally { + controller.cleanup(); + h.close(); + } +}); diff --git a/tests/frontend/requests/request-submission.test.mjs b/tests/frontend/requests/request-submission.test.mjs new file mode 100644 index 000000000..e169602fa --- /dev/null +++ b/tests/frontend/requests/request-submission.test.mjs @@ -0,0 +1,172 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, deferred, plain } from '../helpers/harness.mjs'; + +function setup(t, { get = async () => ({}), post = async () => ({ id: 7 }), config = {} } = {}) { + const calls = [], invalidated = [], usage = [], events = [], toasts = []; + let epoch = 0, onChange; + const h = createHarness({ JE: { + pluginConfig: config, t: (key, args) => key + (args ? JSON.stringify(args) : ''), + escapeHtml: value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])), + toast: (...args) => toasts.push(args), helpers: { trackUsage: key => usage.push(key) }, + session: { getEpoch: () => epoch, isCurrent: value => value === epoch, onUserChange: (_, fn) => { onChange = fn; } }, + requestManager: { clearCacheMatching: key => invalidated.push(key) }, + core: { api: { fetch: (url, options) => get(url, options), plugin: (url, options) => { calls.push([url, plain(options)]); return post(url, options); } } } + }}); + t.after(() => h.close()); + h.load('jellyseerr/api.js'); + for (const event of ['jellyseerr-media-requested', 'jellyseerr-tv-requested']) h.document.addEventListener(event, e => events.push([event, plain(e.detail)])); + return { ...h, api: h.JE.jellyseerrAPI, calls, invalidated, usage, events, toasts, switchUser() { epoch++; onChange(); } }; +} + +for (const type of ['movie', 'tv']) test(`${type} request sends explicit settings without retry and broadcasts success`, async t => { + const h = setup(t); + const result = await h.api.requestMedia('42', type, { serverId: 2, profileId: 5 }, true, null, 88); + assert.equal(result.id, 7); + assert.deepEqual(h.calls, [['/jellyseerr/request', { method: 'POST', skipRetry: true, body: { + mediaType: type, mediaId: 42, serverId: 2, profileId: 5, ...(type === 'tv' ? { seasons: 'all', tvdbId: 88 } : {}), is4k: true + }}]]); + assert.ok(h.invalidated.includes(`jellyseerr:/${type}/42`)); + assert.ok(h.invalidated.includes('jellyseerr:/quota')); + assert.equal(h.events.length, type === 'tv' ? 2 : 1); + assert.deepEqual(h.events[0][1], { tmdbId: '42', mediaType: type, is4k: true }); + assert.deepEqual(h.usage, ['seerr.request_submitted']); +}); + +test('selected seasons and manual TVDB match survive override-rule evaluation', async t => { + const h = setup(t, { get: async () => [{ sonarrServiceId: 9, language: 'ja', profileId: 3, rootFolder: '/anime' }] }); + await h.api.requestTvSeasons('42', [0, 2], {}, { originalLanguage: 'ja' }, false, 777); + assert.deepEqual(h.calls[0][1].body, { mediaType: 'tv', mediaId: 42, seasons: [0, 2], serverId: 9, profileId: 3, rootFolder: '/anime', tvdbId: 777 }); +}); + +for (const status of [403, 404, 429, 503]) test(`request ${status} fails without success side effects or retry`, async t => { + const failure = Object.assign(new Error('rejected'), { status }); + const h = setup(t, { post: async () => { throw failure; } }); + await assert.rejects(h.api.requestTvSeasons(42, [1]), error => error === failure); + assert.equal(h.calls.length, 1); + assert.deepEqual(h.events, []); assert.deepEqual(h.invalidated, []); assert.deepEqual(h.usage, []); +}); + +test('watchlist failure does not turn an accepted request into a retryable failure', async t => { + const h = setup(t); h.api.addToWatchlist = async () => { throw new Error('unavailable'); }; + assert.equal((await h.api.requestMedia(42, 'movie')).id, 7); + assert.equal(h.calls.length, 1); assert.equal(h.events.length, 1); +}); + +test('advanced options require both administrator setting and current user permission', async t => { + let status = { active: true, userFound: true, canRequestAdvanced: true }; + const h = setup(t, { config: { JellyseerrShowAdvanced: true }, get: async () => status }); + assert.equal(h.api.shouldShowAdvanced(), false); + await h.api.checkUserStatus(); assert.equal(h.api.shouldShowAdvanced(), true); + h.JE.pluginConfig.JellyseerrShowAdvanced = false; assert.equal(h.api.shouldShowAdvanced(), false); + h.JE.pluginConfig.JellyseerrShowAdvanced = true; + status = { active: true, userFound: true, canRequestAdvanced: false }; h.switchUser(); + assert.equal(h.api.shouldShowAdvanced(), false); + await h.api.checkUserStatus(); assert.equal(h.api.shouldShowAdvanced(), false); +}); + +test('late status response from previous user cannot grant advanced options to next user', async t => { + const pending = deferred(); let reads = 0; + const h = setup(t, { config: { JellyseerrShowAdvanced: true }, get: () => ++reads === 1 ? pending.promise : Promise.resolve({ active: true, userFound: true, canRequestAdvanced: false }) }); + const first = h.api.checkUserStatus(); h.switchUser(); + pending.resolve({ active: true, userFound: true, canRequestAdvanced: true }); await first; + assert.equal(h.api.shouldShowAdvanced(), false); + await h.api.checkUserStatus(); assert.equal(reads, 2); assert.equal(h.api.shouldShowAdvanced(), false); +}); + +test('status banners escape upstream HTML and reset on user switch', t => { + const h = setup(t); const status = { reason: 'blocked', message: '' }; + h.api.surfaceUserStatusBanner(status); h.api.surfaceUserStatusBanner(status); + assert.equal(h.toasts.length, 1); assert.ok(h.toasts[0][0].includes('<img')); assert.ok(!h.toasts[0][0].includes(' h.JE.jellyseerrUI.configureRequestButton(h.button, h.item, active, user); + return h; +} +const flush = () => new Promise(resolve => setImmediate(resolve)); +for (const [active, linked] of [[false, true], [true, false]]) test(`request button blocks offline/unlinked state ${active}/${linked}`, async t => { + const h = uiSetup(t); h.configure(active, linked); h.button.click(); await flush(); + assert.equal(h.button.disabled, true); assert.equal(h.calls.length, 0); +}); + +test('rapid repeated clicks submit one movie request and update status on success', async t => { + const pending = deferred(); const h = uiSetup(t, { post: () => pending.promise }); h.configure(); + h.button.click(); h.button.click(); assert.equal(h.button.disabled, true); await flush(); assert.equal(h.calls.length, 1); + pending.resolve({ id: 7 }); await flush(); + assert.equal(h.item.mediaInfo.status, 3); assert.equal(h.button.disabled, true); + assert.ok(h.button.classList.contains('jellyseerr-button-pending')); +}); + +for (const split of [false, true]) test(`request failure escapes message and permits explicit retry (split=${split})`, async t => { + let tries = 0; + const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: split }, post: async () => { if (++tries === 1) throw { status: 500, responseJSON: { message: '' } }; return { id: 7 }; } }); + h.configure(); const button = split ? h.document.querySelector('.jellyseerr-split-main') : h.button; + button.click(); await flush(); assert.equal(button.disabled, false); assert.equal(button.querySelector('img'), null); assert.ok(button.textContent.includes(' { + const h = uiSetup(t, { post: async () => { throw { status: 403, responseJSON: { message: 'Movie Quota exceeded. ' } }; }, get: async () => ({ movie: { limit: 2, used: 2, restricted: true } }) }); + const dialogs = []; h.window.Dashboard = { alert: data => dialogs.push(data) }; h.configure(); h.button.click(); await flush(); + assert.equal(dialogs.length, 1); assert.ok(dialogs[0].message.includes('<img')); assert.equal(h.button.disabled, false); assert.equal(h.events.length, 0); +}); + +test('quota detection distinguishes permission errors and honors feature toggle', t => { + const h = uiSetup(t); const ui = h.JE.jellyseerrUI; + assert.equal(ui.isQuotaError({ status: 403, responseJSON: { message: 'TV Quota exceeded.' } }), true); + assert.equal(ui.isQuotaError({ status: 403, responseJSON: { message: 'Forbidden' } }), false); + assert.equal(ui.isQuotaError({ status: 500, responseJSON: { message: 'Quota exceeded' } }), false); + h.JE.pluginConfig.JellyseerrShowQuotaInfo = false; + assert.equal(ui.isQuotaError({ status: 403, responseJSON: { message: 'Quota exceeded' } }), false); +}); + +test('reconfiguring a request button does not duplicate submission handlers', async t => { + const h = uiSetup(t); h.configure(); h.configure(); h.button.click(); await flush(); + assert.equal(h.calls.length, 1); +}); + +test('reconfigured button submits the new media rather than stale item data', async t => { + const h = uiSetup(t); h.configure(); h.item = { id: 99, title: 'New film', mediaType: 'movie' }; h.configure(); + h.button.click(); await flush(); assert.equal(h.calls.length, 1); assert.equal(h.calls[0][1].body.mediaId, 99); +}); + +test('advanced-permission UI opens options without prematurely submitting', async t => { + const h = uiSetup(t, { config: { JellyseerrShowAdvanced: true }, get: async () => ({ active: true, userFound: true, canRequestAdvanced: true }) }); + await h.api.checkUserStatus(); const opened = []; h.JE.jellyseerrUI.showMovieRequestModal = (...args) => opened.push(args); + h.configure(); h.button.click(); await flush(); assert.equal(h.calls.length, 0); assert.equal(opened.length, 1); assert.equal(opened[0][0], 42); +}); + +for (const code of ['no_request_permission', 'request_4k_forbidden']) test(`typed permission denial ${code} is shown without success state`, async t => { + const h = uiSetup(t, { post: async () => { throw { status: 403, responseJSON: { code } }; } }); + h.configure(); h.button.click(); await flush(); + assert.ok(h.button.textContent.includes(`jellyseerr_err_${code}`)); assert.equal(h.events.length, 0); assert.equal(h.item.mediaInfo, undefined); +}); + +test('quota chips hide unlimited quotas and distinguish warning from restriction', t => { + const h = uiSetup(t); const ui = h.JE.jellyseerrUI; + assert.equal(ui.buildQuotaChip({ movie: { limit: 0, used: 500 } }, 'movie'), null); + const warning = ui.buildQuotaChip({ movie: { limit: 4, remaining: 1, used: 3 } }, 'movie'); + assert.ok(warning.classList.contains('jellyseerr-quota-chip-warning')); + const blocked = ui.buildQuotaChip({ tv: { limit: 2, remaining: 0, used: 2, restricted: true } }, 'tv'); + assert.ok(blocked.classList.contains('jellyseerr-quota-chip-restricted')); assert.ok(blocked.textContent.includes('restricted_hint')); +}); + +test('disabled quota feature makes no network request', async t => { + const h = setup(t, { config: { JellyseerrShowQuotaInfo: false }, get: () => { throw new Error('must not fetch'); } }); + assert.equal(await h.api.fetchUserQuota(), null); +}); + +for (const split of [false, true]) for (const succeeds of [true, false]) test(`late request ${succeeds ? 'success' : 'failure'} cannot overwrite a refreshed offline button (split=${split})`, async t => { + const pending = deferred(); const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: split }, post: () => pending.promise }); h.configure(); + if (split) h.button = h.document.querySelector('.jellyseerr-split-main'); + h.button.click(); await flush(); h.configure(false, true); + const offlineLabel = h.button.textContent; + if (succeeds) pending.resolve({ id: 7 }); else pending.reject({ status: 500, responseJSON: { message: 'old failure' } }); + await flush(); assert.equal(h.button.disabled, true); assert.equal(h.button.textContent, offlineLabel); +}); diff --git a/tests/frontend/run.mjs b/tests/frontend/run.mjs new file mode 100644 index 000000000..62b5524a4 --- /dev/null +++ b/tests/frontend/run.mjs @@ -0,0 +1,7 @@ +import { spawnSync } from 'node:child_process'; +import { discoverTests, repositoryRoot } from './helpers/discovery.mjs'; + +const result = spawnSync(process.execPath, + ['--test', ...process.argv.slice(2), ...discoverTests()], + { cwd: repositoryRoot, stdio: 'inherit' }); +process.exitCode = result.error || result.signal || result.status === null ? 1 : result.status; diff --git a/tests/history/backend-mutations.json b/tests/history/backend-mutations.json new file mode 100644 index 000000000..693fe5c53 --- /dev/null +++ b/tests/history/backend-mutations.json @@ -0,0 +1,57 @@ +[ + { + "name": "issue-823-cdn-timeout", + "layer": "backend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "before": "catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)", + "after": "catch (OperationCanceledException)", + "test": "FullKnownAssetRefreshContinuesAfterHttpTimeoutAndReportsCompletion", + "assertion": "TaskCanceledException", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/823", + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/825" + ], + "evidence": "exact reversal of the production catch-filter change in PR #825" + }, + { + "name": "issue-861-podman-alias", + "layer": "backend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs", + "before": "var allowLinkLocal = _containerHostAliases.Contains(host);", + "after": "var allowLinkLocal = false;", + "test": "ContainerAliasesPermitPodmanLinkLocalWithoutPermittingCloudMetadata", + "assertion": "Assert.Equal", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/861", + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/873" + ], + "evidence": "representative restoration of pre-fix rejection; retains the new policy helper so current tests compile, no actual Podman host" + }, + { + "name": "issue-905-jellium-exclusion", + "layer": "backend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativeClientPolicy.cs", + "before": " \"Jellium Desktop\",\n", + "after": "", + "test": "ReportedDesktopShellMustNotReceiveNativeTags", + "assertion": "Assert.Equal", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/905", + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/906" + ], + "evidence": "exact reversal of the production exclusion-list change in PR #906" + }, + { + "name": "issue-900-advanced-payload", + "layer": "backend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "before": "content = StripAdvancedRequestOptions(content);", + "after": "content = content; // reproduce missing filtering", + "test": "RequestsUsePrincipalIdentityPreserveSeasonSelectionAndGateAdvancedOptions", + "assertion": "Assert.Equal", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/900" + ], + "evidence": "representative fault: unauthorized advanced fields reach upstream; UI visibility must be assessed separately" + } +] diff --git a/tests/history/early-mutations.json b/tests/history/early-mutations.json new file mode 100644 index 000000000..2c454a0dd --- /dev/null +++ b/tests/history/early-mutations.json @@ -0,0 +1,18 @@ +[ + { + "name": "history-55-meta-refresh", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js", + "before": "const combo = (e.metaKey ? 'Meta+' : '') +", + "after": "const combo = '' +", + "test": "history issue 55 Command-R preserves browser refresh without selecting random media", + "test_file": "tests/frontend/regressions/history-early-events.test.mjs", + "assertion": "ERR_ASSERTION", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/55", + "https://github.com/n00bcodr/Jellyfin-Enhanced/commit/4f8feb69997749667e08d65a2e933b0999b9bd94" + ], + "evidence": "exact-reversal", + "rationale": "Removes the Meta modifier term added in 4f8feb69; surrounding modifier order changed later, but this precisely restores the reported missing-Meta behavior." + } +] diff --git a/tests/history/early-triage.json b/tests/history/early-triage.json new file mode 100644 index 000000000..cd9671b0f --- /dev/null +++ b/tests/history/early-triage.json @@ -0,0 +1,3012 @@ +[ + { + "number": 1, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/1", + "title": "Prevent 'S' from reopening subtitle menu if it is open", + "status": "tested-new", + "tests": [ + "tests/frontend/regressions/history-early-events.test.mjs" + ], + "assessment": "Specific reported symptom tested against current production event module. Passes; historical fault not replayed for this record. #1 only English host sheet label; #163 exercises settings flag rather than administrator-to-user default propagation." + }, + { + "number": 2, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/2", + "title": "Add a way to open the panel on devices without keyboard", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 3, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/3", + "title": "\"C\" hotkey says \"no subtitle tracks\" even though there are.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 4, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/4", + "title": "Audio Track Cycling 'v' not working", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 5, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/5", + "title": "Add an option to the JS file to set defaults for all server users overwriting previous localstorage defaults", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 6, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/6", + "title": "Shift + Esc for Home clashes with Browser Taskmanager", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 7, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/7", + "title": "Auto updating on server side install", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 9, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/9", + "title": "System.UnauthorizedAccessException: Access to the path '/usr/share/jellyfin/web/index.html' is denied", + "status": "environment", + "tests": [], + "assessment": "Reported OS web-folder permissions differ from disposable-host startup. Can test denied-write handling but cannot claim reproducing deployment permissions here." + }, + { + "number": 10, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/10", + "title": "the plugin can't access the web folder", + "status": "environment", + "tests": [], + "assessment": "Reported OS web-folder permissions differ from disposable-host startup. Can test denied-write handling but cannot claim reproducing deployment permissions here." + }, + { + "number": 11, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/11", + "title": "Jellyfin Installation on Docker doesn't have \"/usr/share/jellyfin/web/index.html\" location", + "status": "environment", + "tests": [], + "assessment": "Reported OS web-folder permissions differ from disposable-host startup. Can test denied-write handling but cannot claim reproducing deployment permissions here." + }, + { + "number": 12, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/12", + "title": "Clear local storage setting", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 13, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/13", + "title": "Use File Transformation plugin to inject JS", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 14, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/14", + "title": "Customizable Hotkey shortcuts", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 15, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/15", + "title": "Move Default configuration to config page.", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 16, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/16", + "title": "Subtitles are tiny, even when on \"Extra Large\" settings", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 17, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/17", + "title": "Add ability to override shortcuts", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 18, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/18", + "title": "Add customizable keyboard shortcuts with UI", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 19, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/19", + "title": "Subtitle styling doesn't change", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 20, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/20", + "title": "🐛[BUG] Can't install on my jellyfin instance", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 21, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/21", + "title": "🐛[BUG] TMDB links open in the app instead of external browser", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 22, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/22", + "title": "🐛[BUG]Jellyseerr Search not showing anything", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 23, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/23", + "title": "🐛[BUG] Most features broken on local address", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 24, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/24", + "title": "🐛[BUG] tmdb integration breaks on jellyfin app", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 25, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/25", + "title": "🐛[BUG] Issue with Card Sizing while using Elegantfin theme", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 26, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/26", + "title": "🐛[BUG] Admin works but not admin doesnt on truenass", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 27, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/27", + "title": "🐛[BUG] cannot request -getting Error response", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 28, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/28", + "title": "🐛[BUG] Jellyseerr not returning results", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 29, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/29", + "title": "🐛[BUG]not seem to be able to request stuff", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 30, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/30", + "title": "➕[Feature Request] - Add Quality Tags and Pause Screen from BobHasNoSoul", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 31, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/31", + "title": "➕[Feature Request] Jellyseerr request seasons", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 32, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/32", + "title": "🐛[BUG?] Auto update scheduled task not updating the plugin", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 33, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/33", + "title": "🐛[BUG] Quality Tags are showing wrong info", + "status": "partial-existing", + "tests": [ + "tests/backend/PosterTags/PosterRegressionTests.cs", + "tests/frontend/core/core-tag-renderer.test.mjs" + ], + "assessment": "Production tag renderer and stream-height resolution tested; original TV-mode div-card and 1328x720 fixture not all replayed. Width-sensitive threshold may escape height-only test." + }, + { + "number": 34, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/34", + "title": "➕[Feature Request] Add ability to select from multiple Radarr and Sonarr Instances", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 36, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/36", + "title": "➕[Feature Request] Multi lang support or option to edit texts/images", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 37, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/37", + "title": "Localisation", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 38, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/38", + "title": "🐛[BUG]", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 40, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/40", + "title": "Adjust Italian localization text in it.json", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 41, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/41", + "title": "Spanish translation", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 42, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/42", + "title": "🐛[BUG] Not working with \"/jellyfin\" BaseUrl", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiMiddlewareTests.cs", + "tests/frontend/bootstrap/bootstrap.test.mjs" + ], + "assessment": "Base-path asset/bootstrap coverage exists; historical reverse-proxy deployment and branded-image route not reproduced end-to-end." + }, + { + "number": 43, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/43", + "title": "🐛[BUG] ASS subtitle files are not displayed", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 44, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/44", + "title": "➕[Feature Request]", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiSeerrTests.cs", + "tests/frontend/requests/request-submission.test.mjs" + ], + "assessment": "Request identity/permission/advanced option gates and submission covered; each historical partial-request control and ARR queue visibility not replayed." + }, + { + "number": 45, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/45", + "title": "Add Traditional Chinese", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 46, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/46", + "title": "➕[Feature Request] Genre Flags", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 47, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/47", + "title": "➕[Feature Request] Subtitles - Add dropshadow to 'Clean White'", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 48, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/48", + "title": "feat: Watchlist", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 49, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/49", + "title": "➕[Feature Request] Hungarian translation", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 50, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/50", + "title": "➕[Feature Request] Pause screen should be toggleable", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 51, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/51", + "title": "➕[Feature Request] Integrate watchlist with Home Sections plugin", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 52, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/52", + "title": "Create hu.json", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 53, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/53", + "title": "➕[Feature Request] See who requested a particular media item on Jellyseerr from within Jellyfin", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 54, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/54", + "title": "➕[Feature Request] Sync watchlist with Jellyseerr watchlist", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 55, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/55", + "title": "🐛[BUG] Random Shortcut Breaks Refresh", + "status": "proven-new", + "tests": [ + "tests/frontend/regressions/history-early-events.test.mjs" + ], + "assessment": "Command-R is not consumed and does not select random media; plain R still works. Exact missing-Meta fix reversal detected in disposable copy." + }, + { + "number": 56, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/56", + "title": "➕[Feature Request] Option to disable streaming providers", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 57, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/57", + "title": "🐛[BUG] Jellyseerr search results not displayed in TV display mode", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 58, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/58", + "title": "Add admin update check and install", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 59, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/59", + "title": "➕[Feature Request] Add option for custom splash screen", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 60, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/60", + "title": "10.11 RC support???", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 61, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/61", + "title": "🐛[BUG] Actors returned in Jellyseerr results", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 62, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/62", + "title": "🐛[BUG] Partially Available Show Unable To Request", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 63, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/63", + "title": "Integrate watchlist into home sections plugin", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 64, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/64", + "title": "➕[Feature Request] Disable All Shortcuts", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 66, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/66", + "title": "🐛[BUG] Doesnt work on any browser except for firefox", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 67, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/67", + "title": "➕ Proxy TheMovieDB requests through jellyfin", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 68, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/68", + "title": "🐛[BUG] Long loading", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 69, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/69", + "title": "🐛[BUG] Invalid TMDB API Key", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 70, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/70", + "title": "➕[Feature Request] Better loader", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 71, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/71", + "title": "🐛[BUG] Language Settings not applied if Profile Language is set to Auto, Always ENG", + "status": "partial-existing", + "tests": [ + "tests/frontend/core/core-translations.test.mjs" + ], + "assessment": "Document-language fallback and bundled Unicode loading covered; original browser Auto locale and release-specific deployment not replayed." + }, + { + "number": 72, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/72", + "title": "➕[Feature Request] Disable GUI for users", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 73, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/73", + "title": "➕[Feature Request] Tap and Hold for Instant 2x Playback", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 74, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/74", + "title": "🐛[BUG] Jellyseer requests only working on home network.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 76, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/76", + "title": "🐛[BUG] User not found on jellyseerr", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 77, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/77", + "title": "➕[Feature Request] Add a black list for genre's", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 78, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/78", + "title": "➕[Feature Request]Make the jellyseerr logo sizable", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 79, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/79", + "title": "🐛[BUG] Permissions error on Windows, Jellyfin as a service.", + "status": "environment", + "tests": [], + "assessment": "Reported OS web-folder permissions differ from disposable-host startup. Can test denied-write handling but cannot claim reproducing deployment permissions here." + }, + { + "number": 80, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/80", + "title": "🐛[BUG] - Jellyseerr API key Validation does not work", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 82, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/82", + "title": "🐛[BUG] Pause Screen backdrop is still blurred after disabling backdrop", + "status": "partial-existing", + "tests": [ + "tests/frontend/features/features-pause.test.mjs" + ], + "assessment": "Disabled initial mount and pause lifecycle covered; administrator precedence/backdrop unblur/mouse-delay original reports need separate fixtures." + }, + { + "number": 83, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/83", + "title": "🐛[BUG] Watchlist Custom Tab Scrolling doesnt work", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 85, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/85", + "title": "➕[Feature Request] Ability to request only 4K movies", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 86, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/86", + "title": "🐛[BUG] Subtitles are weird on the android app", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 87, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/87", + "title": "🐛[BUG] Enhancer features are working only on local ip address", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 88, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/88", + "title": "🐛[BUG] Subtitles not showing", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 89, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/89", + "title": "Unable to find splash screen folder", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 90, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/90", + "title": "🐛 [BUG] JellySeer Search + Meilisearch (I think) ", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 91, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/91", + "title": "➕[Feature Request] Global Default subtitle settings for admins & Drop shadow options.", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 92, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/92", + "title": "➕[Feature Request] Make Jellyseerr search work on the LG WebOS Jellyfin app", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 93, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/93", + "title": "Cannot find remove from continue watching", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 94, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/94", + "title": "🐛[BUG] Language and Audio Tags don't persist when navigating between episodes", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 95, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/95", + "title": "🐛[BUG] Manually mark watched items should be removed from watchlist", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 96, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/96", + "title": "➕[Feature Request] Back button should be visible on top of enhanced pause screen", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 97, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/97", + "title": "➕[Feature Request] Jellyseer Request to not have Seasons option", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 98, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/98", + "title": "UNINSTALL INSTRUCTIONS", + "status": "support", + "tests": [], + "assessment": "Support/setup request without established production fault; retain as deployment/documentation evidence, no claim of automated bug detection." + }, + { + "number": 99, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/99", + "title": "🐛[BUG] Genre Tags should better handle foreign movies/languages", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 100, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/100", + "title": "➕[Feature Request] 3D Tag for Show Quality Tag", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 102, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/102", + "title": "🐛[BUG]", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 103, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/103", + "title": "🐛[BUG] After the new plug-in update. Shows are struggling to find languages, causing the ui to act weird", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 104, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/104", + "title": "🐛[BUG] Integrated Jellyseer search not working after update to Jellyfin 10.11.0", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 105, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/105", + "title": "🐛[BUG] Not working on 10.11", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 106, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/106", + "title": "➕[Feature Request] hdr10plus label", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 107, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/107", + "title": "🐛[BUG] INTERACTION WITH GELATO PLUGIN", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 108, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/108", + "title": "🐛[BUG] Watchlist feature not working in Jellyfin 10.11.0", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 109, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/109", + "title": "🐛[BUG] Subtitle settings not having an effect in Firefox, Jellyfin 10.11.0", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 110, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/110", + "title": "🐛[BUG] the logo is not showing in the splash-screen", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 111, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/111", + "title": "➕[Feature Request] Option to hide audio quality and HDR", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 112, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/112", + "title": "[Question] Jellyseerr and SSO?", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 113, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/113", + "title": "Watchlist from KefinTweaks", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 114, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/114", + "title": "➕[Feature Request] Jellyseerr button on show page", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 115, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/115", + "title": "➕[Feature Request] Export/Import watchlist", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 116, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/116", + "title": "➕[Feature Request] key navigation", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 117, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/117", + "title": "🐛[BUG] No features working after update to Jellyfin 10.11.0", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 118, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/118", + "title": "➕[Feature Request] Auto-expand Reviews", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 120, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/120", + "title": "➕[Feature Request] Decouple Quality Tags and Genre Tags cache from plugin version", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 122, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/122", + "title": "🐛[BUG] Jellyseerr icon not showing, no search results.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 124, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/124", + "title": "🐛[BUG] Multiple Watchlist buttons appear", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 125, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/125", + "title": "🐛[BUG]", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 126, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/126", + "title": "🐛[BUG] Watchlist no longer works with Home Screen Sections plugin", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 127, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/127", + "title": "🐛[BUG] The settings doesnt work on jellyfin client", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 128, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/128", + "title": "Disc Missing", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 129, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/129", + "title": "fix typo", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 130, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/130", + "title": "Add [Authorize] to Jellyseerr and TMDB endpoints", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiRoutingTests.cs", + "tests/backend/Api/ApiConfigurationTests.cs" + ], + "assessment": "Authenticated routing and safe configuration responses covered; no exact removal of historical authorization attributes replayed." + }, + { + "number": 131, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/131", + "title": "Add check for TMDb link before adding ARR links", + "status": "gap", + "tests": [], + "assessment": "Specific ARR missing-TMDB polling/slug/trailing-slash or Seerr HTTP fallback contract needs dedicated fixture and historical patch proof; generic integration tests cannot establish detection." + }, + { + "number": 132, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/132", + "title": "➕[Feature Request] Respect Override Rules from Jellyseerr when Requesting content", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiSeerrTests.cs", + "tests/frontend/requests/request-submission.test.mjs" + ], + "assessment": "Request identity/permission/advanced option gates and submission covered; each historical partial-request control and ARR queue visibility not replayed." + }, + { + "number": 133, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/133", + "title": "Add theme detection and dynamic theming support", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 134, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/134", + "title": "[BUG] MissingMethodException: IUserManager.GetUserById not found in Jellyfin 10.11.1", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 135, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/135", + "title": "➕[Feature Request] Ability to add multiple Sonarr, Radarr instances", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 136, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/136", + "title": "➕[Feature Request] Show filesizes for Entire Shows and Seasons", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 137, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/137", + "title": "Fix quality tags for TV mode", + "status": "partial-existing", + "tests": [ + "tests/backend/PosterTags/PosterRegressionTests.cs", + "tests/frontend/core/core-tag-renderer.test.mjs" + ], + "assessment": "Production tag renderer and stream-height resolution tested; original TV-mode div-card and 1328x720 fixture not all replayed. Width-sensitive threshold may escape height-only test." + }, + { + "number": 138, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/138", + "title": "🐛[BUG]", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 139, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/139", + "title": "Fix LanguageTagsEnabled not resetting when using ResetAllUserSettings", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiConfigurationTests.cs" + ], + "assessment": "Reset/default configuration has coverage; exact LanguageTagsEnabled reset and old Jellyfin10.11.1 TypeLoadException not replayed. Historical unsupported binary cannot be inferred from current host passes." + }, + { + "number": 140, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/140", + "title": "➕[Feature Request] Requested Media added to Watchlist", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 141, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/141", + "title": "➕[Feature Request] Automatic season requests", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 142, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/142", + "title": "🐛[BUG] Genre tags global position settings do not work", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 143, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/143", + "title": "➕[Feature Request] InPlayerEpisodePreview", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 144, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/144", + "title": "🐛[BUG] No option for remove continue watching.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 145, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/145", + "title": "fix: Add 'Remove from Continue Watching' option on right-click", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 146, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/146", + "title": "🐛[BUG] Exception while trying to get Jellyseerr user ID", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 147, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/147", + "title": "Focus in jellyseerr dialog", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 148, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/148", + "title": "🐛[BUG] Unable to open user menu on WebOS", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 149, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/149", + "title": "🐛[BUG] Subtitles are rendered too small and the subtitle settings are ignored", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 150, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/150", + "title": "Fix: Unable to open user menu on WebOS", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 151, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/151", + "title": "🐛[BUG] Quality tags are incorrect for 720p media", + "status": "partial-existing", + "tests": [ + "tests/backend/PosterTags/PosterRegressionTests.cs", + "tests/frontend/core/core-tag-renderer.test.mjs" + ], + "assessment": "Production tag renderer and stream-height resolution tested; original TV-mode div-card and 1328x720 fixture not all replayed. Width-sensitive threshold may escape height-only test." + }, + { + "number": 152, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/152", + "title": "➕[Feature Request] Make Jellyseerr search's result cards' images clickable", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 153, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/153", + "title": "➕[Feature Request] Remove/Hide from Next Up?", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 154, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/154", + "title": "[WHY] watchlist seems different than KefinTweaks Watchlist even though you are using his?", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 156, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/156", + "title": "🐛[BUG] request in 4K fail", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 158, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/158", + "title": "🐛[BUG] quality tags / pause screen and pause on switch tab do not work", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 159, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/159", + "title": "🐛[BUG] Unable to reset all user settings to Default", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiConfigurationTests.cs" + ], + "assessment": "Reset/default configuration has coverage; exact LanguageTagsEnabled reset and old Jellyfin10.11.1 TypeLoadException not replayed. Historical unsupported binary cannot be inferred from current host passes." + }, + { + "number": 160, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/160", + "title": "Partial TV Show Requests Ignore Jellyseerr Settings", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiSeerrTests.cs", + "tests/frontend/requests/request-submission.test.mjs" + ], + "assessment": "Request identity/permission/advanced option gates and submission covered; each historical partial-request control and ARR queue visibility not replayed." + }, + { + "number": 161, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/161", + "title": "➕[Feature Request]Scaling quality tags mobile", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 162, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/162", + "title": "🐛[BUG] isPartialRequestsEnabled failed with 403 for user account", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiSeerrTests.cs", + "tests/frontend/requests/request-submission.test.mjs" + ], + "assessment": "Request identity/permission/advanced option gates and submission covered; each historical partial-request control and ARR queue visibility not replayed." + }, + { + "number": 163, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/163", + "title": "🐛[BUG] Auto Pause", + "status": "tested-new", + "tests": [ + "tests/frontend/regressions/history-early-events.test.mjs" + ], + "assessment": "Specific reported symptom tested against current production event module. Passes; historical fault not replayed for this record. #1 only English host sheet label; #163 exercises settings flag rather than administrator-to-user default propagation." + }, + { + "number": 164, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/164", + "title": "➕[Feature Request] Leverage Genres and Languages for search", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 165, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/165", + "title": "➕[Feature Request] Pause screen with video background instead of landscape image", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 166, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/166", + "title": "➕[Feature Request] Something should happen when clicking the jellyseer poster in search result", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 167, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/167", + "title": "🐛[BUG] Request button looks strange", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 168, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/168", + "title": "🐛[BUG] Pause screen not localized", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 170, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/170", + "title": "add brazilian portuguese localization", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 171, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/171", + "title": "🐛[BUG] Subtitles iOS don’t show up when fullscreen", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 172, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/172", + "title": "Add Turkish localization for Jellyfin Enhanced", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 175, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/175", + "title": "🐛[BUG] Cannot change settings tab", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 177, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/177", + "title": "➕[Feature Request] Separate internal and external Jellyseerr URLs", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 178, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/178", + "title": "Fix Review layout to support different themes", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 179, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/179", + "title": "➕[Feature Request] The custom pause screen should be activated after a delay", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 180, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/180", + "title": "🐛[BUG] No longer showing search results from Jellyseerr", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 181, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/181", + "title": "➕[Feature Request] Letterboxd link on item page", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 182, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/182", + "title": "fix: replace '&' (ampersand) with 'and' in series name for Sonarr link", + "status": "gap", + "tests": [], + "assessment": "Specific ARR missing-TMDB polling/slug/trailing-slash or Seerr HTTP fallback contract needs dedicated fixture and historical patch proof; generic integration tests cannot establish detection." + }, + { + "number": 183, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/183", + "title": "Fix watchlist following to https://github.com/ranaldsgift/KefinTweaks/commit/216be4502a87730da43da01aabdb9a86b50249f0", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 184, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/184", + "title": "➕[Feature Request] Jellyseer integration works but doesn't look good.", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 185, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/185", + "title": "[Fix] Allow Entire Request Cover to Be Clicked", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 186, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/186", + "title": "🐛[BUG] Critical error while loading watchlist scripts", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 187, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/187", + "title": "*arr tags causes app to crash when clicked", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 188, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/188", + "title": "Found a little typo", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 189, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/189", + "title": "File does not exist", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 190, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/190", + "title": "➕[Feature Request] Configurable quality tags", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 191, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/191", + "title": "feat: file size info for shows and seasons", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 192, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/192", + "title": "🐛[BUG] Random button serving missing content", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 193, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/193", + "title": "feat: watch progress for item details", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 194, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/194", + "title": "➕[Feature Request] change Language TMDB comments", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 195, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/195", + "title": "➕[Feature Request] Download transcoded videos", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 196, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/196", + "title": "🐛[BUG] Cannot make request", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 197, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/197", + "title": "🐛[BUG] Languages for shows don't show up for shows", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 198, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/198", + "title": "➕[Feature Request] Changing flags and size", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 199, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/199", + "title": "➕[Feature Request] Automatic selection of a content version based on the device", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 200, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/200", + "title": "🐛[BUG] Automatic Advance Season Request not working", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + }, + { + "number": 201, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/201", + "title": "Add Russian language support for genre icons", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 202, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/202", + "title": "➕🐛[Feature Request/BUG] Automatically add requested media to user's Watchlist", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 203, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/203", + "title": "🐛[BUG] Jellyseer connection fails", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 204, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/204", + "title": "➕[Feature Request] IMDB Rating tags in video player and on posters.", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 205, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/205", + "title": "🐛[BUG] Subtitles disconnected shadows", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 206, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/206", + "title": "🐛[BUG] Jellyseerr Automatic Advance Season Requests is requesting seasons that don't exist.", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + }, + { + "number": 207, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/207", + "title": "🐛[BUG] Failed to load assembly \"/config/plugins/Jellyfin Enhanced_9.6.1.0/Jellyfin.Plugin.JellyfinEnhanced.dll", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 208, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/208", + "title": "➕[Feature Request] Add select all seasons checkbox", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 209, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/209", + "title": "➕[Feature Request] Report issues to jellyseerr", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 210, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/210", + "title": "🐛[BUG] Status NotSupported Version\t9.6.2.0", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 211, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/211", + "title": "➕[Feature Request] Improve Item Identification flow", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 212, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/212", + "title": "🐛[BUG] Custom Pause Screen not working if moving mouse before default delay", + "status": "partial-existing", + "tests": [ + "tests/frontend/features/features-pause.test.mjs" + ], + "assessment": "Disabled initial mount and pause lifecycle covered; administrator precedence/backdrop unblur/mouse-delay original reports need separate fixtures." + }, + { + "number": 213, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/213", + "title": "🐛[BUG] Genre tags not displayed for TV shows (Series) or on detail-page posters", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 215, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/215", + "title": "➕[Feature Request] Expose Jellyseerr “Request Movie Collection” from Jellyfin Enhanced", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 216, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/216", + "title": "🐛[BUG]Extremely laggy UI on Jellyfin Media Player, also crashes my Plugins directory.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 217, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/217", + "title": "🐛[BUG] [auto request] \"season 2 already exists locally\" <-- but it does not.", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + }, + { + "number": 219, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/219", + "title": "🐛[BUG] Infinite searching for IMDb ID for Letterboxd Link on Playlists", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 220, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/220", + "title": "🐛[BUG][Jellyseer Requests] Request show up and then nothing", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 221, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/221", + "title": "🐛[BUG] Unable to add request", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 222, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/222", + "title": "🐛[BUG] Resquets spam cause poor perfomance", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 223, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/223", + "title": "How to actually install this?", + "status": "support", + "tests": [], + "assessment": "Support/setup request without established production fault; retain as deployment/documentation evidence, no claim of automated bug detection." + }, + { + "number": 224, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/224", + "title": "Added ability to report issues to Jellyseerr through Jellyfin via this plugin", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 225, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/225", + "title": "🐛[BUG] Jellyfin users cannot be fetched from Jellyseeer", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 226, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/226", + "title": "🐛[BUG] Automatic advance season request feature requests too much", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + }, + { + "number": 227, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/227", + "title": "➕[Feature Request] Option to hide streaming-lookup-container", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 228, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/228", + "title": "🐛[BUG] Watchlist Spam even when disabled", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskRegressionTests.cs" + ], + "assessment": "DisabledWatchlistMonitorDoesNotSubscribeAndDisposalUnsubscribes covers inactive monitor; historical client watchlist polling not reproduced." + }, + { + "number": 229, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/229", + "title": "➕[Feature Request] Library Scan on Media Import", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 230, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/230", + "title": "[Help?] Jellyfin-Enhanced on Windows Jellyfin 10.11.5 installed, configured... nothing.", + "status": "support", + "tests": [], + "assessment": "Support/setup request without established production fault; retain as deployment/documentation evidence, no claim of automated bug detection." + }, + { + "number": 231, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/231", + "title": "🐛[BUG] Infinite polling in Jellyseerr UI until client exit", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 232, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/232", + "title": "➕[Feature Request] Ability to choose Quality Profile", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 234, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/234", + "title": "➕[Feature Request] Add Bazarr to Jellyfin", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 235, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/235", + "title": "[Discussion] Jellyseer URL Mappings Not Replacing Localhost with TLD", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 236, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/236", + "title": "➕[Feature Request] Distinguished quality tags", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 237, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/237", + "title": "🐛[BUG] PiP only works when media is in paused state", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 238, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/238", + "title": "🐛[BUG] No image on Jellyseerr results", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 239, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/239", + "title": "➕[Feature Request] Adding new features", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 240, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/240", + "title": "Improve issue reporting: always search missing episodes & lazy TMDB resolution", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 241, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/241", + "title": "🐛[BUG] Slow input response on media player IOS", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 242, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/242", + "title": "🐛[BUG] Jellyseerr Watchlist integration adds all Jellyseerr requests to all users", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs" + ], + "assessment": "InboundWatchlistIsUserScopedIdempotentAndDoesNotReaddRemovedItems covers per-user ownership and scheduled removal preservation; manual endpoint and library-event path are not equivalent proof." + }, + { + "number": 243, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/243", + "title": "➕[Feature Request] Add Rotten Tomatoes/TMDB ratings", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 244, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/244", + "title": "Add Polish locale file", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 245, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/245", + "title": "🐛[BUG] arr links arent absolute", + "status": "gap", + "tests": [], + "assessment": "Specific ARR missing-TMDB polling/slug/trailing-slash or Seerr HTTP fallback contract needs dedicated fixture and historical patch proof; generic integration tests cannot establish detection." + }, + { + "number": 246, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/246", + "title": "🐛[BUG] NotSupported error after installed", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 247, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/247", + "title": "➕[Feature Request] More detailed auto request for seasons", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 248, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/248", + "title": "➕[Feature Request] Automatic Movie Request", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 249, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/249", + "title": "➕[Feature Request] Modern 2x speed video playback control", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 250, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/250", + "title": "➕[Feature Request] Option to remove emoji", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 251, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/251", + "title": "🐛[BUG] The plugin adds an extra character to the arr link address", + "status": "gap", + "tests": [], + "assessment": "Specific ARR missing-TMDB polling/slug/trailing-slash or Seerr HTTP fallback contract needs dedicated fixture and historical patch proof; generic integration tests cannot establish detection." + }, + { + "number": 252, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/252", + "title": "➕[Feature Request] Use multiple ARR applications", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 253, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/253", + "title": "Redesign seerr UI", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 254, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/254", + "title": "➕[Feature Request] Show recommended series/shows", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 255, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/255", + "title": "🐛[BUG] Genre tags cover native jellyfin watch progress indicator", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 256, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/256", + "title": "➕[Feature Request] Double-tap/click to seek.md", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 257, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/257", + "title": "🐛[BUG] No more translations", + "status": "partial-existing", + "tests": [ + "tests/frontend/core/core-translations.test.mjs" + ], + "assessment": "Document-language fallback and bundled Unicode loading covered; original browser Auto locale and release-specific deployment not replayed." + }, + { + "number": 259, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/259", + "title": "Feat/watch-progress-as-time-string", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 260, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/260", + "title": "[Feature Request] Shortcut key to skip intro", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 261, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/261", + "title": "Add discovery sections for networks, genres, tags, and persons", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 262, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/262", + "title": "Feat: Customizable icons", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 265, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/265", + "title": "🐛[BUG] Report button not working", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 266, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/266", + "title": "➕[Feature Request] File size / watch progress for nested collections", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 267, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/267", + "title": "🐛[BUG] Custom xxxx does not support base URL", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiMiddlewareTests.cs", + "tests/frontend/bootstrap/bootstrap.test.mjs" + ], + "assessment": "Base-path asset/bootstrap coverage exists; historical reverse-proxy deployment and branded-image route not reproduced end-to-end." + }, + { + "number": 268, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/268", + "title": "➕[Feature Request] \"Continue Watching\" list in Jellyfin without resetting the watch progress", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 269, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/269", + "title": "➕[Feature Request] Collection in Details page", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 270, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/270", + "title": "🐛[BUG] Disabling custom pause screen does not disable custom pause screen.", + "status": "partial-existing", + "tests": [ + "tests/frontend/features/features-pause.test.mjs" + ], + "assessment": "Disabled initial mount and pause lifecycle covered; administrator precedence/backdrop unblur/mouse-delay original reports need separate fixtures." + }, + { + "number": 271, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/271", + "title": "➕[Feature Request] Arr integration for request & download tracking", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 272, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/272", + "title": "🐛[BUG]NotSupported on 10.11.5", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 275, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/275", + "title": "🐛[BUG] 2x speed overlay displaying escaped HTML/SVG code instead of rendering icon when using non-emoji icons", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 276, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/276", + "title": "🐛[BUG] Random Unwatched only option not working", + "status": "partial-existing", + "tests": [ + "tests/frontend/features/features-random.test.mjs" + ], + "assessment": "Pinned collection unwatched-empty fallback covered; full historical only-unwatched API filter contract not proven." + }, + { + "number": 277, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/277", + "title": "🐛[BUG] Jellyfin enhanced settings common to all users", + "status": "partial-existing", + "tests": [ + "tests/frontend/bootstrap/bootstrap.test.mjs", + "tests/backend/Api/ApiConfigurationTests.cs" + ], + "assessment": "Identity transitions and user settings isolation exercised; original complete settings UI journey across accounts not replayed." + }, + { + "number": 278, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/278", + "title": "Fix person page missing TV shows/episodes sections", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 279, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/279", + "title": "🐛[BUG] Not Supported on v10.11.5", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 280, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/280", + "title": "🐛[BUG] Expanding Jellyseerr search results in Android app fails", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 281, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/281", + "title": "➕[Calendar/Schedule Element with integrated requesting]", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 282, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/282", + "title": "🐛[BUG] Auto-Season Request does not check unreleased episodes for threshold", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + }, + { + "number": 283, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/283", + "title": "➕[Feature Request] Show next movie in collection in next up", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 284, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/284", + "title": "➕[Feature Request] Add option to disable the \"hold to select\" feature", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 285, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/285", + "title": "Refactor discovery modules with shared utilities and fix All filter", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 286, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/286", + "title": "🐛[BUG] Can not install Jellyfin Enhanced", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 287, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/287", + "title": "[Miscellaneous] Move Scripts in Separate \"Scripts\" Section", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 288, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/288", + "title": "➕[Feature Request] Exclude rejected movies/series from Jellyseerr Item Details", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 289, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/289", + "title": "🐛[BUG] Disable Colored Ratings doesn't work", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 291, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/291", + "title": "➕[Feature Request] Also theme the favicons that are shown on apple devices", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 293, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/293", + "title": "➕[Feature Request] Preview or Info On Actual size resolution for Icon Transparent, Favicon, Banner Light and Dark", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 294, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/294", + "title": "Fix config banner text light/dark", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 295, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/295", + "title": "Personal changes", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 296, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/296", + "title": "➕[Feature Request] Options to hide and move the 2X speed overlay", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 297, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/297", + "title": "➕[Feature Request] Ability to set subtitle opacity or custom hex color", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 298, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/298", + "title": "Add dedicated requests page with sonarr/radarr/jellyseerr request monitoring", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 299, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/299", + "title": "🐛[BUG] On connectionManager Id missmatch loading prevent user from taking action", + "status": "gap", + "tests": [], + "assessment": "Stale connectionManager server-ID recovery needs real saved-session/mismatched-server fixture; ordinary bootstrap is insufficient." + }, + { + "number": 300, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/300", + "title": "➕[Feature Request] Jellyseerr Tags", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 301, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/301", + "title": "🐛[BUG] Hide Requests and Calendar if Jellyseerr is not set up", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 302, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/302", + "title": "Replace fixed calendar grid with minmax for flexible widths", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 303, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/303", + "title": "🐛[BUG] The new Request page contains some untranslated elements.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 304, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/304", + "title": "Modify arr/requests endpoint to enforce Jellyseerr user permissions", + "status": "partial-existing", + "tests": [ + "tests/backend/Api/ApiSeerrTests.cs", + "tests/frontend/requests/request-submission.test.mjs" + ], + "assessment": "Request identity/permission/advanced option gates and submission covered; each historical partial-request control and ARR queue visibility not replayed." + }, + { + "number": 305, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/305", + "title": "🐛[BUG] Live Tv info page has colored tags clobbering text.", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 306, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/306", + "title": "🐛[BUG] Sidebar doesn’t auto-close when clicking on Requests", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 307, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/307", + "title": "🐛[BUG] A few visual things", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 308, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/308", + "title": "🐛[BUG] Sidebar favicon fetched via unsecure `http`", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 309, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/309", + "title": "➕[Feature Request] Add filtering for calendar", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 310, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/310", + "title": "➕[Feature Request] Jump to item from Calendar view if item is available", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 311, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/311", + "title": "➕[Feature Request] Highlight Calendar Entries of watchlistedContent", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 312, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/312", + "title": "Fix server ID mismatch blocking user authentication", + "status": "gap", + "tests": [], + "assessment": "Stale connectionManager server-ID recovery needs real saved-session/mismatched-server fixture; ordinary bootstrap is insufficient." + }, + { + "number": 313, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/313", + "title": "fix: Navigate to episode details instead of series from calendar", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 314, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/314", + "title": "feat: Highlight calendar entries for favorites and watched series", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 315, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/315", + "title": "➕[Feature Request] Coming soon Tab in requests view", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 316, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/316", + "title": "Plugin Pages Support & Calendar/Requests Improvements", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 317, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/317", + "title": "➕[Feature Request] Filter Active Downloads in Requests Page", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 318, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/318", + "title": "➕[Feature Request] Hiding arr* name in calendar", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 319, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/319", + "title": "Add seamless infinite scroll to discovery sections", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-scroll.test.mjs" + ], + "assessment": "Sparse/zero-progress pages, hidden-section inactivity and teardown tested; entire discovery-page filter/dedup/network retry combination not reproduced." + }, + { + "number": 320, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/320", + "title": "Add seamless infinite scroll to discovery sections", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-scroll.test.mjs" + ], + "assessment": "Sparse/zero-progress pages, hidden-section inactivity and teardown tested; entire discovery-page filter/dedup/network retry combination not reproduced." + }, + { + "number": 321, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/321", + "title": "➕[Feature Request] Spinner for jellyseer search results", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 322, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/322", + "title": "Optimize calendar method and new endpoint for item ID by provider IDs", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 324, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/324", + "title": "Intent to Deprecate Support for Jellyfin Version 10.10.x - Mid February", + "status": "historical-deployment", + "tests": [ + "tests/host/run.py" + ], + "assessment": "Old package checksum/ABI/unsupported-version/deprecation reports. Current pinned10.11.11 and12.0 host smoke covers modern load contracts, not historic binaries/installer manifests." + }, + { + "number": 325, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/325", + "title": "➕[Feature Request] IMAX quality tag", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 326, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/326", + "title": "Fix calendar range handling (UTC) and pass RFC3339 start/end to Sonarr/Radarr", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-calendar.test.mjs", + "tests/backend/Integrations/IntegrationCalendarTests.cs" + ], + "assessment": "Calendar settings/filtering/provider integration has coverage; last-day RFC3339 boundary and each historical navigation/layout change not proven by this audit." + }, + { + "number": 327, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/327", + "title": "Keep last calendar view in user settings", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-calendar.test.mjs", + "tests/backend/Integrations/IntegrationCalendarTests.cs" + ], + "assessment": "Calendar settings/filtering/provider integration has coverage; last-day RFC3339 boundary and each historical navigation/layout change not proven by this audit." + }, + { + "number": 328, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/328", + "title": "fix: Jellyseerr proxy URL fallback on HTTP errors", + "status": "gap", + "tests": [], + "assessment": "Specific ARR missing-TMDB polling/slug/trailing-slash or Seerr HTTP fallback contract needs dedicated fixture and historical patch proof; generic integration tests cannot establish detection." + }, + { + "number": 329, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/329", + "title": "➕[Feature Request]/[Bug] allow Watchlist Removal", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs" + ], + "assessment": "InboundWatchlistIsUserScopedIdempotentAndDoesNotReaddRemovedItems covers per-user ownership and scheduled removal preservation; manual endpoint and library-event path are not equivalent proof." + }, + { + "number": 330, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/330", + "title": "fix: prevent watchlist sync from re-adding user-removed items", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs" + ], + "assessment": "InboundWatchlistIsUserScopedIdempotentAndDoesNotReaddRemovedItems covers per-user ownership and scheduled removal preservation; manual endpoint and library-event path are not equivalent proof." + }, + { + "number": 331, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/331", + "title": "Remove weird icons with date below items", + "status": "support", + "tests": [], + "assessment": "Support/setup request without established production fault; retain as deployment/documentation evidence, no claim of automated bug detection." + }, + { + "number": 332, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/332", + "title": "Use new item by provider id endpoint to navigate in calendar", + "status": "change-not-proven", + "tests": [], + "assessment": "Historical change/feature/localization PR; no exact before/after reproduction in this audit. Fix-bearing changes remain candidates, not counted as caught regressions." + }, + { + "number": 333, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/333", + "title": "Jellyseerr search not working", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 334, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/334", + "title": "➕[Feature Request] Add Format tag", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 335, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/335", + "title": "feat: per-user hidden content system", + "status": "partial-existing", + "tests": [ + "tests/frontend/features/features-hidden.test.mjs", + "tests/backend/Privacy/PrivacyHiddenContentTests.cs" + ], + "assessment": "Current hidden-state isolation/filtering covered; all nine surfaces plus dialogs/undo/native-host paths are not exhaustively exercised." + }, + { + "number": 336, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/336", + "title": "🐛[BUG] Jellyseerr Card does not work in JMP", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 337, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/337", + "title": "➕[Feature Request] Allow easy embedding of calendar & requests pages in custom tabs", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 338, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/338", + "title": "Rework Calendar UI", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-calendar.test.mjs", + "tests/backend/Integrations/IntegrationCalendarTests.cs" + ], + "assessment": "Calendar settings/filtering/provider integration has coverage; last-day RFC3339 boundary and each historical navigation/layout change not proven by this audit." + }, + { + "number": 339, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/339", + "title": "➕[Feature Request] Filter which tags to sync from arr to jellyfin", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 340, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/340", + "title": "🐛[BUG] Requests and Calendar custom tabs not working on mobile", + "status": "client-gap", + "tests": [], + "assessment": "Native app/WebOS/iOS behavior requires actual affected client or a faithful host DOM/playback fixture. Browser emulation does not establish native-client detection." + }, + { + "number": 341, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/341", + "title": "➕[Feature Request] internal and external url for *arr", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 342, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/342", + "title": "➕[Feature Request] Option to move Bookmarks to Sidebar", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 343, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/343", + "title": "🐛[BUG] Video codec tags always shown", + "status": "gap", + "tests": [], + "assessment": "Report requires a specific reproducer/root-cause or patch mapping. No assertion that current tests would catch this issue; generic feature coverage is insufficient." + }, + { + "number": 344, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/344", + "title": "➕[Feature Request] Jellyseerr \"Issues\" section on Requests Page", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 345, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/345", + "title": "➕[Feature Request] Requests per user", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 346, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/346", + "title": "➕[Feature Request] Torrent client feedback integration", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 347, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/347", + "title": "➕[Feature Request] Per-user filtered Calendar", + "status": "feature-request", + "tests": [], + "assessment": "Feature request, not by itself evidence of a historical regression. Implemented behavior remains subject to the main coverage matrix; this classification does not mean fully tested." + }, + { + "number": 348, + "kind": "pull-request", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/348", + "title": "Small css tweaks for Calendar Page", + "status": "partial-existing", + "tests": [ + "tests/frontend/pages/pages-calendar.test.mjs", + "tests/backend/Integrations/IntegrationCalendarTests.cs" + ], + "assessment": "Calendar settings/filtering/provider integration has coverage; last-day RFC3339 boundary and each historical navigation/layout change not proven by this audit." + }, + { + "number": 349, + "kind": "issue", + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/349", + "title": "🐛[BUG] Jellyseerr next season requests arent being de-duplicated", + "status": "partial-existing", + "tests": [ + "tests/backend/ScheduledTasks/TaskAutoRequestTests.cs" + ], + "assessment": "SeasonThresholdAvailabilityAndExistingRequestsControlAutomaticRequest covers thresholds/existence/requests; cross-user concurrency/restart request de-dup and every reported release combination remain gaps." + } +] diff --git a/tests/history/middle-mutations.json b/tests/history/middle-mutations.json new file mode 100644 index 000000000..69ef449f4 --- /dev/null +++ b/tests/history/middle-mutations.json @@ -0,0 +1,61 @@ +[ + { + "name": "history551-avatar-basepath", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/helpers.js", + "before": "fetch(ApiClient.getUrl(avatarUrl), { headers: getAvatarAuthHeaders() })", + "after": "fetch(avatarUrl, { headers: getAvatarAuthHeaders() })", + "test": "history551 protected avatars preserve the configured Jellyfin base path and authorization", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/551", + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/550" + ], + "evidence": "exact-reversal of 8310d8c1 call expression, now relocated to helpers.js", + "layer": "frontend", + "test_file": "tests/frontend/regressions/history-middle.test.mjs", + "assertion": "ERR_ASSERTION" + }, + { + "name": "history504-avatar-dedup", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/helpers.js", + "before": "if (avatarFetchPromises.has(avatarUrl)) return avatarFetchPromises.get(avatarUrl);", + "after": "/* historical defect: duplicate simultaneous avatar downloads */", + "test": "history504 repeated and concurrent avatar rendering downloads one blob and releases it", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/504" + ], + "evidence": "representative removal of in-flight deduplication; original fix later relocated", + "layer": "frontend", + "test_file": "tests/frontend/regressions/history-middle.test.mjs", + "assertion": "ERR_ASSERTION" + }, + { + "name": "history414-native-cue", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/subtitles.js", + "before": "let styleElement = document.getElementById('je-html-videoplayer-cuestyle');", + "after": "let styleElement = document.getElementById('htmlvideoplayer-cuestyle');", + "test": "history414 legacy cue stylesheet survives custom subtitle enable and disable", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/414", + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/413" + ], + "evidence": "representative restoration of original defect: JE overwrites Jellyfin native cue stylesheet", + "layer": "frontend", + "test_file": "tests/frontend/regressions/history-middle.test.mjs", + "assertion": "ERR_ASSERTION" + }, + { + "name": "history519-primary-locale", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", + "before": " if (normalizedLang) {\n langCodes.push(normalizedLang);\n }\n", + "after": "", + "test": "translations normalize per-user language and cache the bundled Unicode response", + "test_file": "tests/frontend/core/core-translations.test.mjs", + "assertion": "ERR_ASSERTION", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/519", + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/513" + ], + "evidence": "exact-reversal of primary-language insertion in 7bb7f540; existing test" + } +] diff --git a/tests/history/recent-mutations.json b/tests/history/recent-mutations.json new file mode 100644 index 000000000..be75566dc --- /dev/null +++ b/tests/history/recent-mutations.json @@ -0,0 +1,66 @@ +[ + { + "name": "history-659-audio-stream-order", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/qualitytags.js", + "before": " function getChannelTag(audioStreams) {\n if (!Array.isArray(audioStreams) || audioStreams.length === 0) return null;\n\n const rank = { '7.1': 3, '5.1': 2, '2.0': 1 };\n let maxChannels = 0;\n let detectedLayoutTag = null;\n\n for (const stream of audioStreams) {\n const channels = stream.Channels || 0;\n if (channels > maxChannels) {\n maxChannels = channels;\n }\n\n const layoutSignals = `${stream.ChannelLayout || ''} ${stream.DisplayTitle || ''}`.toLowerCase();\n let tag = null;\n if (/\\b7[. ]?1\\b/.test(layoutSignals)) {\n tag = '7.1';\n } else if (/\\b5[. ]?1\\b/.test(layoutSignals)) {\n tag = '5.1';\n } else if (/\\bstereo\\b|\\b2[. ]?0\\b/.test(layoutSignals)) {\n tag = '2.0';\n }\n\n if (tag && (!detectedLayoutTag || rank[tag] > rank[detectedLayoutTag])) {\n detectedLayoutTag = tag;\n }\n }\n\n if (detectedLayoutTag) {\n return detectedLayoutTag;\n }\n\n if (maxChannels >= 8) return '7.1';\n if (maxChannels >= 6) return '5.1';\n if (maxChannels >= 2) return '2.0';\n\n return null;\n }\n", + "after": " function getChannelTag(audioStreams) {\n if (!Array.isArray(audioStreams) || audioStreams.length === 0) return null;\n\n let maxChannels = 0;\n let detectedLayoutTag = null;\n\n for (const stream of audioStreams) {\n const channels = stream.Channels || 0;\n if (channels > maxChannels) {\n maxChannels = channels;\n }\n\n const layoutSignals = `${stream.ChannelLayout || ''} ${stream.DisplayTitle || ''}`.toLowerCase();\n if (!detectedLayoutTag) {\n if (/\\b7[. ]?1\\b/.test(layoutSignals)) {\n detectedLayoutTag = '7.1';\n } else if (/\\b5[. ]?1\\b/.test(layoutSignals)) {\n detectedLayoutTag = '5.1';\n } else if (/\\bstereo\\b|\\b2[. ]?0\\b/.test(layoutSignals)) {\n detectedLayoutTag = '2.0';\n }\n }\n }\n\n if (detectedLayoutTag) {\n return detectedLayoutTag;\n }\n\n if (maxChannels >= 8) return '7.1';\n if (maxChannels >= 6) return '5.1';\n if (maxChannels >= 2) return '2.0';\n\n return null;\n }\n", + "test": "history issue 659 stereo first cannot hide richer audio badges", + "test_file": "tests/frontend/regressions/history-recent-regressions.test.mjs", + "assertion": "ERR_ASSERTION", + "issue": 659, + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/659", + "evidence": "Historical function restored from 4fc376fb parent, indentation adjusted", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/659" + ] + }, + { + "name": "history-658-pending-approval", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-cards.js", + "before": "state.canApproveRequests && item.requestStatus === 1 && item.id", + "after": "state.canApproveRequests && item.mediaStatus === \"Pending\" && item.id", + "test": "history PR 658 pending season remains approvable on an available series", + "test_file": "tests/frontend/regressions/history-recent-regressions.test.mjs", + "assertion": "ERR_ASSERTION", + "pr": 658, + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658", + "evidence": "Exact predicate reversal from d76a5847, relocated split file", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658" + ] + }, + { + "name": "history-658-repeated-dispatch", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render.js", + "before": "if (!container._jeRequestsActionsBound) {", + "after": "if (true) {", + "test": "history PR 658 rerendering requests dispatches one approval or navigation per click", + "test_file": "tests/frontend/regressions/history-recent-regressions.test.mjs", + "assertion": "ERR_ASSERTION", + "pr": 658, + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658", + "evidence": "Faithful unguarded binding fault from d76a5847 parent, guard replaced rather than whole hunk", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/pull/658" + ] + }, + { + "name": "history-774-bookmark-episode", + "layer": "frontend", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "before": " if (isEpisode && bookmark.episodeNumber != null) {\n const bookmarkIsSameEpisode = bookmark.episodeNumber === episodeNumber\n && (bookmark.seasonNumber ?? null) === (seasonNumber ?? null);\n if (!bookmarkIsSameEpisode) continue;\n }\n", + "after": "", + "test": "episode provider fallback excludes other episodes and seasons but retains legacy records", + "test_file": "tests/frontend/features/features-bookmarks.test.mjs", + "assertion": "ERR_ASSERTION", + "issue": 774, + "url": "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/774", + "evidence": "Exact removal of filtering hunk introduced by 9acc883b", + "sources": [ + "https://github.com/n00bcodr/Jellyfin-Enhanced/issues/774" + ] + } +] diff --git a/tests/host/README.md b/tests/host/README.md new file mode 100644 index 000000000..f1948db1b --- /dev/null +++ b/tests/host/README.md @@ -0,0 +1,32 @@ +# Real Jellyfin host regression checks + +Run on Linux with Python 3, .NET SDK 10, and a local Docker daemon: + +```sh +python3 tests/host/run.py --target all +# Each compatibility line can run separately in CI: +python3 tests/host/run.py --target jf10 --artifacts .engineering-artifacts/host +python3 tests/host/run.py --target jf12 --artifacts .engineering-artifacts/host +``` + +The runner builds production JE separately for each target using temporary MSBuild artifact directories. It installs the actual DLL into pinned official Jellyfin images: **10.11.11** (the 10.11 compatibility line) and **12.0** (the 12 compatibility line). Image manifest digests are recorded in `run.py` and each execution report. Updating a pin requires rerunning both compatibility suites; these versions do not imply coverage of every patch release. + +Each run owns a uniquely named container, internal bridge network, temporary configuration/cache, synthetic one-second WAV, and three disposable users. Docker's existing subnets are inspected before allocating a small unused test subnet. The bridge must be reachable from the runner, so remote Docker daemons and Docker Desktop are not supported. No ports are published. External network access is blocked at the container network: background CDN failures in server logs are expected and no provider credentials are required. Cached images/NuGet packages allow offline execution; first-time dependency retrieval requires internet access on the runner. + +Checks exercise the real HTTP pipeline: plugin loading/host compatibility, embedded assets, generated media scanning, restricted-library enumeration, anonymous authentication rejection, administrator-only APIs, cross-user read/write rejection for six user documents, bootstrap identity/private-config isolation, fake-secret protection, preference persistence, and restart behavior. Both regular and restricted users participate. Without `--browser`, this is an HTTP host suite. Native clients are not exercised. It does not claim playback, image rendering, trickplay, Seerr integration, or every scheduled task is covered. + +Artifacts contain `build.log`, `server.log`, and machine-readable `report.json` per target. Errors fail the process; a missing prerequisite never counts as a skip or pass. Ephemeral passwords/tokens are redacted from server logs. Cleanup attempts log collection and removal independently, including after failures. Only resources created by the invocation are removed; no existing services are modified. An interrupted process killed with SIGKILL cannot run cleanup; uniquely named `je-regression-*` resources from that invocation must then be removed manually after verifying ownership. + +## Real browser journey + +Add `--browser` to execute Chromium against each real host after the HTTP and restart checks: + +```sh +npm ci +npx playwright install --with-deps chromium +python3 tests/host/run.py --target all --browser +# Alternatively use an installed Chromium: +JE_CHROMIUM_PATH=/usr/bin/chromium python3 tests/host/run.py --target all --browser +``` + +This loads the unmodified Jellyfin web application and JE assets, signs in through the real login form as regular and administrator users, waits for authenticated JE bootstrap, opens the Enhanced panel with its keyboard shortcut, changes Auto Pause through the checkbox, and reloads to verify server persistence. Uncaught page errors fail the journey. Third-party browser requests are blocked; host API and asset responses are not mocked. Browser dependencies are required when `--browser` is specified; failures are never silently skipped. Artifacts include a browser result file and failure screenshots. Session credentials are passed over stdin, and traces/HAR/storage snapshots are intentionally not recorded because they contain authentication tokens. These checks cover Chromium desktop only; mocked component browser tests remain a separate suite. diff --git a/tests/host/browser.mjs b/tests/host/browser.mjs new file mode 100644 index 000000000..f50d88816 --- /dev/null +++ b/tests/host/browser.mjs @@ -0,0 +1,60 @@ +import { chromium, expect } from '@playwright/test'; +import fs from 'node:fs/promises'; +let input = ''; +for await (const chunk of process.stdin) input += chunk; +const { url, password, artifacts, users } = JSON.parse(input); +const browser = await chromium.launch(process.env.JE_CHROMIUM_PATH ? { executablePath: process.env.JE_CHROMIUM_PATH } : {}); +const results = []; +try { + for (const username of users) { + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }); + await context.route('**/*', route => new URL(route.request().url()).origin === new URL(url).origin ? route.continue() : route.abort('blockedbyclient')); + context.setDefaultTimeout(30000); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + try { + await page.goto(`${url}/web/index.html#!/login.html`, { waitUntil: 'domcontentloaded' }); + const manual = page.getByRole('button', { name: /manual login|sign in manually/i }); + if (await manual.isVisible()) await manual.click(); + await page.getByLabel(/^User(name)?$/i).fill(username, { timeout: 45000 }); + await page.getByLabel('Password', { exact: true }).fill(password); + const bootstrap = page.waitForResponse(response => response.url().includes('/JellyfinEnhanced/bootstrap') && response.status() === 200, { timeout: 60000 }); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + const response = await bootstrap; + const payload = await response.json(); + expect(payload.UserId).toBeTruthy(); + await page.waitForFunction(() => window.JellyfinEnhanced?.currentSettings && typeof window.JellyfinEnhanced?.showEnhancedPanel === 'function', null, { timeout: 60000 }); + await page.locator('body').click({ position: { x: 1000, y: 950 } }); + await page.keyboard.press('?'); + const panel = page.locator('#jellyfin-enhanced-panel'); + await expect(panel).toBeVisible({ timeout: 30000 }); + await panel.locator('[data-tab="playback"]').click(); + const toggle = page.locator('#autoPauseToggle'); + const before = await toggle.isChecked(); + const saved = page.waitForResponse(r => r.url().includes('/settings.json') && r.request().method() === 'POST' && r.request().postDataJSON()?.AutoPauseEnabled === !before && r.status() === 200); + await toggle.setChecked(!before); + await saved; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.JellyfinEnhanced?.currentSettings && typeof window.JellyfinEnhanced?.showEnhancedPanel === 'function', null, { timeout: 60000 }); + await page.locator('body').click({ position: { x: 1000, y: 950 } }); + await page.keyboard.press('?'); + await expect(page.locator('#jellyfin-enhanced-panel')).toBeVisible({ timeout: 30000 }); + await page.locator('#jellyfin-enhanced-panel [data-tab="playback"]').click(); + await expect(page.locator('#autoPauseToggle')).toBeChecked({ checked: !before }); + expect(errors, 'Uncaught browser errors').toEqual([]); + results.push({ username, status: 'passed', checks: ['real login', 'injected bootstrap', 'keyboard panel', 'save preference', 'reload persistence', 'no uncaught errors'] }); + console.log(`PASS real browser ${username}: login, bootstrap, panel, save/reload`); + } catch (error) { + results.push({ username, status: 'failed', error: error.message, pageErrors: errors }); + await page.screenshot({ path: `${artifacts}/browser-${username}-failure.png`, fullPage: true }); + // Deliberately do not capture traces/HAR/storage: those contain session credentials. + throw new Error(`${username}: ${error.message}; page errors: ${JSON.stringify(errors)}`); + } finally { + await context.close(); + } + } +} finally { + await fs.writeFile(`${artifacts}/browser-report.json`, JSON.stringify(results, null, 2)); + await browser.close(); +} diff --git a/tests/host/run.py b/tests/host/run.py new file mode 100644 index 000000000..eee0d9731 --- /dev/null +++ b/tests/host/run.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Real Jellyfin smoke tests. Every resource belongs to this invocation.""" +import argparse +import json +import os +import ipaddress +import pathlib +import shutil +import signal +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +import uuid +import wave + +ROOT = pathlib.Path(__file__).resolve().parents[2] +IMAGES = { + "jf10": "jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db", + "jf12": "jellyfin/jellyfin:12.0@sha256:baba630419915985442f315f08b0cf46d9f4c8a0cc4bd38e94a6d35751dd5ef5", +} +PLUGIN_ID = "f69e946a-4b3c-4e9a-8f0a-8d7c1b2c4d9b" + + +def command(*args, **kwargs): + result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs) + if result.returncode: + raise RuntimeError(f"{args[0]} {args[1] if len(args) > 1 else ''} failed: {result.stdout.strip()}") + return result.stdout.strip() + + +class Host: + def __init__(self, url): + self.url = url + self.passed = [] + + def request(self, path, method="GET", body=None, token=None, expected=200): + headers = {"Authorization": 'MediaBrowser Client="JE regression", Device="isolated host", DeviceId="je-tests", Version="1.0"'} + if token: + headers["Authorization"] += f', Token="{token}"' + data = None if body is None else json.dumps(body).encode() + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(self.url + path, data=data, headers=headers, method=method) + try: + response = urllib.request.urlopen(req, timeout=30) + except urllib.error.HTTPError as error: + response = error + with response: + raw = response.read().decode() + assert response.status == expected, f"{method} {path}: expected {expected}, got {response.status}: {raw[:400]}" + if response.headers.get_content_type() == "application/json" and raw: + return json.loads(raw) + return raw + + def ready(self): + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + try: + self.request("/JellyfinEnhanced/version") + return + except (OSError, AssertionError): + time.sleep(0.5) + raise AssertionError("Jellyfin did not become ready within 180 seconds; see server.log") + + def check(self, label, action): + action() + self.passed.append(label) + print(f" PASS {label}", flush=True) + + +def run(target, artifacts, browser=False): + output = artifacts / target + output.mkdir(parents=True, exist_ok=True) + name = "je-regression-" + uuid.uuid4().hex[:12] + host = None + network_created = container_created = False + with tempfile.TemporaryDirectory(prefix=name) as temporary: + temp = pathlib.Path(temporary) + temp.chmod(0o755) + try: + print(f"Building {target}", flush=True) + result = subprocess.run(["dotnet", "build", str(ROOT / "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj"), + "-c", "Release", f"-p:JellyfinTarget={target}", "--artifacts-path", str(temp / "build")], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (output / "build.log").write_text(result.stdout) + assert result.returncode == 0, f"Build failed: {output / 'build.log'}" + dlls = list((temp / "build/bin").rglob("Jellyfin.Plugin.JellyfinEnhanced.dll")) + assert len(dlls) == 1, f"Expected one built plugin, found {dlls}" + plugin = temp / "config/plugins/JellyfinEnhanced" + plugin.mkdir(parents=True) + shutil.copyfile(dlls[0], plugin / dlls[0].name) + media = temp / "media" + media.mkdir() + with wave.open(str(media / "Regression Fixture.wav"), "wb") as fixture: + fixture.setnchannels(1) + fixture.setsampwidth(2) + fixture.setframerate(8000) + fixture.writeframes(b"\x00\x00" * 8000) + network_ids = command("docker", "network", "ls", "-q").splitlines() + existing = json.loads(command("docker", "network", "inspect", *network_ids)) + allocated = [ipaddress.ip_network(entry["Subnet"]) for network in existing + for entry in (network.get("IPAM", {}).get("Config") or []) if entry.get("Subnet")] + subnet = next(candidate for candidate in ipaddress.ip_network("10.253.0.0/16").subnets(new_prefix=28) + if not any(candidate.overlaps(used) for used in allocated if used.version == 4)) + command("docker", "network", "create", "--internal", "--subnet", str(subnet), name) + network_created = True + (temp / "cache").mkdir() + command("docker", "run", "-d", "--name", name, "--network", name, + "--user", f"{os.getuid()}:{os.getgid()}", + "--volume", f"{temp / 'cache'}:/cache", + "--volume", f"{temp / 'config'}:/config", + "--volume", f"{media}:/media:ro", "--env", "JELLYFIN_PublishedServerUrl=http://localhost", + IMAGES[target]) + container_created = True + inspection = json.loads(command("docker", "inspect", name))[0] + address = inspection["NetworkSettings"]["Networks"][name]["IPAddress"] + host = Host("http://" + address + ":8096") + host.ready() + password = "Regression-only-" + uuid.uuid4().hex + host.request("/Startup/Configuration", "POST", {"UICulture": "en-US", "MetadataCountryCode": "US", "PreferredMetadataLanguage": "en"}, expected=204) + host.request("/Startup/User") + host.request("/Startup/User", "POST", {"Name": "regression-admin", "Password": password}, expected=204) + host.request("/Startup/RemoteAccess", "POST", {"EnableRemoteAccess": False, "EnableAutomaticPortMapping": False}, expected=204) + host.request("/Startup/Complete", "POST", expected=204) + def login(username): + return host.request("/Users/AuthenticateByName", "POST", {"Username": username, "Pw": password}) + admin = login("regression-admin") + admin_token = admin["AccessToken"] + users = [] + for username in ("regression-regular", "regression-restricted"): + user = host.request("/Users/New", "POST", {"Name": username, "Password": password}, admin_token) + policy = user["Policy"] + policy.update({"IsAdministrator": False, "EnableRemoteAccess": False}) + if username.endswith("restricted"): + policy.update({"EnableAllFolders": False, "EnabledFolders": [], "MaxParentalRating": 1}) + host.request(f"/Users/{user['Id']}/Policy", "POST", policy, admin_token, expected=204) + users.append(login(username)) + regular, restricted = users + host.request("/Library/VirtualFolders?name=Regression&collectionType=music&refreshLibrary=true", "POST", + {"LibraryOptions": {"PathInfos": [{"Path": "/media"}], "EnableRealtimeMonitor": False, + "EnableInternetProviders": False, "SaveLocalMetadata": False}}, admin_token, expected=204) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + items = host.request("/Items?Recursive=true&IncludeItemTypes=Audio", token=admin_token)["Items"] + if items: + break + time.sleep(0.5) + assert len(items) == 1, "Synthetic audio fixture was not scanned" + item_id = items[0]["Id"] + def library_access(): + visible = host.request("/Items?Recursive=true&IncludeItemTypes=Audio", token=regular["AccessToken"])["Items"] + hidden = host.request("/Items?Recursive=true&IncludeItemTypes=Audio", token=restricted["AccessToken"])["Items"] + assert any(item["Id"] == item_id for item in visible), "Regular user lost fixture access" + assert not hidden, "Restricted user can enumerate a disabled library" + host.check("generated media scan and restricted library isolation", library_access) + prefix = "/JellyfinEnhanced" + plugin_config_route = f"/Plugins/{PLUGIN_ID}/Configuration" + configuration = host.request(plugin_config_route, token=admin_token) + configuration["JellyseerrApiKey"] = "regression-fake-secret-do-not-expose" + host.request(plugin_config_route, "POST", configuration, admin_token, expected=204) + def bootstrap_privacy(user): + payload = host.request(prefix + "/bootstrap", token=user["AccessToken"]) + normalized = {key.lower(): value for key, value in payload.items()} + assert normalized["userid"].replace("-", "") == user["User"]["Id"].replace("-", "") + assert normalized.get("privateconfig") is None + assert "regression-fake-secret-do-not-expose" not in json.dumps(payload) + assert normalized["componentscripts"], "Bootstrap must enumerate production scripts" + assert host.request(prefix + "/private-config", token=user["AccessToken"]) == {} + + def compatibility(): + info = host.request(prefix + "/host-compat", token=admin_token) + assert info["mismatch"] is False, info + assert info["builtFor"] == info["hostTarget"], info + host.check("plugin loads and matches host runtime", compatibility) + def web_injection(): + html = host.request("/web/index.html") + assert html.count("/JellyfinEnhanced/script") == 1, "Web index must inject the JE loader exactly once" + host.check("Jellyfin web index script injection", web_injection) + def asset(path): + value = host.request(prefix + path) + assert value, "Embedded resource is empty: " + path + for path in ("/script", "/js/component-scripts.json", "/locales/en.json", "/Configuration/configPage.css"): + host.check("embedded asset " + path, lambda path=path: asset(path)) + host.check("anonymous bootstrap denied", lambda: host.request(prefix + "/bootstrap", expected=401)) + for user in users: + token, uid = user["AccessToken"], user["User"]["Id"] + host.check("bootstrap " + user["User"]["Name"], lambda user=user: bootstrap_privacy(user)) + for path in ("/host-compat", "/admin/hidden-content-users", "/reviews/admin/all"): + host.check("non-admin denied " + user["User"]["Name"] + path, + lambda path=path, token=token: host.request(prefix + path, token=token, expected=403)) + for document in ("settings", "shortcuts", "elsewhere", "bookmark", "hidden-content", "spoilerblur"): + other = admin["User"]["Id"] + route = f"{prefix}/user-settings/{other}/{document}.json" + host.check("cross-user read denied " + user["User"]["Name"] + document, + lambda route=route, token=token: host.request(route, token=token, expected=403)) + host.check("cross-user write denied " + user["User"]["Name"] + document, + lambda route=route, token=token: host.request(route, "POST", {}, token, expected=403)) + route = f"{prefix}/user-settings/{regular['User']['Id']}/settings.json" + saved = host.request(route, token=regular["AccessToken"]) + # Production's JSON contract uses camelCase for user settings. + setting_key = next(key for key in saved if key.lower() == "autopauseenabled") + restricted_route = f"{prefix}/user-settings/{restricted['User']['Id']}/settings.json" + restricted_before = host.request(restricted_route, token=restricted["AccessToken"]) + saved[setting_key] = not saved[setting_key] + host.check("save own preferences", lambda: host.request(route, "POST", saved, regular["AccessToken"])) + def persisted(): + actual = host.request(route, token=regular["AccessToken"]) + assert actual[setting_key] == saved[setting_key] + other = host.request(restricted_route, token=restricted["AccessToken"]) + assert other == restricted_before, "Saving one user changed another user's preferences" + host.check("read back own preferences", persisted) + command("docker", "restart", name) + host.ready() + host.check("plugin reload after restart", compatibility) + host.check("preferences persist across restart", persisted) + host.check("restricted library policy persists across restart", library_access) + if browser: + result = subprocess.run(["node", str(ROOT / "tests/host/browser.mjs")], + input=json.dumps({"url": host.url, "password": password, "artifacts": str(output), + "users": ["regression-regular", "regression-admin"]}), + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=ROOT) + (output / "browser.log").write_text(result.stdout.replace(password, "[REDACTED]")) + assert result.returncode == 0, f"Real browser journey failed: {output / 'browser.log'}" + host.passed.append("real Chromium regular/admin login, panel and preference persistence") + print(result.stdout, flush=True) + (output / "report.json").write_text(json.dumps({"target": target, "image": IMAGES[target], "passed": host.passed, "status": "passed"}, indent=2)) + except BaseException as error: + (output / "report.json").write_text(json.dumps({"target": target, "image": IMAGES[target], "passed": host.passed if host else [], "status": "failed", "error": str(error)}, indent=2)) + raise + finally: + # Attempt all cleanup even if log collection or one removal fails. + cleanup_errors = [] + if container_created: + try: + logs = command("docker", "logs", name) + for value in locals().get("users", []) + ([locals()["admin"]] if "admin" in locals() else []): + logs = logs.replace(value["AccessToken"], "[REDACTED]") + if "password" in locals(): + logs = logs.replace(password, "[REDACTED]") + (output / "server.log").write_text(logs) + except Exception as error: + cleanup_errors.append(str(error)) + try: + command("docker", "rm", "-f", name) + except Exception as error: + cleanup_errors.append(str(error)) + if network_created: + try: + command("docker", "network", "rm", name) + except Exception as error: + cleanup_errors.append(str(error)) + if cleanup_errors: + report_path = output / "report.json" + report = json.loads(report_path.read_text()) if report_path.exists() else {"target": target} + report.update({"status": "failed", "cleanup_errors": cleanup_errors}) + report_path.write_text(json.dumps(report, indent=2)) + raise RuntimeError("Cleanup failed: " + "; ".join(cleanup_errors)) + + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=["all", *IMAGES], default="all") + parser.add_argument("--artifacts", type=pathlib.Path, default=ROOT / ".engineering-artifacts/host") + parser.add_argument("--browser", action="store_true", help="Run real Chromium login/settings journeys; requires npm ci and Playwright Chromium") + args = parser.parse_args() + def terminate(signum, frame): + raise KeyboardInterrupt("Termination requested; cleaning owned test resources") + signal.signal(signal.SIGTERM, terminate) + for binary in ("dotnet", "docker"): + if not shutil.which(binary): + parser.error(f"{binary} is required; install .NET 10 SDK and start Docker") + command("docker", "info", "--format", "{{.ServerVersion}}") + failures = [] + for target in IMAGES if args.target == "all" else [args.target]: + try: + run(target, args.artifacts.resolve(), args.browser) + except Exception as error: + print(f"FAIL {target}: {error}", flush=True) + failures.append(target) + return bool(failures) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/inventory/generate.py b/tests/inventory/generate.py new file mode 100644 index 000000000..61ed81938 --- /dev/null +++ b/tests/inventory/generate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Inventory production surfaces for coverage review; this is not a behavioral test. + +Extraction is deliberately conservative and lexical (not a C# parser). The output +retains paths/line numbers so reviewers can inspect overloads and attributes. +Run with --check in CI to detect an inventory needing regeneration. +""" +import argparse +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PRODUCTION = ROOT / 'Jellyfin.Plugin.JellyfinEnhanced' +OUTPUT = ROOT / 'tests/docs/production-inventory.json' + + +def line_number(text, offset): + return text.count('\n', 0, offset) + 1 + + +def inventory(): + project = ET.parse(PRODUCTION / 'JellyfinEnhanced.csproj') + targets = [] + for group in project.findall('PropertyGroup'): + selector = re.search(r"==\s*'([^']+)'", group.get('Condition', '')) + if selector and group.findtext('TargetFramework'): + targets.append({'selector': selector[1], 'framework': group.findtext('TargetFramework'), + 'jellyfin_reference': group.findtext('JellyfinVersion')}) + result = {'schema_version': 1, + 'extraction': 'Lexical inventory only; entries do not imply behavioral coverage. Public property types/defaults and route attributes require semantic review.', + 'build_targets': targets, + 'files': [], 'routes': [], 'configuration_properties': [], + 'storage_references': [], 'scheduled_tasks': [], 'locales': []} + for path in sorted(PRODUCTION.rglob('*')): + if not path.is_file() or any(part in ('obj', 'bin') for part in path.relative_to(PRODUCTION).parts): + continue + relative = path.relative_to(ROOT).as_posix() + if path.suffix not in ('.cs', '.js', '.html', '.css', '.json'): + continue + text = path.read_text(encoding='utf-8-sig') + category = path.relative_to(PRODUCTION).parts[0] + result['files'].append({'path': relative, 'category': category, 'lines': len(text.splitlines())}) + if category == 'Controllers' and path.suffix == '.cs': + for match in re.finditer(r'\[Http(Get|Post|Put|Delete|Patch|Head|Options)\("([^"\n]*)"\)\]', text): + tail = text[match.end():] + method = re.search(r'\bpublic\s+(?:async\s+)?[^\n]+?\s+(\w+)\s*\(', tail) + attributes = tail[:method.start()] if method else '' + result['routes'].append({'verb': match[1].upper(), 'path': '/JellyfinEnhanced/' + match[2], + 'method': method[1] if method else None, 'file': relative, + 'line': line_number(text, match.start()), + 'following_attributes': re.findall(r'\[([^\n]+)\]', attributes)}) + if category == 'Configuration' and path.suffix == '.cs': + classes = list(re.finditer(r'\bclass\s+(\w+)', text)) + for match in re.finditer(r'\bpublic\s+([\w<>?,.\[\] ]+?)\s+(\w+)\s*\{\s*get\s*;\s*(?:set|init)\s*;\s*\}(?:\s*=\s*([^;]+);)?', text): + preceding = [item for item in classes if item.start() < match.start()] + constructor = re.search(r'^\s*' + re.escape(match[2]) + r'\s*=\s*([^;\n]+);', text, re.M) + result['configuration_properties'].append({'class': preceding[-1][1] if preceding else None, + 'name': match[2], 'type': match[1].strip(), 'initializer': match[3], + 'constructor_assignment': constructor[1] if constructor else None, + 'file': relative, 'line': line_number(text, match.start())}) + if path.suffix in ('.cs', '.js'): + for match in re.finditer(r'''["']([^"'\n]{1,180}\.(?:json|xml|db|sqlite))["']''', text): + result['storage_references'].append({'literal': match[1], 'file': relative, 'line': line_number(text, match.start())}) + if category == 'ScheduledTasks' and re.search(r':\s*IScheduledTask', text): + result['scheduled_tasks'].append({'file': relative, 'class': path.stem, + 'key': next(iter(re.findall(r'\bKey\s*=>\s*"([^"]+)"', text)), None)}) + if 'js/locales/' in relative and path.suffix == '.json': + content = json.loads(text) + result['locales'].append({'file': relative, 'top_level_keys': len(content)}) + result['counts'] = {key: len(value) for key, value in result.items() if isinstance(value, list)} + return json.dumps(result, indent=2, ensure_ascii=False) + '\n' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--check', action='store_true', help='Fail if committed inventory is stale') + args = parser.parse_args() + generated = inventory() + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text(encoding='utf-8') != generated: + raise SystemExit('Inventory is stale: run python3 tests/inventory/generate.py') + print('Production inventory is current (this does not assert behavioral coverage).') + else: + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(generated, encoding='utf-8') + print(f'Wrote {OUTPUT.relative_to(ROOT)}') + + +if __name__ == '__main__': + main() diff --git a/tests/poster/README.md b/tests/poster/README.md new file mode 100644 index 000000000..ed0b43eb3 --- /dev/null +++ b/tests/poster/README.md @@ -0,0 +1,11 @@ +# Poster test harnesses + +```sh +python3 tests/run.py poster +``` + +- `rendering/`: native asset decoding, text shaping and renderer diagnostics against supported Skia lines. +- `pipeline/`: native image-tag, token, client-policy and cache contracts. +- `parity/`: actual JavaScript/C# resolver comparison with deterministic offline profiles. Generated inputs/results stay in its ignored `data/` directory. + +These are test-only projects outside the plugin's production directory. Routine backend poster tests live in `../backend/PosterTags/`. Keep captured images and reports in root `artifacts/`; do not embed test fixtures into the plugin. See [suite setup](../docs/README.md) for runtime and ICU requirements and [parity details](parity/README.md) for focused commands. diff --git a/scripts/poster-tags-parity/.gitignore b/tests/poster/parity/.gitignore similarity index 100% rename from scripts/poster-tags-parity/.gitignore rename to tests/poster/parity/.gitignore diff --git a/scripts/poster-tags-parity/PosterTagParity/PosterTagParity.csproj b/tests/poster/parity/PosterTagParity/PosterTagParity.csproj similarity index 84% rename from scripts/poster-tags-parity/PosterTagParity/PosterTagParity.csproj rename to tests/poster/parity/PosterTagParity/PosterTagParity.csproj index 114f62b82..2502164cc 100644 --- a/scripts/poster-tags-parity/PosterTagParity/PosterTagParity.csproj +++ b/tests/poster/parity/PosterTagParity/PosterTagParity.csproj @@ -15,7 +15,7 @@ - + diff --git a/scripts/poster-tags-parity/PosterTagParity/Program.cs b/tests/poster/parity/PosterTagParity/Program.cs similarity index 99% rename from scripts/poster-tags-parity/PosterTagParity/Program.cs rename to tests/poster/parity/PosterTagParity/Program.cs index 73a7e9c15..31dce34b3 100644 --- a/scripts/poster-tags-parity/PosterTagParity/Program.cs +++ b/tests/poster/parity/PosterTagParity/Program.cs @@ -366,7 +366,7 @@ int CheckNames() File.WriteAllLines(Path.Combine(dataDir, "out", "names-diff.txt"), mismatches); Console.WriteLine($"names: {expected.Count} codes, {mismatches.Count} mismatches"); foreach (var line in mismatches.Take(40)) Console.WriteLine(" " + line); - return 0; + return mismatches.Count == 0 ? 0 : 1; } // ── casing ────────────────────────────────────────────────────────────────── @@ -389,7 +389,7 @@ int CheckCasing() File.WriteAllLines(Path.Combine(dataDir, "out", "casing-diff.txt"), mismatches); Console.WriteLine($"casing: {expected.Count} strings, {mismatches.Count} mismatches"); foreach (var line in mismatches.Take(40)) Console.WriteLine(" " + line); - return 0; + return mismatches.Count == 0 ? 0 : 1; static string Hex(string s) => string.Join(' ', s.EnumerateRunes().Select(r => r.Value.ToString("X4", CultureInfo.InvariantCulture))); } diff --git a/tests/poster/parity/README.md b/tests/poster/parity/README.md new file mode 100644 index 000000000..825cd1e35 --- /dev/null +++ b/tests/poster/parity/README.md @@ -0,0 +1,7 @@ +# Poster resolver parity + +Run `node tests/poster/parity/run.mjs --offline` with Node 26.2.0 and .NET 10 installed. The language/casing oracle uses ICU 78.3, CLDR 48 and Unicode 17, matching the generated production tables; another ICU version can legitimately differ and must be reviewed rather than silently accepted. This generates disposable inputs under ignored `data/`, evaluates the actual JavaScript tag modules and the C# resolver, and compares their results across 39 synthetic setting profiles and 101 metadata fixtures. The additional empty synthetic administrator profile verifies the no-items case. No server or credentials are required. The run also compares language display names and Unicode casing. Any mismatch returns a nonzero exit code. + +Offline mode replaces `data/inputs`; do not use that directory to retain live-server captures. Existing live-server mode and `--skip-fetch` remain available. Offline parity tests resolution behavior in the existing sandbox, not browser pixels or native-client request handling. + +`python tests/runner/poster.py` additionally runs pipeline checks and embedded rendering asset checks against both supported Skia library lines. Artifacts default to `artifacts/regression/poster`. Native checks require Linux Skia/font dependencies and .NET 10 runtime compatible with the plugin's selected Jellyfin package. diff --git a/scripts/poster-tags-parity/casing-expected.mjs b/tests/poster/parity/casing-expected.mjs similarity index 100% rename from scripts/poster-tags-parity/casing-expected.mjs rename to tests/poster/parity/casing-expected.mjs diff --git a/scripts/poster-tags-parity/diff.mjs b/tests/poster/parity/diff.mjs similarity index 100% rename from scripts/poster-tags-parity/diff.mjs rename to tests/poster/parity/diff.mjs diff --git a/scripts/poster-tags-parity/fetch-inputs.mjs b/tests/poster/parity/fetch-inputs.mjs similarity index 100% rename from scripts/poster-tags-parity/fetch-inputs.mjs rename to tests/poster/parity/fetch-inputs.mjs diff --git a/scripts/poster-tags-parity/gen-resolution-data.mjs b/tests/poster/parity/gen-resolution-data.mjs similarity index 97% rename from scripts/poster-tags-parity/gen-resolution-data.mjs rename to tests/poster/parity/gen-resolution-data.mjs index bb105f3fe..3b7554e6e 100644 --- a/scripts/poster-tags-parity/gen-resolution-data.mjs +++ b/tests/poster/parity/gen-resolution-data.mjs @@ -4,8 +4,8 @@ // JavaScript full case mappings the native poster tag resolver needs to match // the web's tag logic without ICU. // -// node scripts/poster-tags-parity/gen-resolution-data.mjs # write -// node scripts/poster-tags-parity/gen-resolution-data.mjs --check # verify +// node tests/poster/parity/gen-resolution-data.mjs # write +// node tests/poster/parity/gen-resolution-data.mjs --check # verify // // The output depends on the ICU/CLDR data of the Node that runs it; the header // records the versions. --check fails when the committed file differs, which @@ -17,7 +17,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); -const target = join(here, '..', '..', 'Jellyfin.Plugin.JellyfinEnhanced', 'Services', 'PosterTags', 'Resolution', 'ResolutionData.g.cs'); +const target = join(here, '..', '..', '..', 'Jellyfin.Plugin.JellyfinEnhanced', 'Services', 'PosterTags', 'Resolution', 'ResolutionData.g.cs'); const languageNames = new Intl.DisplayNames(['en'], { type: 'language' }); @@ -250,7 +250,7 @@ ${lines.join(' +\n')}; } const header = `// -// Generated by scripts/poster-tags-parity/gen-resolution-data.mjs. Do not edit by hand. +// Generated by tests/poster/parity/gen-resolution-data.mjs. Do not edit by hand. // Source: Node ${process.versions.node}, ICU ${process.versions.icu}, CLDR ${process.versions.cldr}, Unicode ${process.versions.unicode}. // `; diff --git a/scripts/poster-tags-parity/names-expected.mjs b/tests/poster/parity/names-expected.mjs similarity index 100% rename from scripts/poster-tags-parity/names-expected.mjs rename to tests/poster/parity/names-expected.mjs diff --git a/tests/poster/parity/offline-inputs.mjs b/tests/poster/parity/offline-inputs.mjs new file mode 100644 index 000000000..09a689212 --- /dev/null +++ b/tests/poster/parity/offline-inputs.mjs @@ -0,0 +1,13 @@ +// Credential-free inputs. The profiles generator supplies the exhaustive synthetic metadata. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const inputs = join(dirname(fileURLToPath(import.meta.url)), 'data', 'inputs'); +mkdirSync(inputs, { recursive: true }); +const write = (name, value) => writeFileSync(join(inputs, name), JSON.stringify(value, null, 2)); +const id = '00000000000000000000000000000001'; +write('users.json', [{ id, name: 'synthetic-admin', isAdmin: true, audioPreference: null }]); +for (const kind of ['settings-raw', 'settings-web', 'tagcache', 'userdata', 'reviews']) write(`${kind}-${id}.json`, {}); +write('public-config.json', {}); +writeFileSync(join(inputs, 'plugin-config.xml'), ''); +console.log('Prepared disposable synthetic parity inputs; no Jellyfin server or credentials required.'); diff --git a/scripts/poster-tags-parity/profiles.mjs b/tests/poster/parity/profiles.mjs similarity index 100% rename from scripts/poster-tags-parity/profiles.mjs rename to tests/poster/parity/profiles.mjs diff --git a/scripts/poster-tags-parity/run.mjs b/tests/poster/parity/run.mjs similarity index 80% rename from scripts/poster-tags-parity/run.mjs rename to tests/poster/parity/run.mjs index 93fa6d51f..1bc1b8fb5 100644 --- a/scripts/poster-tags-parity/run.mjs +++ b/tests/poster/parity/run.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // Native poster tags parity run: real JS tag modules vs the C# PosterTagResolver. // -// node scripts/poster-tags-parity/run.mjs --server URL --admin NAME:PASSWORD --config-dir PATH [--user NAME:PASSWORD ...] -// node scripts/poster-tags-parity/run.mjs --skip-fetch (reuse data/inputs) +// node tests/poster/parity/run.mjs --server URL --admin NAME:PASSWORD --config-dir PATH [--user NAME:PASSWORD ...] +// node tests/poster/parity/run.mjs --skip-fetch (reuse data/inputs) // // Steps: fetch real inputs from a JE test server (read-only; see fetch-inputs.mjs // for --server/--admin/--user/--config-dir), build the profiles and synthetic @@ -10,7 +10,7 @@ // compute the web's tags (web-expected.mjs), run the C# resolver, compare // (diff.mjs), and check the language-name port against Intl (names) and the // string casing port against String.prototype (casing). -// Everything lands in scripts/poster-tags-parity/data/ (git-ignored). +// Everything lands in tests/poster/parity/data/ (git-ignored). import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,7 +19,8 @@ const here = dirname(fileURLToPath(import.meta.url)); const data = join(here, 'data'); const args = process.argv.slice(2); const skipFetch = args.includes('--skip-fetch'); -const passThrough = args.filter((a) => a !== '--skip-fetch'); +const offline = args.includes('--offline'); +const passThrough = args.filter((a) => a !== '--skip-fetch' && a !== '--offline'); // Credentials never reach the log: --admin/--user values print as NAME:***. const redact = (cmdArgs) => cmdArgs.map((a, i) => (i > 0 && (cmdArgs[i - 1] === '--admin' || cmdArgs[i - 1] === '--user') ? a.replace(/:[\s\S]*$/, ':***') : a)); @@ -34,7 +35,8 @@ const run = (cmd, cmdArgs, opts = {}) => { } }; -if (!skipFetch) run('node', ['fetch-inputs.mjs', ...passThrough]); +if (offline) run('node', ['offline-inputs.mjs']); +else if (!skipFetch) run('node', ['fetch-inputs.mjs', ...passThrough]); run('node', ['profiles.mjs']); run('dotnet', ['build', 'PosterTagParity/PosterTagParity.csproj', '-c', 'Release', '-nologo', '-v', 'q']); const harness = join(here, 'PosterTagParity', 'bin', 'Release', 'net10.0', 'PosterTagParity.dll'); diff --git a/scripts/poster-tags-parity/web-expected.mjs b/tests/poster/parity/web-expected.mjs similarity index 99% rename from scripts/poster-tags-parity/web-expected.mjs rename to tests/poster/parity/web-expected.mjs index 1c2a1e8db..7f5ebe2c1 100644 --- a/scripts/poster-tags-parity/web-expected.mjs +++ b/tests/poster/parity/web-expected.mjs @@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; const here = dirname(fileURLToPath(import.meta.url)); -const jsRoot = join(here, '..', '..', 'Jellyfin.Plugin.JellyfinEnhanced', 'js'); +const jsRoot = join(here, '..', '..', '..', 'Jellyfin.Plugin.JellyfinEnhanced', 'js'); const dataDir = join(here, 'data'); const outDir = join(dataDir, 'out'); mkdirSync(outDir, { recursive: true }); @@ -188,7 +188,7 @@ function createSandbox(profile, pluginConfig, webSettings, reviews) { MutationObserver: class { observe() {} disconnect() {} }, fetch: async () => { throw new Error('no network'); }, setTimeout, clearTimeout, setInterval, clearInterval, Promise, Intl, Map, Set, WeakSet, WeakMap, JSON, Math, Date, Object, Array, String, Number, RegExp, Error, - encodeURIComponent, decodeURIComponent, AbortController, + encodeURIComponent, decodeURIComponent, AbortController, URL, URLSearchParams, addEventListener() {}, removeEventListener() {}, }; diff --git a/scripts/poster-tags-pipeline-harness/.gitignore b/tests/poster/pipeline/.gitignore similarity index 100% rename from scripts/poster-tags-pipeline-harness/.gitignore rename to tests/poster/pipeline/.gitignore diff --git a/scripts/poster-tags-pipeline-harness/PosterTagsPipelineHarness.csproj b/tests/poster/pipeline/PosterTagsPipelineHarness.csproj similarity index 80% rename from scripts/poster-tags-pipeline-harness/PosterTagsPipelineHarness.csproj rename to tests/poster/pipeline/PosterTagsPipelineHarness.csproj index 877536a07..8d336dd7c 100644 --- a/scripts/poster-tags-pipeline-harness/PosterTagsPipelineHarness.csproj +++ b/tests/poster/pipeline/PosterTagsPipelineHarness.csproj @@ -10,10 +10,10 @@ --> Exe + false net10.0 enable enable - /tmp/pt-pipeline-jf12 @@ -21,7 +21,8 @@ - + + $(PluginDir)/Jellyfin.Plugin.JellyfinEnhanced.dll diff --git a/scripts/poster-tags-pipeline-harness/Program.cs b/tests/poster/pipeline/Program.cs similarity index 95% rename from scripts/poster-tags-pipeline-harness/Program.cs rename to tests/poster/pipeline/Program.cs index 0eea77576..8fafe16a5 100644 --- a/scripts/poster-tags-pipeline-harness/Program.cs +++ b/tests/poster/pipeline/Program.cs @@ -261,7 +261,6 @@ async Task CacheAsync() await flood.GetOrCreateAsync(i.ToString("x4") + new string('d', 60), ".jpg", () => Task.FromResult(CompositeImageCache.Passthrough), CancellationToken.None); } - await Task.Delay(200); Check("passthrough flood bounded in memory", flood.MemoryCount <= 3 * (1000 + entryOverhead) / entryOverhead && flood.MemoryBytes <= 3 * (1000 + entryOverhead), $"{flood.MemoryCount}/{flood.MemoryBytes}"); Check("1000 passthrough keys create no disk files", !Directory.Exists(floodDir) || Directory.GetFiles(floodDir, "*", SearchOption.AllDirectories).Length == 0); @@ -277,7 +276,6 @@ async Task SlowRender() } var waiters = Enumerable.Range(0, 32).Select(_ => coalesce.GetOrCreateAsync("same", ".jpg", SlowRender, CancellationToken.None)).ToArray(); - await Task.Delay(100); gate.SetResult(); var all = await Task.WhenAll(waiters); Check("32 concurrent misses -> 1 render, same bytes", calls == 1 && all.All(b => b.SequenceEqual(new byte[] { 1, 2, 3 })), $"calls={calls}"); @@ -305,14 +303,16 @@ async Task SlowRender2() // Failures propagate to every waiter and are NOT cached. var failCalls = 0; + var failureGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); async Task Failing() { Interlocked.Increment(ref failCalls); - await Task.Delay(20); + await failureGate.Task; throw new InvalidOperationException("boom"); } var failedWaiters = Enumerable.Range(0, 8).Select(_ => coalesce.GetOrCreateAsync("fails", ".jpg", Failing, CancellationToken.None)).ToArray(); + failureGate.SetResult(); var allFailed = 0; foreach (var w in failedWaiters) { @@ -320,7 +320,6 @@ async Task Failing() catch (InvalidOperationException) { allFailed++; } } - await Task.Delay(20); var retried = await coalesce.GetOrCreateAsync("fails", ".jpg", () => Task.FromResult(new byte[] { 7 }), CancellationToken.None); Check("failure reaches all waiters, one attempt", allFailed == 8 && failCalls == 1, $"failed={allFailed} calls={failCalls}"); Check("failure not cached: next request re-renders", retried.SequenceEqual(new byte[] { 7 })); @@ -329,24 +328,29 @@ async Task Failing() var bounded = CompositeImageCache.Create(null, 1 << 20, 0, 2, TimeSpan.FromSeconds(10), warnings.Add); var running = 0; var maxRunning = 0; + var twoStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var renderGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); async Task Tracked() { var now = Interlocked.Increment(ref running); int seen; while ((seen = Volatile.Read(ref maxRunning)) < now && Interlocked.CompareExchange(ref maxRunning, now, seen) != seen) { } - await Task.Delay(50); + if (now >= 2) twoStarted.TrySetResult(); + await renderGate.Task; Interlocked.Decrement(ref running); return new byte[] { 1 }; } - await Task.WhenAll(Enumerable.Range(0, 12).Select(i => bounded.GetOrCreateAsync("k" + i, ".jpg", Tracked, CancellationToken.None))); - Check("at most N renders at once", maxRunning <= 2 && maxRunning >= 1, $"max={maxRunning}"); + var boundedTasks = Enumerable.Range(0, 12).Select(i => bounded.GetOrCreateAsync("k" + i, ".jpg", Tracked, CancellationToken.None)).ToArray(); + try { await twoStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); } + finally { renderGate.TrySetResult(); } + await Task.WhenAll(boundedTasks); + Check("at most N renders at once", maxRunning == 2, $"max={maxRunning}"); // A render slot that never frees up times out (caller then serves the original). var starved = CompositeImageCache.Create(null, 1 << 20, 0, 1, TimeSpan.FromMilliseconds(100), warnings.Add); var hold = new TaskCompletionSource(); var holder = starved.GetOrCreateAsync("holder", ".jpg", async () => { await hold.Task; return new byte[] { 1 }; }, CancellationToken.None); - await Task.Delay(20); var timedOut = false; try { await starved.GetOrCreateAsync("starved", ".jpg", () => Task.FromResult(new byte[] { 2 }), CancellationToken.None); } catch (TimeoutException) { timedOut = true; } @@ -362,7 +366,7 @@ async Task Tracked() var keyP = new string('b', 64); await disk1.GetOrCreateAsync(keyA, ".png", () => Task.FromResult(new byte[] { 5, 6, 7 }), CancellationToken.None); await disk1.GetOrCreateAsync(keyP, ".png", () => Task.FromResult(CompositeImageCache.Passthrough), CancellationToken.None); - await Task.Delay(300); + await WaitUntil(() => File.Exists(Path.Combine(diskDir, "aa", keyA + ".png"))); Check("disk file written under {k[0..2]}/{k}{ext}", File.Exists(Path.Combine(diskDir, "aa", keyA + ".png"))); Check("passthrough not written to disk", !File.Exists(Path.Combine(diskDir, "bb", keyP + ".png"))); var disk2 = CompositeImageCache.Create(diskDir, 1 << 20, 1 << 20, 2, TimeSpan.FromSeconds(5), warnings.Add); @@ -391,10 +395,10 @@ async Task Tracked() { var k = i.ToString("x2") + new string('c', 62); await capped.GetOrCreateAsync(k, ".jpg", () => Task.FromResult(new byte[1000]), CancellationToken.None); - await Task.Delay(15); + await WaitUntil(() => File.Exists(Path.Combine(capDir, k[..2], k + ".jpg"))); } - await Task.Delay(800); + await WaitUntil(() => Directory.GetFiles(capDir, "*.jpg", SearchOption.AllDirectories).Length <= 12); var remainingFiles = Directory.GetFiles(capDir, "*.jpg", SearchOption.AllDirectories).Length; Check("disk tier trimmed under its cap (per-file charge)", remainingFiles <= 12 && remainingFiles >= 1, $"{remainingFiles} files"); Check("trim kept the newest file", File.Exists(Path.Combine(capDir, "1d", "1d" + new string('c', 62) + ".jpg"))); @@ -475,3 +479,14 @@ async Task FileSettleAsync() var cancelled = await TimeWait(aborted.Token); Check("settle: request abort cancels the wait", cancelled.Error is OperationCanceledException && cancelled.Elapsed < TimeSpan.FromMilliseconds(500), $"{cancelled.Elapsed} {cancelled.Error?.GetType().Name}"); } + +// Bounded condition polling for asynchronous disk writes; no success is inferred from elapsed time. +static async Task WaitUntil(Func condition) +{ + var deadline = Stopwatch.StartNew(); + while (!condition()) + { + if (deadline.Elapsed > TimeSpan.FromSeconds(10)) throw new TimeoutException("Poster pipeline disk operation did not settle"); + await Task.Delay(10); + } +} diff --git a/scripts/poster-tags-pipeline-harness/README.txt b/tests/poster/pipeline/README.txt similarity index 77% rename from scripts/poster-tags-pipeline-harness/README.txt rename to tests/poster/pipeline/README.txt index afa0930fe..08991716e 100644 --- a/scripts/poster-tags-pipeline-harness/README.txt +++ b/tests/poster/pipeline/README.txt @@ -1,7 +1,9 @@ Native Poster Tags pipeline checks (not part of the plugin build). - dotnet build Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj -c Release -p:JellyfinTarget=jf12 -o /tmp/pt-pipeline-jf12 - dotnet run --project scripts/poster-tags-pipeline-harness -p:PluginDir=/tmp/pt-pipeline-jf12 + dotnet run --project tests/poster/pipeline -c Release + +The default builds the production project directly. To test a separately built +plugin DLL, pass -p:PluginDir=/absolute/path/to/plugin-output. (If the installed ASP.NET Core runtime doesn't match the installed .NET runtime, add "-r linux-x64 --self-contained true" to the run command.) diff --git a/scripts/poster-tags-harness/Directory.Build.props b/tests/poster/rendering/Directory.Build.props similarity index 100% rename from scripts/poster-tags-harness/Directory.Build.props rename to tests/poster/rendering/Directory.Build.props diff --git a/scripts/poster-tags-harness/PosterTagsHarness.csproj b/tests/poster/rendering/PosterTagsHarness.csproj similarity index 94% rename from scripts/poster-tags-harness/PosterTagsHarness.csproj rename to tests/poster/rendering/PosterTagsHarness.csproj index 482e652d6..6d2a530e6 100644 --- a/scripts/poster-tags-harness/PosterTagsHarness.csproj +++ b/tests/poster/rendering/PosterTagsHarness.csproj @@ -30,7 +30,7 @@ 8.3.1.1 - + diff --git a/scripts/poster-tags-harness/Program.cs b/tests/poster/rendering/Program.cs similarity index 100% rename from scripts/poster-tags-harness/Program.cs rename to tests/poster/rendering/Program.cs diff --git a/tests/run.py b/tests/run.py new file mode 100644 index 000000000..95d17fca6 --- /dev/null +++ b/tests/run.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Run JE regression suites with explicit prerequisites and machine-readable outcomes.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("suite", choices=["fast", "tooling", "backend", "frontend", "browser", "visual", "host", "poster", "mutation", "history", "all"], nargs="?", default="fast") + parser.add_argument("--target", choices=["jf10", "jf12", "all"], default="all") + parser.add_argument("--artifacts", type=Path, default=ROOT / "artifacts" / "regression") + args = parser.parse_args() + suites = {"fast": ["tooling", "backend", "frontend"], "all": ["tooling", "backend", "frontend", "browser", "poster", "host", "mutation", "history"]}.get(args.suite, [args.suite]) + artifacts = args.artifacts.resolve() + artifacts.mkdir(parents=True, exist_ok=True) + results = [] + (artifacts / "summary.json").write_text("[]\n") + targets = ["jf10", "jf12"] if args.target == "all" else [args.target] + + def execute(name, command): + print("\nRunning " + name + ": " + " ".join(map(str, command)), flush=True) + started = time.monotonic() + code = 127 + log = artifacts / (name + ".log") + with log.open("w") as output: + try: + process = subprocess.Popen(list(map(str, command)), cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + try: + for line in process.stdout: + output.write(line) + print(line, end="", flush=True) + code = process.wait() + except KeyboardInterrupt: + # Forward cancellation to this suite's process tree so its + # own teardown can remove containers/browser fixtures. + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + code = 130 + except OSError as error: + output.write(str(error)) + print(error, file=sys.stderr) + results.append({"name": name, "command": list(map(str, command)), "status": "passed" if code == 0 else "failed", "exit_code": code, "seconds": round(time.monotonic() - started, 2), "log": str(log)}) + (artifacts / "summary.json").write_text(json.dumps(results, indent=2) + "\n") + if code == 130: + raise SystemExit(130) + return code == 0 + + def prerequisite_error(message): + results.append({"name": "prerequisites", "status": "failed", "exit_code": 127, "error": message}) + (artifacts / "summary.json").write_text(json.dumps(results, indent=2) + "\n") + parser.error(message) + + required = set() + if "tooling" in suites: + required.add("node") + if any(s in suites for s in ["backend", "host", "poster", "mutation", "history"]): + required.add("dotnet") + if any(s in suites for s in ["frontend", "browser", "visual", "poster", "mutation", "history", "host"]): + required.update(["node", "npm"]) + if "host" in suites: + required.add("docker") + missing = sorted(tool for tool in required if not shutil.which(tool)) + if missing: + prerequisite_error("Missing prerequisites: " + ", ".join(missing) + ". See tests/docs/README.md.") + if "npm" in required and not (ROOT / "node_modules").exists(): + prerequisite_error("Missing JavaScript dependencies. Run npm ci first.") + + if args.suite in ["fast", "all"]: + execute("inventory", [sys.executable, "tests/inventory/generate.py", "--check"]) + for suite in suites: + if suite == "tooling": + execute("tooling", [sys.executable, "-m", "unittest", "discover", "-s", "tests/runner/tests", "-v"]) + elif suite == "backend": + for target in targets: + results_path = artifacts / "backend" / target + if results_path.exists(): + shutil.rmtree(results_path) + if execute("backend-" + target, ["dotnet", "test", "tests/backend/JE.Tests.csproj", "-c", "Release", "-p:JellyfinTarget=" + target, "-p:RestoreLockedMode=true", "--artifacts-path", artifacts / "build" / target, "--results-directory", results_path, "--logger", "trx", "--collect:XPlat Code Coverage"]): + execute("coverage-" + target, [sys.executable, "tests/runner/coverage.py", results_path]) + elif suite == "frontend": + execute("frontend", ["npm", "run", "test:frontend:coverage"]) + elif suite in ["browser", "visual"]: + execute(suite, ["npm", "run", "test:" + suite]) + elif suite == "host": + execute("host", [sys.executable, "tests/host/run.py", "--target", args.target, "--browser", "--artifacts", artifacts / "host"]) + elif suite == "poster": + execute("poster", [sys.executable, "tests/runner/poster.py", "--artifacts", artifacts / "poster"]) + elif suite == "mutation": + for target in targets: + execute("mutation-" + target, [sys.executable, "tests/runner/mutations.py", "--target", target, "--artifacts", artifacts / "mutations" / target]) + elif suite == "history": + for group in ("early", "middle", "recent"): + execute("history-" + group, [sys.executable, "tests/runner/mutations.py", "--cases", "tests/history/" + group + "-mutations.json", "--layer", "frontend", "--artifacts", artifacts / "history" / group]) + for target in targets: + execute("history-backend-" + target, [sys.executable, "tests/runner/mutations.py", "--cases", "tests/history/backend-mutations.json", "--target", target, "--artifacts", artifacts / "history" / target]) + print("\n" + "\n".join(result["name"] + ": " + result["status"] for result in results)) + return 1 if any(result["status"] != "passed" for result in results) else 0 + + +if __name__ == "__main__": + def cancel(_signal, _frame): + raise KeyboardInterrupt() + signal.signal(signal.SIGTERM, cancel) + sys.exit(main()) diff --git a/tests/runner/clean.py b/tests/runner/clean.py new file mode 100644 index 000000000..d31b6f35e --- /dev/null +++ b/tests/runner/clean.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Validate local, uncommitted changes in a fresh disposable source tree.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--suite', choices=['fast', 'all'], default='all') + parser.add_argument('--target', choices=['jf10', 'jf12', 'all'], default='all') + parser.add_argument('--artifacts', type=Path, default=ROOT / 'artifacts' / 'clean') + args = parser.parse_args() + output = args.artifacts.resolve() + output.mkdir(parents=True, exist_ok=True) + files = subprocess.check_output(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd=ROOT).decode().split('\0') + manifest = {} + result = {'status': 'failed', 'suite': args.suite, 'target': args.target} + with tempfile.TemporaryDirectory(prefix='je-clean-') as temp: + work = Path(temp) / 'source' + work.mkdir() + for relative in sorted(set(files) - {''}): + source = ROOT / relative + if not source.exists(): + continue # A local tracked deletion belongs to this snapshot too. + destination = work / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if source.is_symlink(): + destination.symlink_to(os.readlink(source)) + else: + shutil.copy2(source, destination) + manifest[relative] = hashlib.sha256(source.read_bytes()).hexdigest() + (output / 'source-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + try: + with (output / 'install.log').open('w') as log: + subprocess.run(['npm', 'ci'], cwd=work, check=True, stdout=log, stderr=subprocess.STDOUT) + with (output / 'run.log').open('w') as log: + completed = subprocess.run([sys.executable, 'tests/run.py', args.suite, '--target', args.target, + '--artifacts', str(output / 'regression')], cwd=work, stdout=log, stderr=subprocess.STDOUT) + result.update(status='passed' if completed.returncode == 0 else 'failed', exit_code=completed.returncode) + except (OSError, subprocess.CalledProcessError) as error: + result['error'] = str(error) + finally: + for name in ('frontend-coverage', 'browser'): + source = work / 'artifacts' / name + if source.exists(): + shutil.copytree(source, output / name, dirs_exist_ok=True) + (output / 'summary.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + print(f'Fresh-tree reports: {output}') + return 0 if result['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/runner/coverage.py b/tests/runner/coverage.py new file mode 100644 index 000000000..0c252203f --- /dev/null +++ b/tests/runner/coverage.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Enforce conservative measured backend floors without hiding untested code.""" +import argparse +import hashlib +import json +from pathlib import Path +import xml.etree.ElementTree as ET + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('results', type=Path) + args = parser.parse_args() + # VSTest copies attachments into the TRX results directory as well as the + # collector's GUID directory. Identical copies describe the same run. + unique = {} + for report in args.results.rglob('coverage.cobertura.xml'): + unique.setdefault(hashlib.sha256(report.read_bytes()).hexdigest(), report) + reports = list(unique.values()) + if len(reports) != 1: + parser.error(f'Expected exactly one fresh coverage report, found {len(reports)} in {args.results}') + root = ET.parse(reports[0]).getroot() + package = root.find("./packages/package[@name='Jellyfin.Plugin.JellyfinEnhanced']") + if package is None: + parser.error('Production plugin is missing from coverage report') + measured = {kind: round(float(package.attrib[kind + '-rate']) * 100, 4) for kind in ('line', 'branch')} + # The integrated baseline exceeded 41% lines / 29% branches. Leave a small + # margin across build targets; review and raise floors as coverage improves. + floors = {'line': 38.0, 'branch': 27.0} + report = {'measured_percent': measured, 'minimum_percent': floors, + 'source': str(reports[0]), 'exclusions': [], + 'passed': all(measured[kind] >= floor for kind, floor in floors.items())} + (args.results / 'coverage-summary.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + return 0 if report['passed'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/runner/mutations.py b/tests/runner/mutations.py new file mode 100644 index 000000000..c73ac2498 --- /dev/null +++ b/tests/runner/mutations.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Prove selected regression assertions detect faults in an isolated source copy.""" +import argparse +import json +import os +import re +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +PLUGIN = 'Jellyfin.Plugin.JellyfinEnhanced/' +CASES = [ + dict(name='explicit-user-authorization', layer='backend', file=PLUGIN+'Helpers/UserHelper.cs', + before='if (isAdministrator || (!currentUserId.IsNullOrEmpty() && userId.Equals(currentUserId)))', + after='if (true)', test='ExplicitUserSelectionRequiresSelfOrAdministrator', assertion='Assert.Null'), + dict(name='blocked-tags-precedence', layer='backend', file=PLUGIN+'Helpers/Jellyseerr/ParentalTagDecision.cs', + before='return false; // blocked wins, even over an allowed match', + after='return true; // MUTATION: allow a blocked title', + test='TagsUseWholeValuesBlockedWinsAndGenresCannotGrantAccess', assertion='Assert.Equal'), + dict(name='cookie-negative-cache', layer='backend', file=PLUGIN+'Services/Identity/RequestIdentityService.cs', + before='if (!recentMiss)', after='if (true)', + test='RepeatedForgedCookieDoesNotCauseSessionScanStorm', assertion='Expected invocation'), + dict(name='stale-user-hidden-response', layer='frontend', file=PLUGIN+'js/enhanced/hiddencontent/hidden-content-data.js', + before='if (JE.session && !JE.session.isCurrent(requestEpoch)) return false;', + after='/* MUTATION: accept stale user response */', + test='old-user hidden-content refresh cannot overwrite incoming preferences', assertion='ERR_ASSERTION'), +] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--target', choices=['jf10', 'jf12'], default='jf12') + parser.add_argument('--layer', choices=['all', 'backend', 'frontend'], default='all') + parser.add_argument('--cases', type=Path, help='Local JSON case list, e.g. tests/history/backend-mutations.json') + parser.add_argument('--artifacts', type=Path, default=ROOT/'artifacts/mutations') + args = parser.parse_args() + definitions = json.loads(args.cases.read_text()) if args.cases else CASES + if not isinstance(definitions, list) or not definitions: + parser.error('Mutation cases must be a nonempty JSON list.') + names = set() + for case in definitions: + required = ('name', 'layer', 'file', 'before', 'after', 'test', 'assertion') + if not isinstance(case, dict) or any(not isinstance(case.get(key), str) for key in required): + parser.error('Each mutation must contain string fields: '+', '.join(required)) + if not re.fullmatch(r'[a-z0-9-]+', case['name']) or case['name'] in names: + parser.error('Mutation names must be unique lowercase slugs.') + names.add(case['name']) + if case['layer'] not in ('backend', 'frontend') or not case['before'] or not case['test'] or not case['assertion']: + parser.error('Mutation layer, anchor, selected test and failure signature are required.') + for key in ('file', 'test_file'): + if key in case: + value = case[key] + if (not isinstance(value, str) or Path(value).is_absolute() or '..' in Path(value).parts + or not (ROOT/value).resolve().is_relative_to(ROOT)): + parser.error('Mutation paths must be relative and stay inside the checkout.') + cases = [c for c in definitions if args.layer in ('all', c['layer'])] + if not cases: + parser.error('No mutation cases selected for this layer.') + for tool in {'dotnet' if c['layer'] == 'backend' else 'node' for c in cases}: + if not shutil.which(tool): + parser.error(f'Missing {tool}; see tests/docs/mutations.md') + if any(c['layer'] == 'frontend' for c in cases) and not (ROOT/'node_modules').is_dir(): + parser.error('Run npm ci before frontend mutation verification.') + artifacts = args.artifacts.resolve() + artifacts.mkdir(parents=True, exist_ok=True) + report = [] + # Copy current local files, including uncommitted tests. Never edit the shared tree. + with tempfile.TemporaryDirectory(prefix='je-mutations-') as temp: + work = Path(temp)/'source' + shutil.copytree(ROOT, work, ignore=shutil.ignore_patterns('.git', 'node_modules', 'artifacts', 'bin', 'obj', '__pycache__', 'test-results', 'playwright-report')) + if (ROOT/'node_modules').exists(): + (work/'node_modules').symlink_to(ROOT/'node_modules', target_is_directory=True) + + def run(case, stage): + log = artifacts/f"{case['name']}-{stage}.log" + trx = artifacts/f"{case['name']}-{stage}.trx" + trx.unlink(missing_ok=True) + if case['layer'] == 'backend': + command = ['dotnet', 'test', 'tests/backend/JE.Tests.csproj', '-c', 'Release', + '-p:JellyfinTarget='+args.target, '--filter', 'FullyQualifiedName~'+case['test'], + '--results-directory', str(artifacts), '--logger', 'trx;LogFileName='+trx.name] + else: + command = ['node', '--test', '--test-reporter=tap', '--test-name-pattern='+case['test'], case.get('test_file', 'tests/frontend/features/features-hidden.test.mjs')] + started = time.monotonic() + env = dict(os.environ) + env.pop('JE_COVERAGE_DIR', None) + with log.open('w') as output: + process = subprocess.run(command, cwd=work, env=env, stdout=output, stderr=subprocess.STDOUT, timeout=600) + result = dict(stage=stage, command=command, exit_code=process.returncode, seconds=round(time.monotonic()-started, 2), log=str(log)) + if case['layer'] == 'backend': + rows = ET.parse(trx).getroot().findall('.//{*}UnitTestResult') if trx.exists() else [] + selected = [row for row in rows if case['test'] in row.attrib.get('testName', '')] + failed = [row for row in selected if row.attrib.get('outcome') == 'Failed'] + result['test_count'] = len(selected) + result['assertion_detected'] = any(case['assertion'] in ''.join(row.itertext()) for row in failed) + result['tests_passed'] = bool(selected) and all(row.attrib.get('outcome') == 'Passed' for row in selected) + else: + content = log.read_text() + result['assertion_detected'] = bool(re.search(r'^not ok \d+ - '+re.escape(case['test'])+r'$', content, re.MULTILINE)) and case['assertion'] in content + result['tests_passed'] = bool(re.search(r'^ok \d+ - '+re.escape(case['test'])+r'$', content, re.MULTILINE)) and '# fail 0' in content + print(f"{case['name']} {stage}: exit {process.returncode}", flush=True) + return result + + for case in cases: + item = dict(name=case['name'], target=args.target if case['layer']=='backend' else 'node', source=case['file'], test=case['test'], status='failed', runs=[]) + for key in ('sources', 'evidence'): + if key in case: + item[key] = case[key] + report.append(item) + source = work/case['file'] + if not source.resolve().is_relative_to(work): + raise RuntimeError('Mutation source escaped the disposable checkout.') + test_file = work/case.get('test_file', 'tests/frontend/features/features-hidden.test.mjs') + if not test_file.resolve().is_relative_to(work): + raise RuntimeError('Test file escaped the disposable checkout.') + original = source.read_text() + try: + if original.count(case['before']) != 1: + raise RuntimeError('Mutation anchor must match exactly once; update this case for the current production code.') + baseline = run(case, 'baseline') + item['runs'].append(baseline) + if baseline['exit_code'] != 0 or not baseline['tests_passed']: + raise RuntimeError('Baseline did not pass the selected test; mutation was not applied.') + source.write_text(original.replace(case['before'], case['after'], 1)) + mutant = run(case, 'mutant') + item['runs'].append(mutant) + source.write_text(original) + restored = run(case, 'restored') + item['runs'].append(restored) + if mutant['exit_code'] == 0 or not mutant['assertion_detected']: + raise RuntimeError('Fault survived or failed without the expected test assertion (build failures do not count).') + if restored['exit_code'] != 0 or not restored['tests_passed']: + raise RuntimeError('Restored production source did not pass.') + item['status'] = 'killed-and-restored' + except Exception as error: + item['error'] = str(error) + finally: + source.write_text(original) + (artifacts/'summary.json').write_text(json.dumps(report, indent=2)+'\n') + print(json.dumps([{k:v for k,v in item.items() if k!='runs'} for item in report], indent=2)) + return 0 if all(item['status']=='killed-and-restored' for item in report) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/runner/poster.py b/tests/runner/poster.py new file mode 100644 index 000000000..d35598da1 --- /dev/null +++ b/tests/runner/poster.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Run real native rendering, pipeline, and JS/C# resolver parity without a server.""" +import argparse +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--artifacts', type=Path, default=ROOT / 'artifacts/regression/poster') + args = parser.parse_args() + artifacts = args.artifacts.resolve() + artifacts.mkdir(parents=True, exist_ok=True) + for tool in ('dotnet', 'node'): + if not shutil.which(tool): + parser.error(f'Missing {tool}; see tests/docs/README.md') + commands = [ + ['dotnet', 'run', '--project', 'tests/poster/pipeline', '-c', 'Release'], + ['node', 'tests/poster/parity/run.mjs', '--offline'], + ] + for skia in ('3.116.1', '3.119.4'): + commands.append(['dotnet', 'run', '--project', 'tests/poster/rendering', '-c', 'Release', f'-p:Skia={skia}', '--', 'assets', '--out', str(artifacts / skia)]) + failed = False + for index, command in enumerate(commands): + print('$ ' + ' '.join(command), flush=True) + with (artifacts / f'{index:02d}.log').open('w') as log: + process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in process.stdout: + print(line, end='', flush=True) + log.write(line) + failed |= process.wait() != 0 + parity = ROOT / 'tests/poster/parity/data/out' + if parity.exists(): + shutil.copytree(parity, artifacts / 'parity', dirs_exist_ok=True) + return int(failed) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/runner/tests/test_regression.py b/tests/runner/tests/test_regression.py new file mode 100644 index 000000000..b08ab4892 --- /dev/null +++ b/tests/runner/tests/test_regression.py @@ -0,0 +1,98 @@ +"""Protect regression tooling against false-green reports.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] + + +class RegressionToolingTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.output = Path(self.temporary.name) + + def coverage(self, folder, lines=0.5, branches=0.4, plugin=True): + path = self.output / folder / 'coverage.cobertura.xml' + path.parent.mkdir(parents=True, exist_ok=True) + name = 'Jellyfin.Plugin.JellyfinEnhanced' if plugin else 'Unrelated.Assembly' + path.write_text(f'') + + def check_coverage(self): + return subprocess.run([sys.executable, str(ROOT / 'tests/runner/coverage.py'), str(self.output)], capture_output=True, text=True) + + def test_vstest_duplicate_attachment_is_one_report(self): + self.coverage('collector') + self.coverage('trx/In/host') + self.assertEqual(0, self.check_coverage().returncode) + self.assertTrue(json.loads((self.output / 'coverage-summary.json').read_text())['passed']) + + def test_distinct_runs_must_not_select_a_stale_passing_report(self): + self.coverage('old', lines=0.9) + self.coverage('new', lines=0.01) + self.assertNotEqual(0, self.check_coverage().returncode) + + def test_low_coverage_fails_even_with_a_valid_report(self): + self.coverage('collector', branches=0.01) + self.assertEqual(1, self.check_coverage().returncode) + self.assertFalse(json.loads((self.output / 'coverage-summary.json').read_text())['passed']) + + def test_missing_plugin_or_report_fails(self): + self.assertNotEqual(0, self.check_coverage().returncode) + self.coverage('collector', plugin=False) + self.assertNotEqual(0, self.check_coverage().returncode) + + def test_failed_prerequisites_replace_previous_passing_summary(self): + summary = self.output / 'summary.json' + summary.write_text('[{"status":"passed"}]') + env = {**os.environ, 'PATH': str(self.output / 'no-tools')} + result = subprocess.run([sys.executable, str(ROOT / 'tests/run.py'), 'frontend', '--artifacts', str(self.output)], env=env, capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertIn('Missing prerequisites', result.stderr) + self.assertEqual('failed', json.loads(summary.read_text())[0]['status']) + + def test_mutation_manifest_cannot_target_original_checkout(self): + original = ROOT / 'Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativeClientPolicy.cs' + before = original.read_bytes() + for path in (str(original), '../outside.cs'): + with self.subTest(path=path): + manifest = self.output / 'cases.json' + manifest.write_text(json.dumps([dict(name='escape', layer='backend', file=path, + before='Jellium Desktop', after='changed', test='Example', assertion='Assert.Equal')])) + result = subprocess.run([sys.executable, str(ROOT / 'tests/runner/mutations.py'), + '--cases', str(manifest)], capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertIn('paths must be relative', result.stderr) + self.assertEqual(before, original.read_bytes()) + + def test_empty_mutation_selection_cannot_report_success(self): + manifest = self.output / 'cases.json' + manifest.write_text('[]') + result = subprocess.run([sys.executable, str(ROOT / 'tests/runner/mutations.py'), + '--cases', str(manifest)], capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertIn('nonempty JSON list', result.stderr) + + def test_frontend_discovery_includes_nested_specs_and_rejects_empty_suite(self): + discovery = (ROOT / 'tests/frontend/helpers/discovery.mjs').as_uri() + command = ['node', '--input-type=module', '-e', + f'import {{discoverTests}} from {json.dumps(discovery)}; console.log(JSON.stringify(discoverTests(process.argv[1])));', str(self.output)] + empty = subprocess.run(command, capture_output=True, text=True) + self.assertNotEqual(0, empty.returncode) + self.assertIn('No frontend tests discovered', empty.stderr) + nested = self.output / 'feature' / 'nested' + nested.mkdir(parents=True) + spec = nested / 'behavior.test.mjs' + spec.write_text('// discovery fixture; never executed') + (nested / 'helper.mjs').write_text('// not a test') + found = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(0, found.returncode, found.stderr) + self.assertEqual([str(spec)], json.loads(found.stdout)) + + +if __name__ == '__main__': + unittest.main() From 28849e18b7f9ea3f8fa507f474604bc605e86308 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:22:51 +0800 Subject: [PATCH 002/111] fix(poster-tags): never replace a token secret another instance already created Two instances sharing the config dir could both see no secret file. The second write passed overwrite: File.Exists(path), so it replaced the secret the first had already handed out, invalidating its poster URLs. File.Move without overwrite would not have closed the gap either: on Unix it checks the destination and then renames. Secret creation is now serialised through an exclusive lock file and the secret is re-read under the lock, so a valid secret is never replaced and only a corrupt one is. The concurrency test is deterministic: a test hook forces the loser to see the winner's file appear, and a barrier makes every instance pass the existence check before any of them writes. --- .../PosterTags/PosterTagVariantToken.cs | 91 +++++++++++++------ tests/backend/PosterTags/PosterTokenTests.cs | 26 +++++- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs index fa3bdb7aa..1602797b0 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs @@ -2,6 +2,7 @@ using System.IO; using System.Security.Cryptography; using System.Text; +using System.Threading; using MediaBrowser.Common.Configuration; namespace Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags @@ -54,8 +55,9 @@ public readonly record struct PosterTagVariant(PosterTagVariantFlags Flags, stri // client refreshes metadata (no stale composites under a "current" URL). // // The secret is 32 random bytes in the plugin's data directory, created - // atomically with owner-only permissions on first use. Losing it only - // resets poster URLs; it grants nothing else. + // atomically with owner-only permissions on first use (instances sharing + // the directory serialize creation through ".lock"). Losing it + // only resets poster URLs; it grants nothing else. public sealed class PosterTagVariantToken { /// Data hash used when the tag data is not pinned (weak token). @@ -84,6 +86,9 @@ private PosterTagVariantToken(string secretPath, Action warn) _warn = warn; } + /// Runs after the first existence check, before the create lock (lets tests force a create race). + internal Action? BeforeSecretWriteForTest { get; set; } + /// An instance keeping its secret at (test harness). public static PosterTagVariantToken CreateForPath(string secretPath, Action warn) => new(secretPath, warn); @@ -164,24 +169,30 @@ private byte[] LoadOrCreateSecret() { try { - if (File.Exists(_secretPath)) + var existing = TryReadSecret(); + if (existing != null) return existing; + + BeforeSecretWriteForTest?.Invoke(); + // Creating is serialized through an exclusive lock file, and the + // secret is re-read under it: another JE instance sharing the config + // dir may have created it since the check above and already minted + // URLs with it, so a valid secret is never replaced. (File.Move + // without overwrite cannot guarantee that: on Unix it checks the + // destination and then renames.) + using (AcquireCreateLock()) { - var existing = File.ReadAllBytes(_secretPath); - if (existing.Length == SecretLength) + existing = TryReadSecret(); + if (existing != null) return existing; + + if (File.Exists(_secretPath)) { - TightenPermissions(_secretPath); - return existing; + _warn($"Native poster tags: {SecretFileName} has an unexpected length; replacing it (issued poster URLs fall back to original artwork until clients refresh)."); } - _warn($"Native poster tags: {SecretFileName} has an unexpected length; replacing it (issued poster URLs fall back to original artwork until clients refresh)."); + var fresh = RandomNumberGenerator.GetBytes(SecretLength); + WriteSecretAtomically(fresh); + return fresh; } - - var fresh = RandomNumberGenerator.GetBytes(SecretLength); - WriteSecretAtomically(fresh, overwrite: File.Exists(_secretPath)); - // Another JE instance sharing the config dir may have won the - // create race; whatever is on disk now is the shared secret. - var onDisk = File.ReadAllBytes(_secretPath); - return onDisk.Length == SecretLength ? onDisk : fresh; } catch (Exception ex) { @@ -192,11 +203,46 @@ private byte[] LoadOrCreateSecret() } } - // Temp file created owner-only from the start (no window where the - // secret is world-readable), flushed, then moved into place. - private void WriteSecretAtomically(byte[] secret, bool overwrite) + /// The persisted secret, or null when it is missing or has the wrong length. + private byte[]? TryReadSecret() + { + if (!File.Exists(_secretPath)) return null; + var existing = File.ReadAllBytes(_secretPath); + if (existing.Length != SecretLength) return null; + TightenPermissions(_secretPath); + return existing; + } + + // Exclusive across processes as well (FileShare.None takes an flock on + // Unix). The lock file is left in place: deleting it while another + // instance waits on it would let two creators hold "the" lock. + private FileStream AcquireCreateLock() { Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!); + var options = new FileStreamOptions { Mode = FileMode.OpenOrCreate, Access = FileAccess.ReadWrite, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + for (var attempt = 0; ; attempt++) + { + try + { + return new FileStream(_secretPath + ".lock", options); + } + catch (IOException) when (attempt < 200) + { + Thread.Sleep(25); + } + } + } + + // Temp file created owner-only from the start (no window where the + // secret is world-readable), flushed, then moved into place. Callers + // hold the create lock. + private void WriteSecretAtomically(byte[] secret) + { var temp = _secretPath + ".tmp." + Guid.NewGuid().ToString("N"); try { @@ -212,14 +258,7 @@ private void WriteSecretAtomically(byte[] secret, bool overwrite) stream.Flush(true); } - try - { - File.Move(temp, _secretPath, overwrite); - } - catch (IOException) when (!overwrite && File.Exists(_secretPath)) - { - // Lost a create race: keep the winner's secret. - } + File.Move(temp, _secretPath, overwrite: true); } finally { diff --git a/tests/backend/PosterTags/PosterTokenTests.cs b/tests/backend/PosterTags/PosterTokenTests.cs index 9eefee08d..b02da80f2 100644 --- a/tests/backend/PosterTags/PosterTokenTests.cs +++ b/tests/backend/PosterTags/PosterTokenTests.cs @@ -68,10 +68,32 @@ public void Weak_token_is_explicit_and_corrupt_secret_recovers() } [Fact] - public async Task Concurrent_first_use_shares_one_persisted_secret() + public void Concurrent_first_use_shares_one_persisted_secret() { - var tokens = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => Tokens().Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc")))); + // The loser has already seen no secret file when the winner creates + // one and mints with it; the loser must adopt it, not overwrite it. + var winner = Tokens(); + var loser = Tokens(); + string? winnerToken = null; + loser.BeforeSecretWriteForTest = () => winnerToken = winner.Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc"); + var loserToken = loser.Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc"); + Assert.NotNull(winnerToken); + Assert.Equal(winnerToken, loserToken); + Assert.Equal(winnerToken, Tokens().Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc")); + Assert.Empty(Directory.GetFiles(directory, "*.tmp.*")); + } + + [Fact] + public async Task Racing_first_use_after_every_check_shares_one_secret() + { + // All instances pass the existence check before any of them writes. + const int count = 8; + using var allChecked = new Barrier(count); + var services = Enumerable.Range(0, count).Select(_ => Tokens()).ToList(); + foreach (var service in services) service.BeforeSecretWriteForTest = () => allChecked.SignalAndWait(TimeSpan.FromSeconds(10)); + var tokens = await Task.WhenAll(services.Select(service => Task.Factory.StartNew(() => service.Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc"), TaskCreationOptions.LongRunning))); Assert.Single(tokens.Distinct()); + Assert.Equal(tokens[0], Tokens().Mint(user, item, "s", "r", PosterTagVariantFlags.None, "123abc")); Assert.Empty(Directory.GetFiles(directory, "*.tmp.*")); } From 972bea82cf7b28948e251ed0448fa8a0cce38090 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:22:52 +0800 Subject: [PATCH 003/111] fix(maintenance): keep the restore journal through write failures and corrupt state - Enable journals each disabled user before touching the next. If that write fails, the user stays on the in-memory restore list, nobody else is disabled, and the next tick or disable restores them. Previously a failed final save lost the whole list. - Disable and expiry fail open: an unwritable state file no longer keeps users locked out. They are restored and anyone who could not be stays pending in memory. Only enable refuses an unwritable journal. - A missing state file is no longer confused with an unreadable or corrupt one. A corrupt file is backed up like reviews.json, is never overwritten, and blocks enabling until it is repaired or removed; an unreadable one is not cached as empty. - Deleted users and users without a policy are dropped from the restore list, and a restore that keeps failing no longer blocks every later window: it is carried into the new window and retried when it ends. --- .../Services/MaintenanceModeService.cs | 236 ++++++++++++++---- tests/backend/Core/CoreMaintenanceTests.cs | 186 ++++++++++++-- tests/docs/maintenance-recovery.md | 4 +- 3 files changed, 365 insertions(+), 61 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs index a02192fdd..1201f8554 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs @@ -60,6 +60,13 @@ public class MaintenanceModeService // In-memory copy of the state file. public-config (every page load, anonymous) and the // playback reminder now read the state, so it must not cost a file read per request. private MaintenanceState? _cached; + // Set while maintenance-state.json exists but cannot be read or parsed. That file may be the + // only record of whom to restore, so nothing writes over it and enable refuses until an admin + // repairs or removes it. Cleared by the next successful load. + private volatile string? _stateLoadError; + // The failure last reported (message plus file size/time), so a broken file is logged and + // backed up once rather than on every status read. + private volatile string? _reportedLoadFailure; public MaintenanceModeService(IUserManager userManager, ISessionManager sessionManager, IApplicationPaths appPaths, Logger logger) { @@ -151,11 +158,13 @@ private async Task EnableLockedAsync(string message, string? notificationMessage action ??= "disable_accounts"; var currentState = LoadState(); + var loadError = _stateLoadError; + if (loadError != null) + throw new InvalidOperationException($"Maintenance state could not be loaded ({loadError}); repair or remove maintenance-state.json before enabling maintenance."); if (!currentState.IsActive && HasPendingRestores(currentState)) { - await RestoreUsersAsync(currentState).ConfigureAwait(false); - if (HasPendingRestores(currentState)) - throw new InvalidOperationException("Previous maintenance policy restoration is still pending; retry before opening a new window."); + await RestoreUsersAsync(currentState, failOpen: false).ConfigureAwait(false); + LogCarriedRestores(currentState); } if (currentState.IsActive) { @@ -209,9 +218,8 @@ private async Task EnableLockedAsync(string message, string? notificationMessage // must reapply it rather than take the same-target shortcut. currentState.IsActive = false; SaveState(currentState); - await RestoreUsersAsync(currentState).ConfigureAwait(false); - if (HasPendingRestores(currentState)) - throw new InvalidOperationException("Maintenance policy restoration failed; previous targets are retained for retry."); + await RestoreUsersAsync(currentState, failOpen: false).ConfigureAwait(false); + LogCarriedRestores(currentState); } bool doAccounts = action == "disable_accounts" || action == "both"; @@ -236,8 +244,11 @@ private async Task EnableLockedAsync(string message, string? notificationMessage targetUsers = allNonAdmin.Where(u => idSet.Contains(u.Id)); } - var accountDisabled = new List(); - var remoteDisabled = new List(); + // The journal is the still-inactive state. Any restores carried over from an earlier window + // stay on it, and each user disabled below is added and checkpointed before the next user is + // touched, so a failure or crash part-way leaves an inactive state listing everyone to restore. + var accountDisabled = currentState.AccountDisabledUserIds; + var remoteDisabled = currentState.RemoteDisabledUserIds; // Refuse policy mutations if the restoration journal is already // unwritable. Host policy commits and this file cannot share a @@ -246,45 +257,48 @@ private async Task EnableLockedAsync(string message, string? notificationMessage foreach (var user in targetUsers) { + bool accountChanged = false; + bool remoteChanged = false; try { var dto = _userManager.GetUserDto(user, string.Empty); if (dto.Policy == null) continue; - bool changed = false; - bool accountChanged = false; - bool remoteChanged = false; - if (doAccounts && !dto.Policy.IsDisabled) { dto.Policy.IsDisabled = true; accountChanged = true; - changed = true; } if (doRemote && dto.Policy.EnableRemoteAccess) { dto.Policy.EnableRemoteAccess = false; remoteChanged = true; - changed = true; } - if (changed) + if (accountChanged || remoteChanged) { await _userManager.UpdatePolicyAsync(user.Id, dto.Policy).ConfigureAwait(false); - // Only restore changes the policy store actually accepted. - // A failed update must not grant access on a later disable. - if (accountChanged) accountDisabled.Add(user.Id.ToString()); - if (remoteChanged) remoteDisabled.Add(user.Id.ToString()); - _logger.Info($"[Maintenance] Updated user '{user.Username}'" + - $"{(doAccounts && accountDisabled.Contains(user.Id.ToString()) ? " (account disabled)" : "")}" + - $"{(doRemote && remoteDisabled.Contains(user.Id.ToString()) ? " (remote disabled)" : "")}"); } } catch (Exception ex) { _logger.Error($"[Maintenance] Failed to update user '{user.Username}': {ex.Message}"); + continue; } + + if (!accountChanged && !remoteChanged) continue; + // Only restore changes the policy store actually accepted. + // A failed update must not grant access on a later disable. + var id = user.Id.ToString(); + if (accountChanged && !accountDisabled.Contains(id)) accountDisabled.Add(id); + if (remoteChanged && !remoteDisabled.Contains(id)) remoteDisabled.Add(id); + // A journal write failure stops here: nobody else is locked out without a record, and + // the in-memory journal still lists this user so the next tick or disable restores them. + SaveJournal(currentState); + _logger.Info($"[Maintenance] Updated user '{user.Username}'" + + $"{(accountChanged ? " (account disabled)" : "")}" + + $"{(remoteChanged ? " (remote disabled)" : "")}"); } var newState = new MaintenanceState @@ -343,7 +357,7 @@ private async Task DisableCoreAsync(Func shouldDisable, if (!state.IsActive) { if (HasPendingRestores(state)) - await RestoreUsersAsync(state).ConfigureAwait(false); + await RestoreUsersAsync(state, failOpen: true).ConfigureAwait(false); _logger.Info("[Maintenance] Already inactive - skipping disable."); return; } @@ -351,9 +365,11 @@ private async Task DisableCoreAsync(Func shouldDisable, if (reason != null) _logger.Info($"[Maintenance] {reason} - disabling."); // Persist restoration intent before touching policies. Failed users // remain on this inactive state so the next tick/restart retries. + // An unwritable state file must not keep users locked out: the + // pending list is then kept in memory and restoration goes ahead. state.IsActive = false; state.SkippedScheduledWindowEnd = explicitEnd && state.Source == "schedule" ? state.EndsAt : null; - SaveState(state); + Checkpoint(state, failOpen: true); // A timed manual window that ran out must also clear the admin toggle, or the config // page would still show it checked and the next save would re-enable it. Cleared before @@ -363,7 +379,7 @@ private async Task DisableCoreAsync(Func shouldDisable, ClearManualConfigFlag(); } - await RestoreUsersAsync(state).ConfigureAwait(false); + await RestoreUsersAsync(state, failOpen: true).ConfigureAwait(false); _logger.Info($"[Maintenance Mode] Disabled (was {state.Source})."); } finally @@ -511,7 +527,12 @@ public Task SendToSessionAsync(string sessionId, string text, DateTime? endsAtUt /// (reconciling a changed Action/target selection while still active) - callers own /// updating IsActive/persisting the resulting state themselves. /// - private async Task RestoreUsersAsync(MaintenanceState state) + /// + /// True on the disable/expiry path: a journal write failure is logged and restoration carries + /// on with the pending list kept in memory. False on the enable path, where it aborts the + /// transition before a new window can be applied. + /// + private async Task RestoreUsersAsync(MaintenanceState state, bool failOpen) { var allIds = state.AccountDisabledUserIds .Union(state.RemoteDisabledUserIds) @@ -527,22 +548,33 @@ private async Task RestoreUsersAsync(MaintenanceState state) { state.AccountDisabledUserIds.Remove(idStr); state.RemoteDisabledUserIds.Remove(idStr); - SaveState(state); + Checkpoint(state, failOpen); continue; } try { + // A deleted user or one without a policy has nothing left to restore; + // dropping the entry keeps it from staying pending forever. var user = _userManager.GetUserById(userId); - if (user != null) + if (user == null) + { + _logger.Warning($"[Maintenance] User {idStr} no longer exists - dropping it from the restore list."); + } + else { var dto = _userManager.GetUserDto(user, string.Empty); - if (dto.Policy == null) continue; - - if (accountSet.Contains(idStr)) dto.Policy.IsDisabled = false; - if (remoteSet.Contains(idStr)) dto.Policy.EnableRemoteAccess = true; - - await _userManager.UpdatePolicyAsync(userId, dto.Policy).ConfigureAwait(false); - _logger.Info($"[Maintenance] Restored user '{user.Username}'"); + if (dto.Policy == null) + { + _logger.Warning($"[Maintenance] User '{user.Username}' has no policy - dropping it from the restore list."); + } + else + { + if (accountSet.Contains(idStr)) dto.Policy.IsDisabled = false; + if (remoteSet.Contains(idStr)) dto.Policy.EnableRemoteAccess = true; + + await _userManager.UpdatePolicyAsync(userId, dto.Policy).ConfigureAwait(false); + _logger.Info($"[Maintenance] Restored user '{user.Username}'"); + } } } catch (Exception ex) @@ -550,14 +582,25 @@ private async Task RestoreUsersAsync(MaintenanceState state) _logger.Error($"[Maintenance] Failed to restore user {idStr}: {ex.Message}"); continue; } - // Persistence failures must abort the transition, not be swallowed - // as policy failures and allow a new window to replace old intent. + // On the enable path persistence failures abort the transition rather + // than being swallowed as policy failures. state.AccountDisabledUserIds.Remove(idStr); state.RemoteDisabledUserIds.Remove(idStr); - SaveState(state); + Checkpoint(state, failOpen); } } + /// + /// Restores that still failed stay on the journal and are carried into the window being + /// opened, so they are retried when it ends instead of blocking every future window. + /// + private void LogCarriedRestores(MaintenanceState state) + { + if (!HasPendingRestores(state)) return; + _logger.Warning($"[Maintenance] {state.AccountDisabledUserIds.Union(state.RemoteDisabledUserIds).Count()} user(s) could not be restored; " + + "they stay on the restore list and are retried when the new window ends."); + } + /// Order-independent comparison; null/empty both mean "all non-admin users". private static bool AffectedUserSetsEqual(List? a, List? b) { @@ -570,18 +613,39 @@ private MaintenanceState LoadState() { var cached = _cached; if (cached != null) return CopyState(cached); - try + // Only a missing file means "no maintenance state". An unreadable or corrupt one reads as + // inactive but is not cached, so the next read retries, and nothing may overwrite it. + if (File.Exists(_stateFilePath)) { - if (File.Exists(_stateFilePath)) + string json; + try + { + json = File.ReadAllText(_stateFilePath); + } + catch (Exception ex) + { + ReportLoadFailure($"could not read maintenance-state.json: {ex.Message}", backup: false); + return new MaintenanceState(); + } + + string? parseError = null; + try { - var json = File.ReadAllText(_stateFilePath); cached = JsonConvert.DeserializeObject(json); } + catch (Exception ex) + { + parseError = ex.Message; + } + + if (cached == null) + { + ReportLoadFailure($"maintenance-state.json is corrupt ({parseError ?? "empty or null"})", backup: true); + return new MaintenanceState(); + } } - catch (Exception ex) - { - _logger.Error($"[Maintenance] Failed to load state: {ex.Message}"); - } + _stateLoadError = null; + _reportedLoadFailure = null; cached ??= new MaintenanceState(); cached.AccountDisabledUserIds = (cached.AccountDisabledUserIds ?? new()).Distinct().ToList(); cached.RemoteDisabledUserIds = (cached.RemoteDisabledUserIds ?? new()).Distinct().ToList(); @@ -589,6 +653,47 @@ private MaintenanceState LoadState() return CopyState(cached); } + /// + /// Records why the state file could not be loaded (enable refuses while it is set). A given + /// failure on a given file version is logged, and a corrupt file backed up, only once. + /// + private void ReportLoadFailure(string message, bool backup) + { + _stateLoadError = message; + var key = message; + try + { + var info = new FileInfo(_stateFilePath); + key += "|" + info.Length.ToString(CultureInfo.InvariantCulture) + "|" + info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + } + catch (Exception) + { + // The message alone still deduplicates. + } + + if (key == _reportedLoadFailure) return; + _reportedLoadFailure = key; + _logger.Error($"[Maintenance] Failed to load state: {message}. Maintenance cannot be enabled and the file will not be " + + "overwritten until it is repaired or removed; users it lists as disabled by maintenance are not restored automatically."); + if (backup) BackupCorruptStateFile(); + } + + /// Copies a corrupt state file aside for recovery, like the reviews store does. Never throws. + private void BackupCorruptStateFile() + { + try + { + var backupPath = _stateFilePath + ".corrupt-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture); + if (!File.Exists(backupPath)) + File.Copy(_stateFilePath, backupPath); + _logger.Warning($"[Maintenance] Corrupt maintenance-state.json backed up to {backupPath}"); + } + catch (Exception ex) + { + _logger.Error($"[Maintenance] Failed to back up corrupt maintenance-state.json: {ex.Message}"); + } + } + // Callers stage mutations privately; a failed disk checkpoint must not // silently change the authoritative cached state. private static MaintenanceState CopyState(MaintenanceState state) => new() @@ -602,8 +707,49 @@ private MaintenanceState LoadState() SkippedScheduledWindowEnd = state.SkippedScheduledWindowEnd }; + /// + /// Saves a restore-path checkpoint. With a write failure is already + /// logged by , and the in-memory state takes the change anyway so + /// restoration can continue and be retried. + /// + private void Checkpoint(MaintenanceState state, bool failOpen) + { + try + { + SaveState(state); + } + catch (Exception) when (failOpen) + { + _cached = CopyState(state); + } + } + + /// + /// Saves the enable-path journal after a user was disabled. On failure the in-memory state + /// still records that user for restoration before the error propagates. + /// + private void SaveJournal(MaintenanceState state) + { + try + { + SaveState(state); + } + catch (Exception) + { + _cached = CopyState(state); + throw; + } + } + private void SaveState(MaintenanceState state) { + var loadError = _stateLoadError; + if (loadError != null) + { + // Writing now would erase the only record of whom to restore. + throw new InvalidOperationException($"Maintenance state could not be loaded ({loadError}); refusing to overwrite it."); + } + var temporaryPath = _stateFilePath + ".tmp." + Guid.NewGuid().ToString("N"); try { diff --git a/tests/backend/Core/CoreMaintenanceTests.cs b/tests/backend/Core/CoreMaintenanceTests.cs index d01af3199..df2e1aff5 100644 --- a/tests/backend/Core/CoreMaintenanceTests.cs +++ b/tests/backend/Core/CoreMaintenanceTests.cs @@ -175,17 +175,60 @@ public async Task FailedRestoreRemainsPendingAcrossRestartAndRetriesWithoutTouch } [Fact] - public async Task FailedReconcileCannotOverwritePreviousRestorationIntent() + public async Task FailedReconcileCarriesPreviousRestorationIntentIntoTheNewWindow() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); + await f.Service.EnableAsync("new", 0, "none", null); + var state = f.Reopen().GetStatus(); + Assert.True(state.IsActive); + Assert.Equal("none", state.Action); + Assert.Contains(f.Alice.Id.ToString(), state.AccountDisabledUserIds); + Assert.Contains(f.Alice.Id.ToString(), state.RemoteDisabledUserIds); + StorePoliciesNormally(f, f.Alice.Id); + await f.Service.DisableAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.Empty(f.Reopen().GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Reopen().GetStatus().RemoteDisabledUserIds); + } + + [Fact] + public async Task PersistentRestoreFailureDoesNotBlockLaterWindowsAndIsRetriedWhenTheyEnd() { using var f = new Fixture(); await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); - await Assert.ThrowsAsync(() => f.Service.EnableAsync("new", 0, "none", null)); - Assert.Contains(f.Alice.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); - Assert.Equal("both", f.Reopen().GetStatus().Action); await f.Service.DisableAsync(); - await Assert.ThrowsAsync(() => f.Service.EnableAsync("new", 0, "none", null)); + Assert.False(f.Service.GetStatus().IsActive); + Assert.Contains(f.Alice.Id.ToString(), f.Service.GetStatus().AccountDisabledUserIds); + // Retried and still failing: the new window opens anyway and keeps the pending restore. + await f.Service.EnableAsync("next", 0, "disable_remote", null); + Assert.True(f.Service.GetStatus().IsActive); Assert.Contains(f.Alice.Id.ToString(), f.Reopen().GetStatus().AccountDisabledUserIds); + StorePoliciesNormally(f, f.Alice.Id); + await f.Service.DisableAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.Empty(f.Reopen().GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Reopen().GetStatus().RemoteDisabledUserIds); + } + + [Fact] + public async Task DeletedUsersAndUsersWithoutPolicyAreDroppedFromTheRestoreList() + { + using var f = new Fixture(); + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + await f.Service.EnableAsync("", 0, "both", null); + Assert.Equal(2, f.Service.GetStatus().AccountDisabledUserIds.Count); + f.Users.Setup(x => x.GetUserById(f.Alice.Id)).Returns((User?)null); + f.Users.Setup(x => x.GetUserDto(f.Restricted, It.IsAny())).Returns(() => new UserDto { Id = f.Restricted.Id, Policy = null! }); + await f.Service.DisableAsync(); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); + Assert.Empty(f.Reopen().GetStatus().AccountDisabledUserIds); } [Fact] @@ -213,8 +256,9 @@ public async Task PartialReconcileFailureThenOriginalSelectionReappliesEveryUser f.Policies[id] = policy; return Task.CompletedTask; }); - await Assert.ThrowsAsync(() => f.Service.EnableAsync("", 0, "none", null)); + await f.Service.EnableAsync("", 0, "none", null); Assert.False(f.Policies[f.Restricted.Id].IsDisabled); + Assert.Equal([f.Alice.Id.ToString()], f.Service.GetStatus().AccountDisabledUserIds); fail = false; await f.Service.EnableAsync("", 0, "both", null); Assert.True(f.Policies[f.Alice.Id].IsDisabled); @@ -223,17 +267,117 @@ public async Task PartialReconcileFailureThenOriginalSelectionReappliesEveryUser } [Fact] - public async Task StateWriteFailureIsReportedBeforeRestoringPolicies() + public async Task UnwritableStateStillRestoresPoliciesOnDisableAndKeepsFailuresPendingInMemory() { using var f = new Fixture(); + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; await f.Service.EnableAsync("", 0, "both", null); - var statePath = Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"); - File.Delete(statePath); - Directory.CreateDirectory(statePath); - await Assert.ThrowsAnyAsync(() => f.Service.DisableAsync()); + BreakStateFile(f); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Restricted.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); + await f.Service.DisableAsync(); + Assert.False(f.Service.GetStatus().IsActive); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.Equal([f.Restricted.Id.ToString()], f.Service.GetStatus().AccountDisabledUserIds); + Assert.Equal([f.Restricted.Id.ToString()], f.Service.GetStatus().RemoteDisabledUserIds); + // The expiry tick retries from memory while the file is still unwritable. + StorePoliciesNormally(f, f.Restricted.Id); + await f.Service.ExpireIfDueAsync(); + Assert.False(f.Policies[f.Restricted.Id].IsDisabled); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); + Assert.Empty(Directory.GetFiles(f.Core.ConfigRoot, "maintenance-state.json.tmp.*")); + // Only enable refuses an unwritable journal, before touching anyone. + await Assert.ThrowsAnyAsync(() => f.Service.EnableAsync("", 0, "both", null)); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.False(f.Service.GetStatus().IsActive); + } + + [Fact] + public async Task EnableJournalFailureKeepsDisabledUserForRestorationAndStopsDisablingOthers() + { + using var f = new Fixture(); + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((id, policy) => + { + f.Policies[id] = policy; + BreakStateFile(f); + return Task.CompletedTask; + }); + await Assert.ThrowsAnyAsync(() => f.Service.EnableAsync("", 0, "both", null)); Assert.True(f.Policies[f.Alice.Id].IsDisabled); + Assert.False(f.Policies[f.Restricted.Id].IsDisabled); + var state = f.Service.GetStatus(); + Assert.False(state.IsActive); + Assert.Equal([f.Alice.Id.ToString()], state.AccountDisabledUserIds); + Assert.Equal([f.Alice.Id.ToString()], state.RemoteDisabledUserIds); + StorePoliciesNormally(f, f.Alice.Id); + await f.Service.ExpireIfDueAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + } + + [Fact] + public async Task EnableJournalsEachDisabledUserBeforeTouchingTheNext() + { + using var f = new Fixture(); + f.Policies[f.Restricted.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + MaintenanceState? onDiskBeforeRestricted = null; + f.Users.Setup(x => x.UpdatePolicyAsync(f.Restricted.Id, It.IsAny())).Returns((id, policy) => + { + onDiskBeforeRestricted = JsonConvert.DeserializeObject(File.ReadAllText(StatePath(f))); + f.Policies[id] = policy; + return Task.CompletedTask; + }); + await f.Service.EnableAsync("", 0, "both", null); + Assert.NotNull(onDiskBeforeRestricted); + Assert.False(onDiskBeforeRestricted!.IsActive); + Assert.Equal([f.Alice.Id.ToString()], onDiskBeforeRestricted.AccountDisabledUserIds); + Assert.Equal([f.Alice.Id.ToString()], onDiskBeforeRestricted.RemoteDisabledUserIds); Assert.True(f.Service.GetStatus().IsActive); - Assert.Empty(Directory.GetFiles(f.Core.ConfigRoot, "maintenance-state.json.tmp.*")); + Assert.Equal(2, f.Reopen().GetStatus().AccountDisabledUserIds.Count); + } + + [Theory] + [InlineData("{\"IsActive\":true,\"AccountDisabledUserIds\":[\"")] + [InlineData("")] + [InlineData("null")] + public async Task CorruptStateIsBackedUpNeverOverwrittenAndBlocksEnableUntilRemoved(string content) + { + using var f = new Fixture(); + File.WriteAllText(StatePath(f), content); + var reopened = f.Reopen(); + Assert.False(reopened.GetStatus().IsActive); + Assert.False(reopened.GetStatus().IsActive); + await Assert.ThrowsAsync(() => reopened.EnableAsync("", 0, "both", null)); + await reopened.DisableAsync(); + await reopened.ExpireIfDueAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.Equal(content, File.ReadAllText(StatePath(f))); + var backup = Assert.Single(Directory.GetFiles(f.Core.ConfigRoot, "maintenance-state.json.corrupt-*")); + Assert.Equal(content, File.ReadAllText(backup)); + File.Delete(StatePath(f)); + await reopened.EnableAsync("", 0, "both", null); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Reopen().GetStatus().IsActive); + } + + [Fact] + public async Task UnreadableStateIsNotCachedAsEmpty() + { + if (OperatingSystem.IsWindows()) return; + using var f = new Fixture(); + // A self-referencing link exists but every read fails, even for root. + File.CreateSymbolicLink(StatePath(f), StatePath(f)); + var reopened = f.Reopen(); + Assert.False(reopened.GetStatus().IsActive); + await Assert.ThrowsAsync(() => reopened.EnableAsync("", 0, "both", null)); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + File.Delete(StatePath(f)); + File.WriteAllText(StatePath(f), JsonConvert.SerializeObject(new MaintenanceState { IsActive = true, Message = "readable again", Action = "none" })); + Assert.True(reopened.GetStatus().IsActive); + Assert.Equal("readable again", reopened.GetStatus().Message); } [Fact] @@ -241,12 +385,10 @@ public async Task CheckpointFailureAfterPolicyRestoreAbortsReconciliation() { using var f = new Fixture(); await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); - var statePath = Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"); f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((id, policy) => { f.Policies[id] = policy; - File.Delete(statePath); - Directory.CreateDirectory(statePath); + BreakStateFile(f); return Task.CompletedTask; }); await Assert.ThrowsAnyAsync(() => f.Service.EnableAsync("new", 0, "none", null)); @@ -255,6 +397,20 @@ public async Task CheckpointFailureAfterPolicyRestoreAbortsReconciliation() Assert.False(f.Service.GetStatus().IsActive); } + private static string StatePath(Fixture f) => Path.Combine(f.Core.ConfigRoot, "maintenance-state.json"); + + // A directory in place of the state file makes every later atomic replace fail. + private static void BreakStateFile(Fixture f) + { + File.Delete(StatePath(f)); + Directory.CreateDirectory(StatePath(f)); + } + + private static void StorePoliciesNormally(Fixture f, Guid id) + => f.Users.Setup(x => x.UpdatePolicyAsync(id, It.IsAny())) + .Callback((user, policy) => f.Policies[user] = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(policy))!) + .Returns(Task.CompletedTask); + [Theory] [InlineData("00:00", "08:00", true)][InlineData("22:00", "06:00", true)] [InlineData(" 9:00 ", "10:00", true)][InlineData("24:00", "06:00", false)] diff --git a/tests/docs/maintenance-recovery.md b/tests/docs/maintenance-recovery.md index 7cea92bf2..5e27c76e1 100644 --- a/tests/docs/maintenance-recovery.md +++ b/tests/docs/maintenance-recovery.md @@ -2,7 +2,9 @@ `tests/backend/Core/CoreMaintenanceTests.cs` exercises the actual maintenance service against a disposable state directory and a mocked Jellyfin policy store. Policy copies model independent persisted reads, so a failed update cannot accidentally mutate the test store. -The suite verifies that administrators and previously restricted users keep their original access, only successful maintenance changes enter restoration lists, and failed restores remain pending across restart. Each successfully restored user is removed from the journal; retries do not change that user's later administrator-set restrictions. Reconciliation becomes inactive while restoration is pending, and cannot replace unresolved restoration intent with a new selection. Null persisted lists are normalized. Failed journal writes are observable, use an atomic replacement, and do not silently change the cached state. +The suite verifies that administrators and previously restricted users keep their original access, only successful maintenance changes enter restoration lists, and failed restores remain pending across restart. Each successfully restored user is removed from the journal; retries do not change that user's later administrator-set restrictions. Null persisted lists are normalized. + +Enabling journals every disabled user before touching the next one; if that write fails, the user stays on the in-memory restore list, no one else is disabled, and the next tick or disable restores them. Enabling refuses to start when the journal is already unwritable. Disabling and expiry fail open: when the state file cannot be written, users are still restored and anyone who could not be restored stays pending in memory for retry. A corrupt state file is backed up beside itself (`maintenance-state.json.corrupt-`), never overwritten, and blocks enabling until an administrator repairs or removes it; an unreadable file is not cached as empty. Entries for deleted users or users without a policy are dropped. A user whose restore keeps failing no longer blocks new windows: reconciliation and new windows carry the still-pending restore into the new window, which retries it when it ends. These tests do **not** establish a transaction across Jellyfin's policy database and JE's journal file. A process failure between a committed policy update and its journal checkpoint, a policy backend that commits and then throws, or administrator changes to a still-pending user's same permission remain ambiguous. The two stores do not expose a shared transaction or policy revision check. Recovery retries are covered for policy operations that fail before committing and for successful checkpoints; crash-exactly-between-stores and after-commit exceptions remain explicit limitations. From 1f948c119481d5dbd7f9d6fe9c606dd772c148f7 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:10 +0800 Subject: [PATCH 004/111] fix(bookmarks): roll back failed bookmark saves JE.saveUserSettings logs and swallows every failure, so the rollback in bookmark add, update, delete and sync could never run: a failed save left the change in memory and reported success. saveUserSettings takes an opt-in { throwOnError: true } that rejects when nothing was saved; every other caller keeps the old log-and-resolve behaviour. The bookmark mutations opt in. The bookmark tests now run the real saveUserSettings against a failing ApiClient.ajax instead of a throwing stub. --- .../js/enhanced/bookmarks/bookmarks.js | 8 ++--- .../js/enhanced/config.js | 24 +++++++++---- .../features/features-bookmarks.test.mjs | 34 +++++++++++++++---- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js index 30255a49f..68b7ef8ef 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js @@ -272,7 +272,7 @@ bookmarks[bookmarkId] = bookmark; try { - await JE.saveUserSettings('bookmark.json', owner.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); console.log(`${logPrefix} Bookmark added:`, bookmarkId, bookmark); if (isCurrentOwner()) emitBookmarksUpdated('add'); return { id: bookmarkId, ...bookmark }; @@ -302,7 +302,7 @@ bookmarks[bookmarkId] = bookmark; try { - await JE.saveUserSettings('bookmark.json', owner.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); console.log(`${logPrefix} Bookmark updated:`, bookmarkId); if (isCurrentOwner()) emitBookmarksUpdated('update'); return true; @@ -333,7 +333,7 @@ delete bookmarks[bookmarkId]; try { - await JE.saveUserSettings('bookmark.json', owner.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); console.log(`${logPrefix} Bookmark deleted:`, bookmarkId); if (isCurrentOwner()) emitBookmarksUpdated('delete'); return true; @@ -383,7 +383,7 @@ } try { - await JE.saveUserSettings('bookmark.json', owner.bookmark); + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); console.log(`${logPrefix} Synced ${synced.length} bookmarks to new item ID`); if (isCurrentOwner()) emitBookmarksUpdated('sync'); return synced; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js index f66ff4152..9f0edfe61 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js @@ -26,12 +26,6 @@ pauseScreenClickTimer: null }; - /** - * Saves user settings to the server. - * Skips the POST if the data is identical to the last value saved this session - * (prevents redundant writes). The first save per session for a given file is - * always allowed through since the cache starts empty. - */ // Per-file cache of the last JSON string successfully sent to the server. const _lastSavedJson = {}; // Per-file chain of in-flight saves. Every control posts the whole object, @@ -39,14 +33,29 @@ // (smaller) snapshot can land last and drop the newer change. const _saveChain = {}; - JE.saveUserSettings = async (fileName, settings) => { + /** + * Saves user settings to the server. + * Skips the POST if the data is identical to the last value saved this session + * (prevents redundant writes). The first save per session for a given file is + * always allowed through since the cache starts empty. + * Failures are logged and swallowed unless the caller opts in to them. + * @param {string} fileName Per-user settings file, e.g. 'settings.json'. + * @param {object} settings The whole document to save. + * @param {{throwOnError?: boolean}} [options] throwOnError: reject (without logging) when + * nothing was saved, so a caller can roll back its own optimistic change. + * @returns {Promise} + */ + JE.saveUserSettings = async (fileName, settings, options = {}) => { + const throwOnError = options?.throwOnError === true; if (typeof ApiClient === 'undefined' || !ApiClient.getCurrentUserId) { + if (throwOnError) throw new Error('ApiClient not available'); console.error("🪼 Jellyfin Enhanced: ApiClient not available"); return; } try { const userId = ApiClient.getCurrentUserId(); if (!userId) { + if (throwOnError) throw new Error('User ID not available'); console.error("🪼 Jellyfin Enhanced: User ID not available"); return; } @@ -86,6 +95,7 @@ _saveChain[cacheKey] = save.catch(() => {}); await save; } catch (e) { + if (throwOnError) throw e; console.error(`🪼 Jellyfin Enhanced: Failed to save ${fileName}:`, e); } }; diff --git a/tests/frontend/features/features-bookmarks.test.mjs b/tests/frontend/features/features-bookmarks.test.mjs index f0fbc6efb..da4cc0a5e 100644 --- a/tests/frontend/features/features-bookmarks.test.mjs +++ b/tests/frontend/features/features-bookmarks.test.mjs @@ -2,13 +2,24 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHarness, plain } from '../helpers/harness.mjs'; +/** + * Loads the real settings saver and bookmarks. `save` answers each user-settings POST made through + * ApiClient.ajax (reject it to fail the save); `routes.items` answers item lookups. + */ function setup(t, bookmarks = {}, save = async()=>{}) { const events=[]; - const h=createHarness({JE:{pluginConfig:{BookmarksEnabled:true},userConfig:{bookmark:{bookmarks}},saveUserSettings:save,t:key=>key,toast:()=>{}}}); + const routes={save,items:null}; + const ajax=request=>{ + if(request.type==='POST'&&request.url.includes('/JellyfinEnhanced/user-settings/user-a/'))return routes.save(request.url.split('/').pop(),JSON.parse(request.data)); + if(routes.items)return routes.items(request); + throw new Error(`Unexpected request: ${request.url}`); + }; + const h=createHarness({JE:{pluginConfig:{BookmarksEnabled:true},userConfig:{bookmark:{bookmarks}},t:key=>key,toast:()=>{}},apiClient:{ajax}}); t.after(()=>h.close()); h.document.addEventListener('je-bookmarks-updated',e=>events.push(e.detail.reason)); + h.load('enhanced/config.js'); h.load('enhanced/bookmarks/bookmarks.js'); - return {...h,events,api:h.JE.bookmarks}; + return {...h,events,routes,api:h.JE.bookmarks}; } test('bookmarks remain inactive when feature is disabled',t=>{ const h=createHarness({JE:{pluginConfig:{BookmarksEnabled:false}}});t.after(()=>h.close()); @@ -51,6 +62,16 @@ for(const operation of ['update','delete']){ assert.deepEqual(h.events,[]); }); } +test('saveUserSettings rejects only for callers that opt in',async t=>{ + let fail=true;const h=setup(t,{},async()=>{if(fail)throw new Error('offline');}); + h.expectConsoleError(/Failed to save settings\.json/); + assert.equal(await h.JE.saveUserSettings('settings.json',{a:1}),undefined); + await assert.rejects(h.JE.saveUserSettings('settings.json',{a:1},{throwOnError:true}),/offline/); + fail=false; + await h.JE.saveUserSettings('settings.json',{a:1},{throwOnError:true}); + h.window.ApiClient.getCurrentUserId=()=>null; + await assert.rejects(h.JE.saveUserSettings('settings.json',{a:2},{throwOnError:true}),/User ID not available/); +}); test('bookmark sync preserves originals, offsets timestamps and rolls back failed copies',async t=>{ let fail=false; const h=setup(t,{old:{itemId:'old-item',timestamp:5,label:'scene'}},async()=>{if(fail)throw new Error('offline');}); @@ -73,6 +94,7 @@ for(const operation of ['update','delete']) { let reject; const h=setup(t,{one:{itemId:'secret',label:'old-user'}},()=>new Promise((_,r)=>{reject=r;})); const pending=h.api[operation]('one',{label:'new'}); + await new Promise(done=>setImmediate(done)); h.JE.userConfig={bookmark:{bookmarks:{}}}; h.expectConsoleError(new RegExp(`Failed to ${operation} bookmark`)); reject(new Error('offline')); @@ -84,7 +106,7 @@ test('bookmark item lookup resolving after a user switch cannot add old-user dat let resolve;let saves=0; const h=setup(t,{},async()=>{saves++;}); h.document.body.innerHTML='
    '; - h.window.ApiClient.ajax=()=>new Promise(r=>{resolve=r;}); + h.routes.items=()=>new Promise(r=>{resolve=r;}); const pending=h.api.add(5); h.JE.userConfig={bookmark:{bookmarks:{}}}; resolve({Items:[{Id:'secret',Name:'Secret movie',Type:'Movie',ProviderIds:{Tmdb:'42'}}]}); @@ -95,7 +117,7 @@ for(const operation of ['add','update','delete','syncBookmarks'])for(const failu test(`bookmark ${operation} ${failure?'failure':'success'} after logout leaves absent config untouched`,async t=>{ let resolve,reject;const h=setup(t,{one:{itemId:'old',timestamp:5}},()=>new Promise((a,b)=>{resolve=a;reject=b;})); h.document.body.innerHTML='
    '; - h.window.ApiClient.ajax=async()=>({Items:[{Id:'item',Name:'Movie',Type:'Movie'}]}); + h.routes.items=async()=>({Items:[{Id:'item',Name:'Movie',Type:'Movie'}]}); const pending=operation==='add'?h.api.add(5):operation==='syncBookmarks'?h.api.syncBookmarks([{itemId:'old',timestamp:5}],{itemId:'new'}):h.api[operation]('one',{label:'updated'}); await new Promise(done=>setImmediate(done)); h.JE.userConfig={}; @@ -106,12 +128,12 @@ for(const operation of ['add','update','delete','syncBookmarks'])for(const failu test('bookmark metadata cached for one user is fetched again after account switch',async t=>{ const h=setup(t);let calls=0; h.document.body.innerHTML='
    '; - h.window.ApiClient.ajax=async()=>{calls++;return {Items:[{Id:'item',Name:calls===1?'Private name':'Redacted',Type:'Movie'}]};}; + h.routes.items=async()=>{calls++;return {Items:[{Id:'item',Name:calls===1?'Private name':'Redacted',Type:'Movie'}]};}; assert.equal((await h.api.add(1)).name,'Private name'); h.JE.userConfig={bookmark:{bookmarks:{}}}; assert.equal((await h.api.add(2)).name,'Redacted');assert.equal(calls,2); }); test('stale episode metadata cannot start a secondary series lookup as the old user',async t=>{ let resolve,calls=0;const h=setup(t);h.document.body.innerHTML='
    '; - h.window.ApiClient.ajax=()=>{calls++;return new Promise(r=>{resolve=r;});};const pending=h.api.add(5);h.JE.userConfig={};resolve({Items:[{Id:'episode',Type:'Episode',SeriesId:'private-series'}]});assert.equal(await pending,null);assert.equal(calls,1); + h.routes.items=()=>{calls++;return new Promise(r=>{resolve=r;});};const pending=h.api.add(5);h.JE.userConfig={};resolve({Items:[{Id:'episode',Type:'Episode',SeriesId:'private-series'}]});assert.equal(await pending,null);assert.equal(calls,1); }); From 0600ca1b7a35fe66d6ff2b005fbcf945434d25ad Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:10 +0800 Subject: [PATCH 005/111] fix(shortcuts): match stored shortcuts whatever their modifier order The runtime combo is built in the editor's Meta+Ctrl+Alt+Shift order, so a binding an admin typed as "Shift+Ctrl+S" stopped matching. Stored combos are now canonicalised to the same modifier order (and letter case) before comparing. --- .../js/enhanced/events.js | 40 ++++++++++++++++++- .../bootstrap/bootstrap-shortcuts.test.mjs | 13 ++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js index f1f17bbd5..25e397544 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js @@ -21,6 +21,41 @@ } } + const MODIFIER_ORDER = ['Meta', 'Ctrl', 'Alt', 'Shift']; + + /** + * Rewrites a stored shortcut ("Shift+Ctrl+S", "Ctrl+Shift+S") as its modifier set in the + * shortcut editor's order followed by the key, so a binding typed with the modifiers in any + * order matches the runtime combo. + * @param {*} combo A stored shortcut; anything but a non-empty string is returned unchanged. + * @returns {*} The canonical combo. + */ + function canonicalCombo(combo) { + if (typeof combo !== 'string' || !combo) return combo; + const modifiers = new Set(); + let rest = combo; + let match; + // A trailing "+" is the key itself ("Ctrl++"), never a separator. + while ((match = /^(Meta|Ctrl|Alt|Shift)\+(?=.)/.exec(rest))) { + modifiers.add(match[1]); + rest = rest.slice(match[0].length); + } + const key = /^[a-zA-Z]$/.test(rest) ? rest.toUpperCase() : rest; + return MODIFIER_ORDER.filter(name => modifiers.has(name)).map(name => name + '+').join('') + key; + } + + /** + * The active shortcuts with every combo canonicalised (see canonicalCombo). + * @returns {Object} Action name to canonical combo. + */ + function canonicalShortcuts() { + const canonical = {}; + for (const [name, combo] of Object.entries(JE.state.activeShortcuts || {})) { + canonical[name] = canonicalCombo(combo); + } + return canonical; + } + /** * The main key listener for all other shortcuts. * @param {KeyboardEvent} e The keyboard event. @@ -29,7 +64,8 @@ if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; const key = e.key; - // Match the modifier order persisted by the shortcut editor. + // Built in the shortcut editor's modifier order; stored combos are + // canonicalised to the same order below. const combo = (e.metaKey ? 'Meta+' : '') + (e.ctrlKey ? 'Ctrl+' : '') + (e.altKey ? 'Alt+' : '') + @@ -37,7 +73,7 @@ (key.match(/^[a-zA-Z]$/) ? key.toUpperCase() : key); const video = document.querySelector('video'); - const activeShortcuts = JE.state.activeShortcuts; + const activeShortcuts = canonicalShortcuts(); // --- Global Shortcuts --- if (combo === activeShortcuts.OpenSearch) { diff --git a/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs b/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs index 0fdad461c..070e7b0e7 100644 --- a/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs +++ b/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs @@ -55,6 +55,19 @@ test('editor-created combined-modifier shortcut executes through actual playback assert.deepEqual(h.actions, ['audio']); }); +// Admin-typed bindings may list modifiers in any order; they must keep matching the runtime combo. +for (const [stored, key, modifiers] of [['Shift+Ctrl+Z', 'Z', { ctrlKey: true, shiftKey: true }], ['Ctrl+Shift+z', 'Z', { ctrlKey: true, shiftKey: true }], + ['Shift+Alt+Meta+Ctrl+Q', 'Q', { metaKey: true, ctrlKey: true, altKey: true, shiftKey: true }], ['Ctrl++', '+', { ctrlKey: true }]]) { + test(`stored shortcut ${stored} matches whatever its modifier order`, t => { + const h = setup(t); h.JE.state.activeShortcuts.CycleAudioTracks = stored; + h.JE.initializeEnhancedScript(); h.document.body.focus(); + h.press(h.document.body, key, { ...modifiers, altKey: !modifiers.altKey }); + assert.deepEqual(h.actions, []); + h.press(h.document.body, key, modifiers); + assert.deepEqual(h.actions, ['audio']); + }); +} + test('administrator disabling shortcuts leaves panel key available but player bindings inactive', t => { const h = setup(t, true); h.JE.initializeEnhancedScript(); h.press(h.document.body, 'a'); h.press(h.document.body, '?'); From 5a0d3cfac6530e09c51c4ec478b4c977298bcaae Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:10 +0800 Subject: [PATCH 006/111] fix(item-details): show release dates on their calendar day west of UTC TMDB air dates ("2025-02-01"), movie release dates ("2025-02-01T00:00:00.000Z") and trimmed episode premiere dates are calendar dates, but they were parsed as UTC midnight and formatted in local time, so users west of UTC saw the previous day. The leading date is now formatted as that local calendar day; anything else is parsed as before. A test runs the chips under America/Los_Angeles. --- .../itemdetails/features-release-dates.js | 19 ++++++++++++++++++- .../features/features-release-dates.test.mjs | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js index 3f500f0e0..88d70624e 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js @@ -22,8 +22,25 @@ return new Date().toISOString().slice(0, 10); } + /** + * Formats a release or air date in the user's locale. These are calendar dates, not instants: + * TMDB sends "YYYY-MM-DD" (air dates) or "YYYY-MM-DDT00:00:00.000Z" (movie release dates) and + * episode premieres arrive trimmed to "YYYY-MM-DD". Read as UTC midnight, any of them would show + * the previous day west of UTC, so the leading date is formatted as that local calendar day. + * @param {string} dateStr The date as received. + * @returns {string} The formatted date, or the input unchanged when it is not a valid date. + */ function formatReleaseDate(dateStr) { - const d = new Date(dateStr); + const parts = /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/.exec(String(dateStr)); + let d; + if (parts) { + const [year, month, day] = [Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])]; + d = new Date(year, month, day); + // Out-of-range parts would otherwise roll over into another date. + if (d.getFullYear() !== year || d.getMonth() !== month || d.getDate() !== day) return dateStr; + } else { + d = new Date(dateStr); + } if (isNaN(d.getTime())) return dateStr; return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } diff --git a/tests/frontend/features/features-release-dates.test.mjs b/tests/frontend/features/features-release-dates.test.mjs index 9dfe7d509..c575d0cd7 100644 --- a/tests/frontend/features/features-release-dates.test.mjs +++ b/tests/frontend/features/features-release-dates.test.mjs @@ -23,6 +23,20 @@ test('release-date network failure removes placeholder and logs expected diagnos test('release response arriving after navigation does not recreate removed chips',async t=>{ const response=deferred();const h=setup(t,{fetch:()=>response.promise});h.api.displayReleaseDate('movie',h.container);await Promise.resolve();h.container.replaceChildren();response.resolve(jsonResponse({results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:'2025-01-01'}]}]}));await h.settle();assert.equal(h.container.children.length,0); }); +test('calendar dates show the same day west of UTC',async t=>{ + // Runners pin TZ=UTC; switch this process to a zone behind UTC for the duration of the test. + const previous=process.env.TZ;process.env.TZ='America/Los_Angeles'; + t.after(()=>{if(previous===undefined)delete process.env.TZ;else process.env.TZ=previous;}); + assert.ok(new Date(2025,1,1).getTimezoneOffset()>0); + const h=setup(t,{data:{results:[{iso_3166_1:'AU',release_dates:[{type:3,release_date:'2025-02-01T00:00:00.000Z'},{type:4,release_date:'2025-03-01'}]}]}}); + h.api.displayReleaseDate('movie',h.container);await h.settle(); + const format=(year,month,day)=>new Date(year,month-1,day).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); + assert.equal(h.container.querySelector('.je-release-date-cinema').textContent.slice('local_movies'.length),format(2025,2,1)); + assert.equal(h.container.querySelector('.je-release-date-digital').textContent.slice('ondemand_video'.length),format(2025,3,1)); + const episode=setup(t,{getItem:async(_user,id)=>id==='series'?{Type:'Series',ProviderIds:{Tmdb:'42'}}:{Type:'Episode',SeriesId:'series',PremiereDate:'2025-02-03T00:00:00.0000000Z'}}); + episode.api.displayReleaseDate('episode',episode.container);await episode.settle(); + assert.equal(episode.container.textContent.slice('tv_guide'.length),format(2025,2,3)); +}); test('malformed external release date cannot inject HTML into details',async t=>{ const malicious='';const h=setup(t,{data:{results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:malicious}]}]}});h.api.displayReleaseDate('movie',h.container);await h.settle();assert.equal(h.container.querySelector('img'),null);assert.ok(h.container.textContent.includes(malicious)); }); From 9096fdef36910950d9ee0db34f90e7bc5d47d1ce Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:10 +0800 Subject: [PATCH 007/111] fix(calendar): parse and send calendar ranges with the invariant culture Under a culture with another calendar (th-TH is Buddhist) the start/end query values were read as Buddhist years and the Shoko range and dedup keys were formatted the same way, so every event fell outside the range. The range is parsed with InvariantCulture (assumed and adjusted to UTC, as before) and the yyyy-MM-dd values are formatted invariantly. A test runs the calendar under th-TH. --- .../Controllers/JellyfinEnhancedController.cs | 14 +++++--- .../Integrations/IntegrationCalendarTests.cs | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs index c7f8f2407..54ae80472 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs @@ -10620,7 +10620,10 @@ public async Task GetCalendarEvents() if (Request.Query.TryGetValue("start", out var startValues)) { - if (DateTime.TryParse(startValues.ToString(), out var parsedStart)) + // Invariant: under a culture with another calendar (th-TH is Buddhist) "2026-10-05" + // would be read as a different year and the range would miss every event. + if (DateTime.TryParse(startValues.ToString(), System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsedStart)) { startDate = parsedStart.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(parsedStart, DateTimeKind.Utc) : parsedStart.ToUniversalTime(); } @@ -10628,7 +10631,8 @@ public async Task GetCalendarEvents() if (Request.Query.TryGetValue("end", out var endValues)) { - if (DateTime.TryParse(endValues.ToString(), out var parsedEnd)) + if (DateTime.TryParse(endValues.ToString(), System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsedEnd)) { endDate = parsedEnd.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(parsedEnd, DateTimeKind.Utc) : parsedEnd.ToUniversalTime(); } @@ -10858,7 +10862,7 @@ static string NormalizeDateForDedup(string? raw) System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dto)) { - return dto.UtcDateTime.ToString("yyyy-MM-dd"); + return dto.UtcDateTime.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); } // Fallback: strip everything after the first 10 chars when it // already looks like an ISO date prefix. @@ -11123,8 +11127,8 @@ static string NormalizeDateForDedup(string? raw) ArrInstance instance, DateTime startDate, DateTime endDate, HashSet includedTypes, Func parseDate, CancellationToken ct) { - var startParam = startDate.ToString("yyyy-MM-dd"); - var endParam = endDate.ToString("yyyy-MM-dd"); + var startParam = startDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + var endParam = endDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); return FetchAndMapAsync>( instance, $"/api/v3/Dashboard/CalendarEpisodes?startDate={startParam}&endDate={endParam}&includeMissing=false&includeRestricted=false", diff --git a/tests/backend/Integrations/IntegrationCalendarTests.cs b/tests/backend/Integrations/IntegrationCalendarTests.cs index f3d4bcc46..f1a2d36ee 100644 --- a/tests/backend/Integrations/IntegrationCalendarTests.cs +++ b/tests/backend/Integrations/IntegrationCalendarTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using System.Text.Json; using Jellyfin.Database.Implementations; @@ -152,6 +153,39 @@ public async Task ThreeSourcesMergeAndMalformedDatesAreSkipped() Assert.Equal(3, transport.Calls); } + [Fact] + public async Task RangeIsParsedAndSentAsGregorianDatesUnderNonGregorianCulture() + { + using var f = new ApiPluginFixture(); Configure(f); + f.Plugin.Configuration.SonarrUrl = "http://127.0.0.1/sonarr"; + f.Plugin.Configuration.SonarrApiKey = "arr-secret"; + f.Plugin.Configuration.RadarrUrl = "http://127.0.0.1/radarr"; + f.Plugin.Configuration.RadarrApiKey = "arr-secret"; + var queries = new List(); + using var transport = new IntegrationTransport((request, _) => + { + lock (queries) queries.Add(request.RequestUri!.AbsolutePath + request.RequestUri.Query); + return Task.FromResult(IntegrationTransport.Response( + request.RequestUri!.AbsolutePath.Contains("/shoko/") ? "[" + Episode("Episode", 1, 42) + "]" : + request.RequestUri.AbsolutePath.Contains("/radarr/") ? "[{\"id\":1,\"title\":\"Movie\",\"digitalRelease\":\"2026-10-10\"}]" : + "[{\"airDateUtc\":\"2026-10-10\",\"series\":{\"title\":\"TV\"}}]")); + }); + var old = CultureInfo.CurrentCulture; + try + { + // th-TH uses the Buddhist calendar: culture-sensitive parsing reads 2026 as a Buddhist year. + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("th-TH"); + var controller = ApiAssetTests.Controller(f.Core, transport, new CalendarDatabase()); + controller.Request.QueryString = new QueryString("?start=2026-10-01&end=2026-11-01"); + var result = Unpack(await controller.GetCalendarEvents()); + Assert.Equal(new[] { "Radarr", "Shoko", "Sonarr" }, result.Events.Select(e => e.Source).OrderBy(x => x)); + Assert.Equal(0, result.Errors.GetArrayLength()); + } + finally { CultureInfo.CurrentCulture = old; } + Assert.Contains(queries, q => q.Contains("/shoko/") && q.Contains("startDate=2026-10-01&endDate=2026-11-01")); + Assert.Contains(queries, q => q.Contains("/sonarr/") && q.Contains("start=2026-10-01T00")); + } + [Theory] [InlineData(true)][InlineData(false)] public async Task PartialConfigurationReturnsErrorWithoutSendingRequest(bool urlOnly) From 71af93e3880b706837dc9523af4c81d25b5429a7 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:10 +0800 Subject: [PATCH 008/111] fix(poster-tags): match ICU when baku1926 sits beside alalc97 or a region alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICU only promotes baku1926 to the Baku script when no other variant becomes alalc97 (heploc) or a region (aaland -> AX): "az-baku1926-heploc" is "Azerbaijani (ALALC97_BAKU1926)" and "az-baku1926-aaland" is "Azerbaijani (Åland Islands, Unified Turkic Latin Alphabet)". The native resolver promoted both to "(Baku)". The generator now includes CLDR's aaland variant alias, the resolver fills an empty region from it, and promotion is skipped beside those variants. The tags are in the parity corpus (aaland across the variant matrix) and match Node 26.2.0 Intl.DisplayNames. --- .../PosterTags/Resolution/LanguageNames.cs | 29 +++++++++++++++++-- .../PosterTags/Resolution/ResolutionData.g.cs | 10 +++++-- .../PosterTags/PosterRegressionTests.cs | 11 +++++++ tests/poster/parity/gen-resolution-data.mjs | 7 +++-- tests/poster/parity/names-expected.mjs | 10 ++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs index d8a0087a3..528cf81bb 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs @@ -118,12 +118,13 @@ internal static class LanguageNames /// /// Variant canonicalization: language+variant aliases (zh-hakka -> hak, art-lojban -> jbo), - /// then the language-independent ones (heploc -> alalc97, lojban dropped; baku1926 becomes the - /// Baku script when no script or region is present after language alias expansion). + /// then the language-independent ones (heploc -> alalc97, lojban dropped, aaland fills an empty + /// region with AX; baku1926 becomes the Baku script when no script or region is present after + /// language alias expansion and no other variant becomes alalc97 or a region). /// private static List? ApplyVariantAliases(string originalLanguage, List variants, ref string language, ref string? script, ref string? region) { - var canUseVariantScript = script is null && region is null; + var canUseVariantScript = script is null && region is null && !variants.Exists(BlocksVariantScript); var result = new List(variants.Count); foreach (var variant in variants) { @@ -145,6 +146,11 @@ internal static class LanguageNames { result.Add(variant); // posix stays a variant, named "Computer" } + else if (IsRegionSubtag(replacement)) + { + // A region alias fills an empty region and is otherwise dropped (en-US-aaland -> en-US). + region ??= replacement; + } else if (replacement.Length == 4 && char.IsAsciiLetterUpper(replacement[0])) { if (canUseVariantScript) @@ -166,6 +172,23 @@ internal static class LanguageNames return result.Count == 0 ? null : result; } + /// + /// True for a variant that keeps ICU from promoting baku1926 to the Baku script: one that is or + /// becomes alalc97 (heploc), or one that becomes a region (aaland). The tag then keeps + /// baku1926 as a variant ("az-baku1926-heploc" is "Azerbaijani (ALALC97_BAKU1926)"). + /// + private static bool BlocksVariantScript(string variant) + { + if (variant == "alalc97") return true; + return GenericVariantAliases.TryGetValue(variant, out var replacement) + && (replacement == "alalc97" || IsRegionSubtag(replacement)); + } + + /// True for a canonical region subtag: two upper-case letters or three digits. + private static bool IsRegionSubtag(string subtag) + => (subtag.Length == 2 && char.IsAsciiLetterUpper(subtag[0]) && char.IsAsciiLetterUpper(subtag[1])) + || (subtag.Length == 3 && char.IsAsciiDigit(subtag[0]) && char.IsAsciiDigit(subtag[1]) && char.IsAsciiDigit(subtag[2])); + /// /// The canonical base language Intl.getCanonicalLocales gives a 2-3 letter code /// (iw -> he, cmn -> zh, sh -> sr), or the code itself. diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs index e61b62314..031f1606a 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs @@ -2805,8 +2805,9 @@ internal static class ResolutionData "valencia\tValencian\n" + "wadegile\tWade-Giles romanization"; - /// Language-independent variant aliases: replacement subtags, empty when the variant is dropped. 11 rows of key TAB value, newline separated. + /// Language-independent variant aliases: replacement subtags, empty when the variant is dropped. 12 rows of key TAB value, newline separated. internal const string GenericVariantAliases = + "aaland\tAX\n" + "arevela\t\n" + "arevmda\t\n" + "baku1926\tBaku\n" + @@ -2819,7 +2820,7 @@ internal static class ResolutionData "posix\tu-va-posix\n" + "xiang\t"; - /// Language+variant pairs Intl canonicalizes beyond the generic rules (art-lojban -> jbo). 620 rows of key TAB value, newline separated. + /// Language+variant pairs Intl canonicalizes beyond the generic rules (art-lojban -> jbo). 625 rows of key TAB value, newline separated. internal const string VariantAliases = "arm-arevmda\thyw\n" + "art-lojban\tjbo\n" + @@ -2836,6 +2837,7 @@ internal static class ResolutionData "cnr-1959acad\tsr-ME-1959acad\n" + "cnr-1994\tsr-ME-1994\n" + "cnr-1996\tsr-ME-1996\n" + + "cnr-aaland\tsr-ME\n" + "cnr-abl1943\tsr-ME-abl1943\n" + "cnr-akuapem\tsr-ME-akuapem\n" + "cnr-alalc97\tsr-ME-alalc97\n" + @@ -2955,6 +2957,7 @@ internal static class ResolutionData "drw-1959acad\tfa-AF-1959acad\n" + "drw-1994\tfa-AF-1994\n" + "drw-1996\tfa-AF-1996\n" + + "drw-aaland\tfa-AF\n" + "drw-abl1943\tfa-AF-abl1943\n" + "drw-akuapem\tfa-AF-akuapem\n" + "drw-alalc97\tfa-AF-alalc97\n" + @@ -3080,6 +3083,7 @@ internal static class ResolutionData "prs-1959acad\tfa-AF-1959acad\n" + "prs-1994\tfa-AF-1994\n" + "prs-1996\tfa-AF-1996\n" + + "prs-aaland\tfa-AF\n" + "prs-abl1943\tfa-AF-abl1943\n" + "prs-akuapem\tfa-AF-akuapem\n" + "prs-alalc97\tfa-AF-alalc97\n" + @@ -3203,6 +3207,7 @@ internal static class ResolutionData "swc-1959acad\tsw-CD-1959acad\n" + "swc-1994\tsw-CD-1994\n" + "swc-1996\tsw-CD-1996\n" + + "swc-aaland\tsw-CD\n" + "swc-abl1943\tsw-CD-abl1943\n" + "swc-akuapem\tsw-CD-akuapem\n" + "swc-alalc97\tsw-CD-alalc97\n" + @@ -3322,6 +3327,7 @@ internal static class ResolutionData "tnf-1959acad\tfa-AF-1959acad\n" + "tnf-1994\tfa-AF-1994\n" + "tnf-1996\tfa-AF-1996\n" + + "tnf-aaland\tfa-AF\n" + "tnf-abl1943\tfa-AF-abl1943\n" + "tnf-akuapem\tfa-AF-akuapem\n" + "tnf-alalc97\tfa-AF-alalc97\n" + diff --git a/tests/backend/PosterTags/PosterRegressionTests.cs b/tests/backend/PosterTags/PosterRegressionTests.cs index b2727a524..967841805 100644 --- a/tests/backend/PosterTags/PosterRegressionTests.cs +++ b/tests/backend/PosterTags/PosterRegressionTests.cs @@ -207,6 +207,17 @@ public void Shared_corner_groups_stack_without_overlap(PosterTagCorner corner) [InlineData("prs-baku1926", "Dari (Unified Turkic Latin Alphabet)")] [InlineData("sh-baku1926", "Serbian (Latin, Unified Turkic Latin Alphabet)")] [InlineData("cnr-baku1926-scouse", "Montenegrin (BAKU1926_SCOUSE)")] + // Baku is not promoted to a script beside a variant that maps to alalc97 or to a region. + [InlineData("az-baku1926-heploc", "Azerbaijani (ALALC97_BAKU1926)")] + [InlineData("tk-baku1926-heploc", "Turkmen (ALALC97_BAKU1926)")] + [InlineData("az-heploc-baku1926", "Azerbaijani (ALALC97_BAKU1926)")] + [InlineData("az-baku1926-alalc97", "Azerbaijani (ALALC97_BAKU1926)")] + [InlineData("az-baku1926-heploc-fonipa", "Azerbaijani (ALALC97_BAKU1926_FONIPA)")] + [InlineData("az-baku1926-aaland", "Azerbaijani (Åland Islands, Unified Turkic Latin Alphabet)")] + [InlineData("az-aaland-baku1926", "Azerbaijani (Åland Islands, Unified Turkic Latin Alphabet)")] + [InlineData("az-baku1926-aaland-heploc", "Azerbaijani (Åland Islands, ALALC97_BAKU1926)")] + [InlineData("az-baku1926-fonipa", "Azerbaijani (Baku)")] + [InlineData("az-aaland", "Azerbaijani (Åland Islands)")] public void Language_name_variant_aliases_match_browser_display_names(string code, string expected) { Assert.Equal(expected, Jellyfin.Plugin.JellyfinEnhanced.Services.PosterTags.Resolution.LanguageNames.Of(code)); diff --git a/tests/poster/parity/gen-resolution-data.mjs b/tests/poster/parity/gen-resolution-data.mjs index 3b7554e6e..29dfdf729 100644 --- a/tests/poster/parity/gen-resolution-data.mjs +++ b/tests/poster/parity/gen-resolution-data.mjs @@ -163,15 +163,16 @@ const languageRegionAliases = new Map(await scanInWorkers('languageRegion', allC // 8. Variant subtags: IANA-registered variants (vendored list, language-subtag-registry // 2023-10-16) plus CLDR's "posix". Names as used inside a language name, the -// language-independent variant aliases (und-heploc -> und-alalc97, und-lojban -> und), -// and the language+variant pairs Intl canonicalizes beyond those (art-lojban -> jbo). +// language-independent variant aliases (und-heploc -> und-alalc97, und-lojban -> und, +// CLDR's und-aaland -> und-AX region alias), and the language+variant pairs Intl +// canonicalizes beyond those (art-lojban -> jbo). const VARIANTS = ('1606nict 1694acad 1901 1959acad 1994 1996 abl1943 akuapem alalc97 aluku ao1990 aranes arevela arevmda arkaika asante ' + 'auvern baku1926 balanka barla basiceng bauddha bciav bcizbl biscayan biske blasl bohoric boont bornholm cisaup colb1945 cornu creiss ' + 'dajnko ekavsk emodeng fonipa fonkirsh fonnapa fonupa fonxsamp gallo gascon grclass grital grmistr hepburn heploc hognorsk hsistemo ' + 'ijekavsk itihasa ivanchov jauer jyutping kkcor kociewie kscor laukika lemosin lengadoc lipaw ltg1929 ltg2007 luna1918 metelko monoton ' + 'ndyuka nedis newfound nicard njiva nulik osojs oxendict pahawh2 pahawh3 pahawh4 pamaka peano petr1708 pinyin polyton provenc puter ' + 'rigik rozaj rumgr scotland scouse simple solba sotav spanglis surmiran sursilv sutsilv synnejyl tarask tongyong tunumiit uccor ucrcor ' + - 'ulster unifon vaidika valencia vallader vecdruka vivaraup wadegile xsistemo posix lojban guoyu hakka xiang gaulish').split(' '); + 'ulster unifon vaidika valencia vallader vecdruka vivaraup wadegile xsistemo posix lojban guoyu hakka xiang gaulish aaland').split(' '); const variantNames = new Map(); const genericVariantAliases = new Map(); const VARIANT_PREFIX = `${ROOT_PREFIX}${contextName('ZZ')}, `; diff --git a/tests/poster/parity/names-expected.mjs b/tests/poster/parity/names-expected.mjs index b1459124b..78433b86a 100644 --- a/tests/poster/parity/names-expected.mjs +++ b/tests/poster/parity/names-expected.mjs @@ -32,7 +32,7 @@ for (const a of A) { } } } -const variants = ['1901', '1994', 'valencia', 'posix', 'lojban', 'arevela', 'arevmda', 'heploc', 'baku1926', 'pinyin', 'scouse', 'rozaj', 'biske', 'guoyu', 'hakka', 'xiang', 'gaulish', 'abcde', 'fonipa']; +const variants = ['1901', '1994', 'valencia', 'posix', 'lojban', 'arevela', 'arevmda', 'heploc', 'baku1926', 'pinyin', 'scouse', 'rozaj', 'biske', 'guoyu', 'hakka', 'xiang', 'gaulish', 'abcde', 'fonipa', 'aaland']; for (const lang of ['en', 'de', 'ca', 'sl', 'zh', 'hy', 'arm', 'art', 'cel', 'prs', 'cnr', 'bh', 'tw', 'sh', 'iw', 'aa', 'und', 'xyz', 'sr', 'az', 'ja']) { for (const variant of variants) { codes.add(`${lang}-${variant}`); @@ -75,6 +75,14 @@ for (const code of ['pt_BR', 'root', 'e', 'en--us', '-en', 'en-', 'x-klingon', ' codes.add(code); } +// baku1926 becomes the Baku script only when no other variant becomes alalc97 or a region. +for (const lang of ['az', 'tk', 'en', 'sh', 'prs', 'sgn', 'und']) { + for (const tail of ['baku1926-heploc', 'heploc-baku1926', 'baku1926-alalc97', 'alalc97-baku1926', 'baku1926-aaland', 'aaland-baku1926', + 'baku1926-aaland-heploc', 'baku1926-heploc-fonipa', 'baku1926-fonipa', 'US-baku1926-heploc', 'Latn-baku1926-heploc']) { + codes.add(`${lang}-${tail}`); + } +} + const expected = {}; for (const code of codes) { let name; From f91bdf95ef38b2fbe29be36210ef8771eaa04a28 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:21 +0800 Subject: [PATCH 009/111] fix(spoiler-guard): stop pending-promotion sweeps with the service Sweeps ran on fire-and-forget tasks after a 2 s settle delay, so they could outlive StopAsync and still read and write user files, and the promoter test asserted Bob's state while the sweep could still be running. StopAsync now cancels sweeps that are still settling and waits for a running one to finish. An internal WaitForSweepsAsync lets tests wait for the sweep instead of racing it, and the settle delay is an internal property tests set to zero; production keeps 2 s. --- .../SpoilerSeerrPendingPromoter.cs | 47 ++++++++++-- .../backend/Privacy/PrivacyAutoEnableTests.cs | 73 +++++++++++++++---- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs index 79e21897e..b20038dc1 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs @@ -69,6 +69,15 @@ private static readonly ConcurrentDictionary _sweepRerun // needs to flip before the user next browses the title. private const int SweepSettleDelayMs = 2000; + /// The settle delay before a scheduled sweep runs (tests shorten it). + internal TimeSpan SweepSettleDelay { get; set; } = TimeSpan.FromMilliseconds(SweepSettleDelayMs); + + // Sweeps this instance scheduled and that have not finished yet. StopAsync + // cancels the waiting ones and awaits the rest, so no sweep reads or writes + // user files after the service has stopped. + private readonly ConcurrentDictionary _activeSweeps = new(); + private readonly CancellationTokenSource _stopping = new(); + // Exposed so the controller's POST/DELETE endpoints can keep the gate // accurate without coupling to the hosted-service instance. public static void RegisterPending(string pendingKey, Guid userId) @@ -150,11 +159,29 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { _libraryManager.ItemAdded -= OnItemAdded; _libraryManager.ItemUpdated -= OnItemAdded; - return Task.CompletedTask; + _stopping.Cancel(); + try + { + await WaitForSweepsAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The host gave up waiting; the sweep finishes its current user on its own. + } + } + + /// Completes once no sweep scheduled by this instance is waiting or running. + internal async Task WaitForSweepsAsync(CancellationToken cancellationToken = default) + { + // A finishing sweep can schedule a follow-up, so wait until none is left. + while (!_activeSweeps.IsEmpty) + { + await Task.WhenAll(_activeSweeps.Keys).WaitAsync(cancellationToken).ConfigureAwait(false); + } } // Best-effort startup scan — corrupt or missing files are skipped on @@ -241,19 +268,25 @@ private void OnItemAdded(object? sender, ItemChangeEventArgs e) // racing the scanner. private void ScheduleSweep(string pendingKey, Guid itemId, string itemName, bool isSeries) { + var stopping = _stopping.Token; + if (stopping.IsCancellationRequested) return; _sweepRerun[pendingKey] = 0; if (!_sweepRunning.TryAdd(pendingKey, 0)) return; // active sweep picks up the rerun flag - _ = Task.Run(async () => + var sweep = Task.Run(async () => { try { - await Task.Delay(SweepSettleDelayMs).ConfigureAwait(false); - while (_sweepRerun.TryRemove(pendingKey, out _)) + await Task.Delay(SweepSettleDelay, stopping).ConfigureAwait(false); + while (!stopping.IsCancellationRequested && _sweepRerun.TryRemove(pendingKey, out _)) { SweepPendingUsers(pendingKey, itemId, itemName, isSeries); } } + catch (OperationCanceledException) when (stopping.IsCancellationRequested) + { + // Stopped while settling; the pending keys stay on disk for the next start. + } catch (Exception ex) { _logger.Warning($"SpoilerSeerrPromoter: sweep for {pendingKey} failed: {ex.Message}"); @@ -269,7 +302,9 @@ private void ScheduleSweep(string pendingKey, Guid itemId, string itemName, bool ScheduleSweep(pendingKey, itemId, itemName, isSeries); } } - }); + }, CancellationToken.None); + _activeSweeps.TryAdd(sweep, 0); + _ = sweep.ContinueWith(finished => _activeSweeps.TryRemove(finished, out _), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } private void SweepPendingUsers(string pendingKey, Guid itemId, string itemName, bool isSeries) diff --git a/tests/backend/Privacy/PrivacyAutoEnableTests.cs b/tests/backend/Privacy/PrivacyAutoEnableTests.cs index 8cabe2f5b..ad8aef63c 100644 --- a/tests/backend/Privacy/PrivacyAutoEnableTests.cs +++ b/tests/backend/Privacy/PrivacyAutoEnableTests.cs @@ -78,10 +78,24 @@ public async Task FirstPlayConsumerOnlyArmsUnseenS1E1(int season, int episodeNum if (expected) Assert.Equal("Series", saved!.Series[series.Id.ToString("N")].SeriesName); } - [Fact] - public async Task PendingPromoterRestoresFromDiskAndPromotesOnlyAccessibleUserOnLibraryEvent() + private sealed record PendingSetup(JE.Tests.ApiPluginFixture Fixture, User Alice, User Bob, Series Series, string PendingKey, Mock Library, SpoilerSeerrPendingPromoter Service) : IAsyncDisposable + { + public UserSpoilerBlur Saved(User user) => Fixture.Core.Manager.GetUserConfiguration(user.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName)!; + public async ValueTask DisposeAsync() + { + try { await Service.StopAsync(CancellationToken.None); } + finally + { + SpoilerSeerrPendingPromoter.UnregisterPending(PendingKey, Alice.Id); SpoilerSeerrPendingPromoter.UnregisterPending(PendingKey, Bob.Id); + Fixture.Dispose(); + } + } + } + + // Alice and Bob both have the series pending on disk; only Alice can see it. + private static PendingSetup SetUpPending() { - using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.SpoilerBlurEnabled = true; + var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.SpoilerBlurEnabled = true; var alice = new User("alice", "default", "default") { Id = Guid.NewGuid() }; var bob = new User("bob", "default", "default") { Id = Guid.NewGuid() }; var tmdb = Interlocked.Increment(ref _tmdbId).ToString(); var pendingKey = "tv:" + tmdb; var series = new Series { Id = Guid.NewGuid(), Name = "Acquired series", ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Tmdb"] = tmdb } }; @@ -91,17 +105,46 @@ public async Task PendingPromoterRestoresFromDiskAndPromotesOnlyAccessibleUserOn } var users = new Mock(); users.Setup(x => x.GetUserById(alice.Id)).Returns(alice); users.Setup(x => x.GetUserById(bob.Id)).Returns(bob); var library = new Mock(); library.Setup(x => x.GetItemById(series.Id, alice)).Returns(series); library.Setup(x => x.GetItemById(series.Id, bob)).Returns((BaseItem?)null); - var promoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - f.Core.Manager.UserConfigurationSaved += (id, file) => { if (id == alice.Id.ToString("N") && file == SpoilerBlurImageFilter.SpoilerBlurFileName) promoted.TrySetResult(); }; - var service = new SpoilerSeerrPendingPromoter(library.Object, users.Object, f.Core.Manager, f.Core.Paths.Object, f.Core.Logger); - try { - await service.StartAsync(CancellationToken.None); - library.Raise(x => x.ItemUpdated += null, library.Object, new ItemChangeEventArgs { Item = series }); - await promoted.Task.WaitAsync(TimeSpan.FromSeconds(15)); - var savedAlice = f.Core.Manager.GetUserConfiguration(alice.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName)!; - var savedBob = f.Core.Manager.GetUserConfiguration(bob.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName)!; - Assert.Empty(savedAlice.PendingTmdb); Assert.Equal("Acquired series", savedAlice.Series[series.Id.ToString("N")].SeriesName); - Assert.Contains(pendingKey, savedBob.PendingTmdb.Keys); Assert.Empty(savedBob.Series); - } finally { await service.StopAsync(CancellationToken.None); SpoilerSeerrPendingPromoter.UnregisterPending(pendingKey, alice.Id); SpoilerSeerrPendingPromoter.UnregisterPending(pendingKey, bob.Id); } + var service = new SpoilerSeerrPendingPromoter(library.Object, users.Object, f.Core.Manager, f.Core.Paths.Object, f.Core.Logger) { SweepSettleDelay = TimeSpan.Zero }; + return new PendingSetup(f, alice, bob, series, pendingKey, library, service); + } + + [Fact] + public async Task PendingPromoterRestoresFromDiskAndPromotesOnlyAccessibleUserOnLibraryEvent() + { + await using var p = SetUpPending(); + await p.Service.StartAsync(CancellationToken.None); + p.Library.Raise(x => x.ItemUpdated += null, p.Library.Object, new ItemChangeEventArgs { Item = p.Series }); + // Every user of the sweep has been handled once it has finished. + await p.Service.WaitForSweepsAsync().WaitAsync(TimeSpan.FromSeconds(15)); + var savedAlice = p.Saved(p.Alice); var savedBob = p.Saved(p.Bob); + Assert.Empty(savedAlice.PendingTmdb); Assert.Equal("Acquired series", savedAlice.Series[p.Series.Id.ToString("N")].SeriesName); + Assert.Contains(p.PendingKey, savedBob.PendingTmdb.Keys); Assert.Empty(savedBob.Series); + } + + [Fact] + public async Task PendingPromoterStopWaitsForARunningSweepAndCancelsSettlingOnes() + { + await using var p = SetUpPending(); + using var entered = new SemaphoreSlim(0); using var release = new SemaphoreSlim(0); + p.Library.Setup(x => x.GetItemById(p.Series.Id, p.Alice)).Returns(() => { entered.Release(); release.Wait(); return p.Series; }); + await p.Service.StartAsync(CancellationToken.None); + p.Library.Raise(x => x.ItemUpdated += null, p.Library.Object, new ItemChangeEventArgs { Item = p.Series }); + Assert.True(await entered.WaitAsync(TimeSpan.FromSeconds(15))); + var stop = p.Service.StopAsync(CancellationToken.None); + await Task.Delay(100); + Assert.False(stop.IsCompleted); + release.Release(); + await stop.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Empty(p.Saved(p.Alice).PendingTmdb); + + // A sweep still in its settle delay is dropped by StopAsync rather than run later. + await using var q = SetUpPending(); + q.Service.SweepSettleDelay = TimeSpan.FromMinutes(5); + await q.Service.StartAsync(CancellationToken.None); + q.Library.Raise(x => x.ItemUpdated += null, q.Library.Object, new ItemChangeEventArgs { Item = q.Series }); + await q.Service.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(15)); + await q.Service.WaitForSweepsAsync().WaitAsync(TimeSpan.FromSeconds(1)); + Assert.Contains(q.PendingKey, q.Saved(q.Alice).PendingTmdb.Keys); } } From 4ade06d76801d99cc2f5d205d906b86df44067f0 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:21 +0800 Subject: [PATCH 010/111] test(cdn): skip the award-logo pacing delay in the refresh test The full-refresh test slept a real 2 s between consecutive award logos (about 20 s per run). The delay is now an internal property, still 2 s in production, that the test sets to zero. --- Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs | 5 ++++- tests/backend/Api/ApiCdnRefreshTests.cs | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs index efd9e45a8..ae8f041f6 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs @@ -460,7 +460,10 @@ private static void RecordNegative(string key) // is a deliberately conservative guess rather than a tuned value — cheap to // be generous here since it only adds a few seconds to a background refresh // that runs once at startup and once every 24h, not on the request path. - private static readonly TimeSpan BurstSensitiveDelay = TimeSpan.FromSeconds(2); + private static readonly TimeSpan DefaultBurstSensitiveDelay = TimeSpan.FromSeconds(2); + + /// Pause between consecutive burst-sensitive downloads (tests set it to zero). + internal TimeSpan BurstSensitiveDelay { get; set; } = DefaultBurstSensitiveDelay; /// Forces a fresh download of every entry. public async Task RefreshKnownAsync(IProgress? progress, CancellationToken cancellationToken) diff --git a/tests/backend/Api/ApiCdnRefreshTests.cs b/tests/backend/Api/ApiCdnRefreshTests.cs index 5255247d5..e2e29446c 100644 --- a/tests/backend/Api/ApiCdnRefreshTests.cs +++ b/tests/backend/Api/ApiCdnRefreshTests.cs @@ -26,7 +26,8 @@ public async Task FullKnownAssetRefreshContinuesAfterHttpTimeoutAndReportsComple }; return Task.FromResult(IntegrationTransport.Response("synthetic-asset", type: mime)); }); - var service = new CdnAssetService(f.Logger, transport, f.Paths.Object); + // The real 2 s pause between award logos is production pacing, not behavior under test. + var service = new CdnAssetService(f.Logger, transport, f.Paths.Object) { BurstSensitiveDelay = TimeSpan.Zero }; var progress = new Reports(); await service.RefreshKnownAsync(progress, CancellationToken.None); Assert.Equal(CdnAssetService.KnownAssets.Count, calls); From ce88c3ba1d65ff1c4055d2f986bc12851a0f7426 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:21 +0800 Subject: [PATCH 011/111] test(backend): make vacuous and date-sensitive backend tests able to fail - Auto requests: configure TMDB and Seerr so only the switches can stop a request, count user lookups and HTTP calls, and add an enabled control. - Maintenance: a selection with no valid id must not target everyone. - Seerr tasks: isolate the JellyseerrEnabled master switch for user import and both watchlist syncs, with an enabled control; the missing-credentials test now also enables the Seerr watchlist sync. - Awards: hold the first Wikidata request open so the coalescing test really overlaps 20 callers. - TMDB cache: assert a hit one tick before the 30 minute expiry. - Logger and usage-period tests no longer fail when a run crosses midnight. --- tests/backend/Core/CoreAwardsTests.cs | 18 +++++++-- tests/backend/Core/CoreMaintenanceTests.cs | 10 +++++ tests/backend/Core/CorePersistenceTests.cs | 3 +- tests/backend/Core/CoreUsageTests.cs | 6 ++- .../Integrations/IntegrationTmdbTests.cs | 5 ++- .../ScheduledTasks/TaskAutoRequestTests.cs | 28 ++++++++++--- .../ScheduledTasks/TaskRegressionTests.cs | 40 +++++++++++++++++++ 7 files changed, 97 insertions(+), 13 deletions(-) diff --git a/tests/backend/Core/CoreAwardsTests.cs b/tests/backend/Core/CoreAwardsTests.cs index cc243c919..0b3a0fb5b 100644 --- a/tests/backend/Core/CoreAwardsTests.cs +++ b/tests/backend/Core/CoreAwardsTests.cs @@ -10,12 +10,17 @@ public class CoreAwardsTests private sealed class Handler(string body, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler { public int Calls; - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + /// When set, every request waits for it, so concurrent callers overlap for real. + public TaskCompletionSource? Release; + public readonly SemaphoreSlim Entered = new(0); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref Calls); Assert.Equal("query.wikidata.org", request.RequestUri!.Host); Assert.Contains("application/sparql-results+json", request.Headers.Accept.ToString()); - return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + Entered.Release(); + if (Release != null) await Release.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } } private static Mock Factory(Handler handler) @@ -29,11 +34,16 @@ private static Mock Factory(Handler handler) public async Task ConfirmedEmptyResultCoalescesAndPersistsAcrossRestart() { using var f = new CoreFixture(); - using var handler = new Handler("{\"results\":{\"bindings\":[]}}"); + using var handler = new Handler("{\"results\":{\"bindings\":[]}}") { Release = new(TaskCreationOptions.RunContinuationsAsynchronously) }; var factory = Factory(handler); using (var service = new WikidataAwardsService(factory.Object, f.Paths.Object, f.Logger)) { - var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => service.GetAwardsAsync("movie", "123", default))); + // Hold the first request open while the other 19 callers arrive; none may start its own. + var pending = Enumerable.Range(0, 20).Select(_ => service.GetAwardsAsync("movie", "123", default)).ToArray(); + Assert.True(await handler.Entered.WaitAsync(TimeSpan.FromSeconds(15))); + Assert.False(await handler.Entered.WaitAsync(TimeSpan.FromMilliseconds(250))); + handler.Release.SetResult(); + var results = await Task.WhenAll(pending); Assert.All(results, r => { Assert.False(r.Found); Assert.True(r.Confirmed); }); Assert.Equal(1, handler.Calls); } diff --git a/tests/backend/Core/CoreMaintenanceTests.cs b/tests/backend/Core/CoreMaintenanceTests.cs index df2e1aff5..75c415998 100644 --- a/tests/backend/Core/CoreMaintenanceTests.cs +++ b/tests/backend/Core/CoreMaintenanceTests.cs @@ -82,6 +82,16 @@ public async Task SelectionReconciliationRestoresPreviousActionAndInvalidIdsDoNo Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); + // A selection with no valid id at all must not fall back to "every user". + await f.Service.EnableAsync("", 0, "both", ["invalid"]); + Assert.True(f.Service.GetStatus().IsActive); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[f.Alice.Id].EnableRemoteAccess); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.False(f.Policies[f.Restricted.Id].EnableRemoteAccess); + Assert.False(f.Policies[f.Admin.Id].IsDisabled); + Assert.Empty(f.Service.GetStatus().AccountDisabledUserIds); + Assert.Empty(f.Service.GetStatus().RemoteDisabledUserIds); } [Fact] diff --git a/tests/backend/Core/CorePersistenceTests.cs b/tests/backend/Core/CorePersistenceTests.cs index 729744caa..8316602e6 100644 --- a/tests/backend/Core/CorePersistenceTests.cs +++ b/tests/backend/Core/CorePersistenceTests.cs @@ -124,7 +124,8 @@ public void LoggerEscapesLineBreaksToPreventForgedEntries() { using var f = new CoreFixture(); f.Logger.Warning("untrusted\r\n[INFO] forged"); - var lines = File.ReadAllLines(f.Logger.CurrentLogFilePath); + // The file name carries the date; whichever file the line landed in, it is the only one. + var lines = File.ReadAllLines(Assert.Single(Directory.GetFiles(f.Root, "*.log"))); Assert.Single(lines); Assert.Contains("untrusted\\r\\n[INFO] forged", lines[0]); } diff --git a/tests/backend/Core/CoreUsageTests.cs b/tests/backend/Core/CoreUsageTests.cs index d611de15e..9d6dfd7cd 100644 --- a/tests/backend/Core/CoreUsageTests.cs +++ b/tests/backend/Core/CoreUsageTests.cs @@ -71,8 +71,12 @@ public void PeriodUsesGregorianCalendarUnderNonGregorianCulture() try { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("th-TH"); + // Read the date on both sides so a run crossing UTC midnight still passes. + var before = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); using var service = new UsageEventCounterService(f.Paths.Object, f.Logger); - Assert.Equal(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), service.GetSnapshot().PeriodStart); + var period = service.GetSnapshot().PeriodStart; + var after = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + Assert.Contains(period, new[] { before, after }); } finally { CultureInfo.CurrentCulture = old; } } diff --git a/tests/backend/Integrations/IntegrationTmdbTests.cs b/tests/backend/Integrations/IntegrationTmdbTests.cs index 5edd5531d..64f3a72c8 100644 --- a/tests/backend/Integrations/IntegrationTmdbTests.cs +++ b/tests/backend/Integrations/IntegrationTmdbTests.cs @@ -22,7 +22,10 @@ public async Task CacheSeparatesCredentialsQueryAndPathAndExpiresAtBoundary() await cache.GetAsync("search/tv", "?query=a", "key", default); await cache.GetAsync("search/movie", "?query=a", "other", default); Assert.Equal(4, transport.Calls); - clock.Now += TimeSpan.FromMinutes(30); + clock.Now += TimeSpan.FromMinutes(30) - TimeSpan.FromTicks(1); + Assert.Same(first, await cache.GetAsync("search/movie", "?query=a", "key", default)); + Assert.Equal(4, transport.Calls); + clock.Now += TimeSpan.FromTicks(1); Assert.NotSame(first, await cache.GetAsync("search/movie", "?query=a", "key", default)); Assert.Equal(5, transport.Calls); } diff --git a/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs b/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs index 8d764feae..d89bca66d 100644 --- a/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs +++ b/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs @@ -79,15 +79,31 @@ public async Task MovieCollectionDecisionsDeduplicateSuccessAndRetryFailure(int } [Theory] - [InlineData(false, true)][InlineData(true, false)] + [InlineData(false, true)][InlineData(true, false)][InlineData(true, true)] public async Task DisabledAutomaticRequestsDoNotConsultUsersOrNetwork(bool feature, bool seerr) { using var f = new ApiPluginFixture(); - f.Plugin.Configuration.AutoMovieRequestEnabled = feature; - f.Plugin.Configuration.AutoSeasonRequestEnabled = feature; - f.Plugin.Configuration.JellyseerrEnabled = seerr; - await new AutoMovieRequestService(null!, f.Core.Logger, null!, null!).CheckMovieForCollectionRequestAsync(new Movie(), Guid.NewGuid()); - await new AutoSeasonRequestService(null!, f.Core.Logger, null!, null!, null!).CheckEpisodeCompletionAsync(new Episode(), Guid.NewGuid()); + var config = f.Plugin.Configuration; + config.AutoMovieRequestEnabled = feature; + config.AutoSeasonRequestEnabled = feature; + config.JellyseerrEnabled = seerr; + // Everything else a request needs is configured, so only the switches can stop it. + config.TMDB_API_KEY = "test-tmdb"; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "test-seerr"; + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + var users = new Mock(); + users.Setup(u => u.GetUserById(user.Id)).Returns(user); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response("{}", 404))); + var movie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }; + await new AutoMovieRequestService(transport, f.Core.Logger, users.Object, null!).CheckMovieForCollectionRequestAsync(movie, user.Id); + var movieCalls = transport.Calls; + await new AutoSeasonRequestService(transport, f.Core.Logger, users.Object, null!, null!).CheckEpisodeCompletionAsync(new Episode(), user.Id); + var enabled = feature && seerr; + // The enabled control proves this setup reaches the user lookup and the TMDB request. + users.Verify(u => u.GetUserById(user.Id), enabled ? Times.Exactly(2) : Times.Never()); + Assert.Equal(enabled ? 1 : 0, movieCalls); + Assert.Equal(movieCalls, transport.Calls); } [Theory] [InlineData(7, 10, 1, false, 0)] diff --git a/tests/backend/ScheduledTasks/TaskRegressionTests.cs b/tests/backend/ScheduledTasks/TaskRegressionTests.cs index 99e6d3989..747452931 100644 --- a/tests/backend/ScheduledTasks/TaskRegressionTests.cs +++ b/tests/backend/ScheduledTasks/TaskRegressionTests.cs @@ -200,6 +200,7 @@ public async Task EnabledSeerrTasksWithoutCredentialsDoNotTouchLibraryOrNetwork( config.JellyseerrEnabled = true; config.JellyseerrAutoImportUsers = true; config.SyncJellyfinWatchlistToSeerr = true; + config.SyncJellyseerrWatchlist = true; config.AddRequestedMediaToWatchlist = true; config.JellyseerrUrls = url; config.JellyseerrApiKey = apiKey; @@ -214,4 +215,43 @@ public async Task EnabledSeerrTasksWithoutCredentialsDoNotTouchLibraryOrNetwork( } } + [Theory] + [InlineData(false)][InlineData(true)] + public async Task SeerrMasterSwitchAloneKeepsSeerrTasksIdle(bool seerrEnabled) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + // Every task flag on and valid credentials: only the master switch differs. + config.JellyseerrEnabled = seerrEnabled; + config.JellyseerrAutoImportUsers = true; + config.SyncJellyfinWatchlistToSeerr = true; + config.SyncJellyseerrWatchlist = true; + config.JellyseerrUrls = "http://seerr.test"; + config.JellyseerrApiKey = "seerr-key"; + foreach (var name in new[] { "import", "export", "watchlist" }) + { + var library = new Mock(); var users = new Mock(); + var data = new Mock(); var http = new Mock(); + IScheduledTask task = name switch + { + "import" => new JellyseerrUserImportTask(users.Object, http.Object, f.Core.Logger), + "export" => new JellyfinToSeerrWatchlistSyncTask(library.Object, users.Object, data.Object, http.Object, f.Core.Manager, f.Core.Logger), + _ => new JellyseerrWatchlistSyncTask(library.Object, users.Object, data.Object, http.Object, f.Core.Manager, f.Core.Logger), + }; + var progress = new ProgressLog(); + try { await task.ExecuteAsync(progress, default); } + catch (Exception) when (seerrEnabled) { /* the unconfigured fakes may fail a running task */ } + var touched = library.Invocations.Count + users.Invocations.Count + data.Invocations.Count + http.Invocations.Count; + if (seerrEnabled) + { + // Control: with the switch on, the same setup reaches the task's dependencies. + Assert.True(touched > 0, name + " never ran with Seerr enabled"); + } + else + { + Assert.Equal(0, touched); + Assert.Equal(100, Assert.Single(progress.Values)); + } + } + } } From 421b9aa972a2531f5d3fadc08f1be609f5516fd0 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:34 +0800 Subject: [PATCH 012/111] test(frontend): run modules like the bundle, pin zone and locale, add timeouts - load() wraps each file the way Services/ClientScriptBundle.cs does, and a smoke test loads every js/component-scripts.json module in manifest order with that wrapper, so order or file-scope mistakes fail. - Both runners (and frontend mutation replays) pin TZ=UTC and an en_US.UTF-8 locale, and give each test a 20 second timeout. - An expectConsoleError that never matched now fails teardown; allowConsoleError covers diagnostics that may or may not occur. --- tests/frontend/README.md | 14 +++++++-- .../bootstrap/bootstrap-manifest.test.mjs | 31 +++++++++++++++++++ tests/frontend/helpers/coverage.mjs | 4 +-- tests/frontend/helpers/discovery.mjs | 8 +++++ tests/frontend/helpers/harness.mjs | 22 +++++++++---- tests/frontend/run.mjs | 6 ++-- tests/runner/mutations.py | 5 ++- 7 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 tests/frontend/bootstrap/bootstrap-manifest.test.mjs diff --git a/tests/frontend/README.md b/tests/frontend/README.md index bdf05a3bd..509a37059 100644 --- a/tests/frontend/README.md +++ b/tests/frontend/README.md @@ -35,8 +35,18 @@ DOM observer fix: a normal subscriber left at the start of the queue prevented remaining high-priority subscribers from running before paint. Unexpected console errors fail teardown; negative tests must explicitly allow -only their expected diagnostic with `h.expectConsoleError(/specific message/)`. -Unexpected default fetch calls fail teardown even when production catches them. +only their expected diagnostic with `h.expectConsoleError(/specific message/)`, +and teardown also fails if that expected error never happened. Use +`h.allowConsoleError(...)` only for a diagnostic that legitimately may or may +not occur. Unexpected default fetch calls fail teardown even when production +catches them. + +`h.load()` wraps each file exactly as the production bundle does +(`Services/ClientScriptBundle.cs`), and `bootstrap/bootstrap-manifest.test.mjs` +loads every `js/component-scripts.json` entry in manifest order, so a module that +needs another file's top-level declarations, or a module later in the manifest, +while it loads fails here. Both runners +pin `TZ=UTC` and an `en_US.UTF-8` locale and give each test a 20 second timeout. Specs are grouped by feature under `bootstrap/`, `core/`, `features/`, `pages/`, `requests/`, and `regressions/`. Both runners discover nested `*.test.mjs` files diff --git a/tests/frontend/bootstrap/bootstrap-manifest.test.mjs b/tests/frontend/bootstrap/bootstrap-manifest.test.mjs new file mode 100644 index 000000000..fde2ec20b --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-manifest.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createHarness, jsRoot } from '../helpers/harness.mjs'; + +// The production bundle runs every js/component-scripts.json entry in order, each wrapped +// in its own function (Services/ClientScriptBundle.cs); the harness load() uses that +// wrapper. Host stubs are only what plugin.js provides before the component stage. +test('every manifest module loads in bundle order without load-time errors', async t => { + const manifest = JSON.parse(readFileSync(resolve(jsRoot, 'component-scripts.json'), 'utf8')) + .filter(entry => !entry.startsWith('//')); + assert.ok(manifest.length > 100); + const cdnUrl = (source, path) => `http://jellyfin.test/JellyfinEnhanced/cdn/${source}/${path}`; + const h = createHarness({ + JE: { + pluginConfig: {}, userConfig: {}, translations: {}, currentSettings: {}, state: { activeShortcuts: {} }, + t: key => key, icon: () => '', IconName: {}, escapeHtml: value => String(value ?? ''), + cdn: { url: cdnUrl, selfhst: file => cdnUrl('selfhst', file), flagPng: code => cdnUrl('flagcdn', code), flagSvg: code => cdnUrl('flag-icons', code), font: name => `http://jellyfin.test/JellyfinEnhanced/fonts/${name}` } + }, + // jsdom lacks ResizeObserver; every supported browser has it. + globals: { ResizeObserver: class { observe() {} unobserve() {} disconnect() {} } } + }); + t.after(() => h.close()); + const failures = []; + for (const path of manifest) { + try { h.load(path); } catch (error) { failures.push(`${path}: ${error?.message ?? error}`); } + } + await new Promise(done => setImmediate(done)); + assert.deepEqual(failures, []); +}); diff --git a/tests/frontend/helpers/coverage.mjs b/tests/frontend/helpers/coverage.mjs index a0bcd050f..0410e2220 100644 --- a/tests/frontend/helpers/coverage.mjs +++ b/tests/frontend/helpers/coverage.mjs @@ -5,14 +5,14 @@ import coverage from 'istanbul-lib-coverage'; import { createInstrumenter } from 'istanbul-lib-instrument'; import { createContext } from 'istanbul-lib-report'; import reports from 'istanbul-reports'; -import { discoverTests, repositoryRoot } from './discovery.mjs'; +import { discoverTests, repositoryRoot, testArguments, testEnvironment } from './discovery.mjs'; process.chdir(repositoryRoot); const out = resolve('artifacts/frontend-coverage'); const raw = join(out, 'raw'); rmSync(out, { recursive: true, force: true }); mkdirSync(raw, { recursive: true }); const files = discoverTests(); -const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', env: { ...process.env, JE_COVERAGE_DIR: raw } }); +const result = spawnSync(process.execPath, ['--test', ...testArguments, ...files], { stdio: 'inherit', env: testEnvironment({ JE_COVERAGE_DIR: raw }) }); const map = coverage.createCoverageMap({}); // Include every production JS module, including those never loaded by a test. function inventory(dir) { diff --git a/tests/frontend/helpers/discovery.mjs b/tests/frontend/helpers/discovery.mjs index 7132084e0..11b6c26a4 100644 --- a/tests/frontend/helpers/discovery.mjs +++ b/tests/frontend/helpers/discovery.mjs @@ -17,3 +17,11 @@ export function discoverTests(directory = frontendRoot) { if (!files.length) throw new Error(`No frontend tests discovered under ${directory}`); return files; } + +/** A hung test fails on its own instead of stalling the whole run. */ +export const testArguments = ['--test-timeout=20000']; + +/** Dates and locale-formatted text must not depend on the machine running the tests. */ +export function testEnvironment(extra = {}) { + return { ...process.env, TZ: 'UTC', LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...extra }; +} diff --git a/tests/frontend/helpers/harness.mjs b/tests/frontend/helpers/harness.mjs index 86411dcfa..c3a209193 100644 --- a/tests/frontend/helpers/harness.mjs +++ b/tests/frontend/helpers/harness.mjs @@ -15,13 +15,14 @@ if (process.env.JE_COVERAGE_DIR) process.on('exit', () => { /** Load real classic-script modules into a fresh browser realm; no network is permitted by default. */ export function createHarness({ html = '', url = 'http://jellyfin.test/web/index.html#!/home', JE = {}, apiClient = {}, fetch, globals = {}, expectedConsoleErrors = [] } = {}) { const errors = []; - const allowedErrors = [...expectedConsoleErrors]; + // Expected errors must occur before close(); optional ones (allowConsoleError) may. + const allowedErrors = expectedConsoleErrors.map(pattern => ({ pattern, required: true, matched: false })); const virtualConsole = new VirtualConsole(); virtualConsole.on('error', (...args) => { const message = args.map(String).join(' '); - if (!allowedErrors.some(pattern => typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message))) { - errors.push(new Error(`Unexpected console.error: ${message}`)); - } + const matches = allowedErrors.filter(({ pattern }) => typeof pattern === 'string' ? message.includes(pattern) : pattern.test(message)); + for (const expected of matches) expected.matched = true; + if (!matches.length) errors.push(new Error(`Unexpected console.error: ${message}`)); }); virtualConsole.on('jsdomError', error => errors.push(error)); const dom = new JSDOM(`${html}`, { @@ -46,17 +47,26 @@ export function createHarness({ html = '', url = 'http://jellyfin.test/web/index window.addEventListener('unhandledrejection', event => errors.push(event.reason)); return { window, document: window.document, JE, errors, - expectConsoleError(pattern) { allowedErrors.push(pattern); }, + /** Declares a console.error the test must produce; close() fails if none matched. */ + expectConsoleError(pattern) { allowedErrors.push({ pattern, required: true, matched: false }); }, + /** Tolerates a console.error that may or may not happen (timing-dependent diagnostics). */ + allowConsoleError(pattern) { allowedErrors.push({ pattern, required: false, matched: false }); }, load(path) { const file = resolve(jsRoot, path); let source = readFileSync(file, 'utf8'); if (process.env.JE_COVERAGE_DIR) source = createInstrumenter().instrumentSync(source, file); - window.eval(`${source}\n//# sourceURL=${file}`); + // Run each file the way Services/ClientScriptBundle.cs packages it: the file body + // inside its own function, called as an element of the module array. Top-level + // declarations therefore stay file-local, exactly as in production. + window.eval(`[function () {\n${source}\n}][0]();\n//# sourceURL=${file}`); return JE; }, close() { if (window.__coverage__) collected.merge(window.__coverage__); window.close(); + for (const { pattern, required, matched } of allowedErrors) { + if (required && !matched) errors.push(new Error(`Expected console.error never happened: ${pattern}`)); + } if (errors.length) throw new AggregateError(errors, 'Unexpected browser errors'); } }; diff --git a/tests/frontend/run.mjs b/tests/frontend/run.mjs index 62b5524a4..e3954b190 100644 --- a/tests/frontend/run.mjs +++ b/tests/frontend/run.mjs @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process'; -import { discoverTests, repositoryRoot } from './helpers/discovery.mjs'; +import { discoverTests, repositoryRoot, testArguments, testEnvironment } from './helpers/discovery.mjs'; const result = spawnSync(process.execPath, - ['--test', ...process.argv.slice(2), ...discoverTests()], - { cwd: repositoryRoot, stdio: 'inherit' }); + ['--test', ...testArguments, ...process.argv.slice(2), ...discoverTests()], + { cwd: repositoryRoot, stdio: 'inherit', env: testEnvironment() }); process.exitCode = result.error || result.signal || result.status === null ? 1 : result.status; diff --git a/tests/runner/mutations.py b/tests/runner/mutations.py index c73ac2498..a14b0062a 100644 --- a/tests/runner/mutations.py +++ b/tests/runner/mutations.py @@ -84,10 +84,13 @@ def run(case, stage): '-p:JellyfinTarget='+args.target, '--filter', 'FullyQualifiedName~'+case['test'], '--results-directory', str(artifacts), '--logger', 'trx;LogFileName='+trx.name] else: - command = ['node', '--test', '--test-reporter=tap', '--test-name-pattern='+case['test'], case.get('test_file', 'tests/frontend/features/features-hidden.test.mjs')] + command = ['node', '--test', '--test-reporter=tap', '--test-timeout=20000', '--test-name-pattern='+case['test'], case.get('test_file', 'tests/frontend/features/features-hidden.test.mjs')] started = time.monotonic() env = dict(os.environ) env.pop('JE_COVERAGE_DIR', None) + if case['layer'] == 'frontend': + # Same pinned zone/locale and per-test timeout as tests/frontend/run.mjs. + env.update(TZ='UTC', LANG='en_US.UTF-8', LC_ALL='en_US.UTF-8') with log.open('w') as output: process = subprocess.run(command, cwd=work, env=env, stdout=output, stderr=subprocess.STDOUT, timeout=600) result = dict(stage=stage, command=command, exit_code=process.returncode, seconds=round(time.monotonic()-started, 2), log=str(log)) From a7e63744b66e2e5a33dd34e2f84c28696aa829aa Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:34 +0800 Subject: [PATCH 013/111] test(frontend): make vacuous frontend tests able to fail - Reviews: Season/Episode fixtures carry their indices, the guard only matches the right key, and an unguarded control must fetch. - Quota: count fetches while disabled (zero) and with a control. - Release dates: the detached placeholder must stay empty. - Auto-skip: the current item's segments land before the stale ones. - More info: count detail fetches after close to catch a leaked listener. - Playback: the item-identity test no longer claims an OSD fallback. --- tests/frontend/features/features-autoskip.test.mjs | 10 +++++++--- tests/frontend/features/features-playback.test.mjs | 2 +- .../frontend/features/features-release-dates.test.mjs | 5 ++++- tests/frontend/pages/pages-moreinfo.test.mjs | 10 +++++++--- tests/frontend/pages/pages-reviews.test.mjs | 11 ++++++++++- tests/frontend/requests/request-submission.test.mjs | 8 +++++++- 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/frontend/features/features-autoskip.test.mjs b/tests/frontend/features/features-autoskip.test.mjs index e619af829..7b0591aed 100644 --- a/tests/frontend/features/features-autoskip.test.mjs +++ b/tests/frontend/features/features-autoskip.test.mjs @@ -33,9 +33,13 @@ test('auto-skip ignores missing end, short segments, unsupported types and rever for(const pos of [0,1,2,6]){h.tick(pos);assert.equal(h.video.currentTime,pos);}assert.deepEqual(h.skipped,[]); }); test('old-item segment response cannot seek after playback identity changes',async t=>{ - const first=deferred();const h=await engine(t,{fetchSegments:id=>id==='first'?first.promise:Promise.resolve([])}); - h.setItem('second');h.tick(0);first.resolve([{Type:'Intro',StartTicks:0,EndTicks:200000000}]);await new Promise(done=>setImmediate(done)); - h.tick(1);assert.equal(h.video.currentTime,1);assert.deepEqual(h.skipped,[]); + const first=deferred(),second=deferred();const h=await engine(t,{fetchSegments:id=>id==='first'?first.promise:second.promise}); + const settle=()=>new Promise(done=>setImmediate(done)); + h.setItem('second');h.tick(0); + // The current item's answer lands first; the stale one must not replace it afterwards. + second.resolve([]);await settle(); + first.resolve([{Id:'intro',Type:'Intro',StartTicks:100000000,EndTicks:200000000}]);await settle(); + h.tick(9);h.tick(10);assert.equal(h.video.currentTime,10);assert.deepEqual(h.skipped,[]); }); test('session resolver coalesces source probes and discards old-source results',async t=>{ const h=setup(t);const first=deferred();let calls=0; diff --git a/tests/frontend/features/features-playback.test.mjs b/tests/frontend/features/features-playback.test.mjs index bb2c72f32..604bc3677 100644 --- a/tests/frontend/features/features-playback.test.mjs +++ b/tests/frontend/features/features-playback.test.mjs @@ -13,6 +13,6 @@ test('playback controls without a video show translated feedback',t=>{ test('percentage seek uses actual duration and last-position tracker is consumed once',t=>{ const h=setup(t);Object.defineProperty(h.video,'duration',{value:200});h.JE.jumpToPercentage(25);assert.equal(h.video.currentTime,50);h.JE.attachSeekTracker(h.video);h.JE.attachSeekTracker(h.video);h.video.dispatchEvent(new h.window.Event('timeupdate'));h.video.currentTime=100;h.video.dispatchEvent(new h.window.Event('seeking'));h.JE.jumpToLastPosition();assert.equal(h.video.currentTime,50);h.video.currentTime=80;h.JE.jumpToLastPosition();assert.equal(h.video.currentTime,80); }); -test('player item identity follows video route then OSD item hint',t=>{ +test('player item identity comes from the video route id',t=>{ const h=setup(t);h.window.location.hash='#!/video?id=route-item';assert.equal(h.JE.internals.player.getCurrentVideoItemId(),'route-item'); }); diff --git a/tests/frontend/features/features-release-dates.test.mjs b/tests/frontend/features/features-release-dates.test.mjs index c575d0cd7..bc77c35f5 100644 --- a/tests/frontend/features/features-release-dates.test.mjs +++ b/tests/frontend/features/features-release-dates.test.mjs @@ -21,7 +21,10 @@ test('release-date network failure removes placeholder and logs expected diagnos const h=setup(t,{fetch:async()=>jsonResponse({},503)});h.expectConsoleError(/TMDB request failed/);h.api.displayReleaseDate('movie',h.container);await h.settle();assert.equal(h.container.children.length,0); }); test('release response arriving after navigation does not recreate removed chips',async t=>{ - const response=deferred();const h=setup(t,{fetch:()=>response.promise});h.api.displayReleaseDate('movie',h.container);await Promise.resolve();h.container.replaceChildren();response.resolve(jsonResponse({results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:'2025-01-01'}]}]}));await h.settle();assert.equal(h.container.children.length,0); + const response=deferred();const h=setup(t,{fetch:()=>response.promise});h.api.displayReleaseDate('movie',h.container);await Promise.resolve(); + const placeholder=h.container.querySelector('.mediaInfoItem-releaseDate');assert.ok(placeholder);h.container.replaceChildren(); + response.resolve(jsonResponse({results:[{iso_3166_1:'US',release_dates:[{type:3,release_date:'2025-01-01'}]}]}));await h.settle(); + assert.equal(h.container.children.length,0);assert.equal(placeholder.childNodes.length,0);assert.equal(placeholder.textContent,''); }); test('calendar dates show the same day west of UTC',async t=>{ // Runners pin TZ=UTC; switch this process to a zone behind UTC for the duration of the test. diff --git a/tests/frontend/pages/pages-moreinfo.test.mjs b/tests/frontend/pages/pages-moreinfo.test.mjs index bccccb61a..cc89c994e 100644 --- a/tests/frontend/pages/pages-moreinfo.test.mjs +++ b/tests/frontend/pages/pages-moreinfo.test.mjs @@ -21,10 +21,14 @@ test('more-info late ratings do not replace ratings of a newer item',async t=>{ assert.equal(h.state.currentModal.querySelector('[data-mount="ratings"]').textContent,'new'); }); test('more-info close removes TV request listeners and navigation dismisses modal',async t=>{ - const h=setup(t);await h.P.open(1,'tv');const count=h.actions.length; + let detailCalls=0;const h=setup(t,{details:async id=>{detailCalls++;return {id};}});await h.P.open(1,'tv'); + const request=()=>h.document.dispatchEvent(new h.window.CustomEvent('jellyseerr-tv-requested',{detail:{tmdbId:1}})); + // Control: while open, a request event refreshes the details. + request();await nextTurn();assert.equal(detailCalls,2); + const count=h.actions.length; h.document.dispatchEvent(new h.window.Event('viewshow'));h.runClosing(); - h.document.dispatchEvent(new h.window.CustomEvent('jellyseerr-tv-requested',{detail:{tmdbId:1}}));await nextTurn(); - assert.equal(h.state.currentModal,null);assert.equal(h.actions.length,count); + request();await nextTurn(); + assert.equal(h.state.currentModal,null);assert.equal(h.actions.length,count);assert.equal(detailCalls,2); }); test('more-info failed data returns a visible error without mounting modal',async t=>{ const h=setup(t,{details:async()=>null});await h.P.open(1,'movie');assert.equal(h.errors.length,1);assert.equal(h.state.currentModal,null); diff --git a/tests/frontend/pages/pages-reviews.test.mjs b/tests/frontend/pages/pages-reviews.test.mjs index 7c341a437..9b4d7e07e 100644 --- a/tests/frontend/pages/pages-reviews.test.mjs +++ b/tests/frontend/pages/pages-reviews.test.mjs @@ -21,10 +21,19 @@ async function setup(t,{item={Id:'item',Type:'Movie',ProviderIds:{Tmdb:'7'}},con } for(const Type of ['Movie','Series','Season','Episode']) { + // Season/Episode pages are guarded through their series; the others through their own id. + const guardedKey=Type==='Season'||Type==='Episode'?'series':'item'; + const item={Id:'item',Type,SeriesId:'series',ProviderIds:{Tmdb:'7'},ParentIndexNumber:1,IndexNumber:2}; + const guard=key=>({whenLoaded:async()=>{},isLoadOk:()=>true, + isEnabledFor:id=>Type!=='Movie'&&id===key,isMovieEnabledFor:id=>Type==='Movie'&&id===key}); test(`reviews suppress guarded ${Type} pages before fetching content`,async t=>{ - const h=await setup(t,{item:{Id:'item',Type,SeriesId:'series',ProviderIds:{Tmdb:'7'}},config:{SpoilerBlurEnabled:true},spoilerBlur:{whenLoaded:async()=>{},isLoadOk:()=>true,isEnabledFor:()=>true,isMovieEnabledFor:()=>true}}); + const h=await setup(t,{item,config:{SpoilerBlurEnabled:true},spoilerBlur:guard(guardedKey)}); assert.equal(h.calls.length,0);assert.equal(h.document.querySelector('.tmdb-reviews-section'),null); }); + test(`reviews still load on ${Type} pages when only another item is guarded`,async t=>{ + const h=await setup(t,{item,config:{SpoilerBlurEnabled:true},spoilerBlur:guard('other')}); + assert.ok(h.calls.length>0);assert.ok(h.document.querySelector('.tmdb-reviews-section')); + }); } test('reviews fail closed when spoiler state fails to load',async t=>{ const h=await setup(t,{config:{SpoilerBlurEnabled:true},spoilerBlur:{whenLoaded:async()=>{},isLoadOk:()=>false}}); diff --git a/tests/frontend/requests/request-submission.test.mjs b/tests/frontend/requests/request-submission.test.mjs index e169602fa..bbb936633 100644 --- a/tests/frontend/requests/request-submission.test.mjs +++ b/tests/frontend/requests/request-submission.test.mjs @@ -158,8 +158,14 @@ test('quota chips hide unlimited quotas and distinguish warning from restriction }); test('disabled quota feature makes no network request', async t => { - const h = setup(t, { config: { JellyseerrShowQuotaInfo: false }, get: () => { throw new Error('must not fetch'); } }); + let gets = 0; + const h = setup(t, { config: { JellyseerrShowQuotaInfo: false }, get: async () => { gets++; return { movie: { limit: 1 } }; } }); assert.equal(await h.api.fetchUserQuota(), null); + assert.equal(gets, 0); + // Control: the same setup fetches once the feature is on. + h.JE.pluginConfig.JellyseerrShowQuotaInfo = true; + assert.deepEqual(plain(await h.api.fetchUserQuota()), { movie: { limit: 1 } }); + assert.equal(gets, 1); }); for (const split of [false, true]) for (const succeeds of [true, false]) test(`late request ${succeeds ? 'success' : 'failure'} cannot overwrite a refreshed offline button (split=${split})`, async t => { From b1713b17dc7397cf8e1219a97d76c6121673ee16 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:34 +0800 Subject: [PATCH 014/111] test(browser): honour JE_BROWSER_PORT in the Playwright config The fixture server already read JE_BROWSER_PORT, but the config hard-coded 4179 for the readiness URL and baseURL. The port is read once and used for the server environment, its readiness probe and every test URL. --- tests/browser/README.md | 2 +- tests/browser/playwright.config.cjs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/browser/README.md b/tests/browser/README.md index 67b8a3e6c..734eee740 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -9,7 +9,7 @@ npm run test:browser npm run test:visual ``` -The suite runs desktop Chromium, mobile Chromium (Pixel 7 viewport/touch emulation, not Android), and desktop Firefox. It starts and stops its own loopback fixture server on port 4179. External requests are blocked except explicitly intercepted locale fixtures. Unexpected console errors and page errors fail tests; retries are disabled. Traces and screenshots are retained for failures under `artifacts/browser`, alongside HTML and JUnit reports. `JE_CHROMIUM_PATH` is an optional local diagnostic override; use Playwright's pinned browser for baseline checks. +The suite runs desktop Chromium, mobile Chromium (Pixel 7 viewport/touch emulation, not Android), and desktop Firefox. It starts and stops its own loopback fixture server on port 4179 (set `JE_BROWSER_PORT` to use another). External requests are blocked except explicitly intercepted locale fixtures. Unexpected console errors and page errors fail tests; retries are disabled. Traces and screenshots are retained for failures under `artifacts/browser`, alongside HTML and JUnit reports. `JE_CHROMIUM_PATH` is an optional local diagnostic override; use Playwright's pinned browser for baseline checks. Covered behavior: diff --git a/tests/browser/playwright.config.cjs b/tests/browser/playwright.config.cjs index aa8bfcfb0..847956828 100644 --- a/tests/browser/playwright.config.cjs +++ b/tests/browser/playwright.config.cjs @@ -1,16 +1,19 @@ const { defineConfig, devices } = require('@playwright/test'); const path = require('node:path'); const root = path.resolve(__dirname, '../..'); +// One port for the fixture server, its readiness probe and every test URL. +const port = Number(process.env.JE_BROWSER_PORT || 4179); +const origin = `http://127.0.0.1:${port}`; module.exports = defineConfig({ testDir: __dirname, fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, workers: process.env.CI ? 2 : 3, timeout: 15000, reporter: [['list'], ['html', { outputFolder: path.join(root, 'artifacts/browser/report'), open: 'never' }], ['junit', { outputFile: path.join(root, 'artifacts/browser/results.xml') }]], outputDir: path.join(root, 'artifacts/browser/results'), - use: { baseURL: 'http://127.0.0.1:4179', locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + use: { baseURL: origin, locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: process.env.JE_CHROMIUM_PATH ? { executablePath: process.env.JE_CHROMIUM_PATH } : {} } }, { name: 'mobile-chromium', use: { ...devices['Pixel 7'], launchOptions: process.env.JE_CHROMIUM_PATH ? { executablePath: process.env.JE_CHROMIUM_PATH } : {} } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } } ], - webServer: { command: 'node tests/browser/server.cjs', cwd: root, url: 'http://127.0.0.1:4179', reuseExistingServer: false }, + webServer: { command: 'node tests/browser/server.cjs', cwd: root, url: origin, env: { ...process.env, JE_BROWSER_PORT: String(port) }, reuseExistingServer: false }, }); From c7ada972cff51b470885c3a347c649b131602b80 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:34 +0800 Subject: [PATCH 015/111] test(host): always clean up by name, label resources, retry taken subnets Cleanup only ran when create had returned, so a docker run that created the container and then failed, or a run interrupted mid-create, leaked it. Cleanup now always removes the container and network by their unique name and treats "no such" as done. Both carry a je-regression.run label, and network creation moves to the next free subnet when a concurrent run takes the chosen one. --- tests/host/README.md | 4 +-- tests/host/run.py | 67 +++++++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/tests/host/README.md b/tests/host/README.md index f1948db1b..364031bac 100644 --- a/tests/host/README.md +++ b/tests/host/README.md @@ -11,11 +11,11 @@ python3 tests/host/run.py --target jf12 --artifacts .engineering-artifacts/host The runner builds production JE separately for each target using temporary MSBuild artifact directories. It installs the actual DLL into pinned official Jellyfin images: **10.11.11** (the 10.11 compatibility line) and **12.0** (the 12 compatibility line). Image manifest digests are recorded in `run.py` and each execution report. Updating a pin requires rerunning both compatibility suites; these versions do not imply coverage of every patch release. -Each run owns a uniquely named container, internal bridge network, temporary configuration/cache, synthetic one-second WAV, and three disposable users. Docker's existing subnets are inspected before allocating a small unused test subnet. The bridge must be reachable from the runner, so remote Docker daemons and Docker Desktop are not supported. No ports are published. External network access is blocked at the container network: background CDN failures in server logs are expected and no provider credentials are required. Cached images/NuGet packages allow offline execution; first-time dependency retrieval requires internet access on the runner. +Each run owns a uniquely named `je-regression-*` container and internal bridge network, both labelled `je-regression.run=`, plus temporary configuration/cache, a synthetic one-second WAV, and three disposable users. Docker's existing subnets are inspected before allocating a small unused test subnet; if another run takes the same subnet first, network creation moves on to the next free one. The bridge must be reachable from the runner, so remote Docker daemons and Docker Desktop are not supported. No ports are published. External network access is blocked at the container network: background CDN failures in server logs are expected and no provider credentials are required. Cached images/NuGet packages allow offline execution; first-time dependency retrieval requires internet access on the runner. Checks exercise the real HTTP pipeline: plugin loading/host compatibility, embedded assets, generated media scanning, restricted-library enumeration, anonymous authentication rejection, administrator-only APIs, cross-user read/write rejection for six user documents, bootstrap identity/private-config isolation, fake-secret protection, preference persistence, and restart behavior. Both regular and restricted users participate. Without `--browser`, this is an HTTP host suite. Native clients are not exercised. It does not claim playback, image rendering, trickplay, Seerr integration, or every scheduled task is covered. -Artifacts contain `build.log`, `server.log`, and machine-readable `report.json` per target. Errors fail the process; a missing prerequisite never counts as a skip or pass. Ephemeral passwords/tokens are redacted from server logs. Cleanup attempts log collection and removal independently, including after failures. Only resources created by the invocation are removed; no existing services are modified. An interrupted process killed with SIGKILL cannot run cleanup; uniquely named `je-regression-*` resources from that invocation must then be removed manually after verifying ownership. +Artifacts contain `build.log`, `server.log`, and machine-readable `report.json` per target. Errors fail the process; a missing prerequisite never counts as a skip or pass. Ephemeral passwords/tokens are redacted from server logs. Cleanup always attempts log collection, container removal and network removal by the run's unique name, each independently and including after failed, partial or interrupted (Ctrl+C, SIGTERM) creation; a resource that was never created is not an error. Only resources created by the invocation are removed; no existing services are modified. A process killed with SIGKILL cannot run cleanup; list leftovers with `docker ps -a --filter label=je-regression.run` and `docker network ls --filter label=je-regression.run`, and remove them after verifying ownership. ## Real browser journey diff --git a/tests/host/run.py b/tests/host/run.py index eee0d9731..0c2e0cdf8 100644 --- a/tests/host/run.py +++ b/tests/host/run.py @@ -21,6 +21,8 @@ "jf12": "jellyfin/jellyfin:12.0@sha256:baba630419915985442f315f08b0cf46d9f4c8a0cc4bd38e94a6d35751dd5ef5", } PLUGIN_ID = "f69e946a-4b3c-4e9a-8f0a-8d7c1b2c4d9b" +# Every container and network carries this label (value: the run's unique name). +LABEL = "je-regression.run" def command(*args, **kwargs): @@ -30,6 +32,36 @@ def command(*args, **kwargs): return result.stdout.strip() +def owned(*args): + """Run a command against this invocation's own resource; a resource that was never created is not an error. + + Returns the output, or None when Docker reports no such object.""" + result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode: + if "No such" in result.stdout or "not found" in result.stdout: + return None + raise RuntimeError(f"{' '.join(args[:3])} failed: {result.stdout.strip()}") + return result.stdout.strip() + + +def create_network(name): + """Create the internal test network on the first free /28, retrying when a concurrent run takes it first.""" + network_ids = command("docker", "network", "ls", "-q").splitlines() + existing = json.loads(command("docker", "network", "inspect", *network_ids)) + allocated = [ipaddress.ip_network(entry["Subnet"]) for network in existing + for entry in (network.get("IPAM", {}).get("Config") or []) if entry.get("Subnet")] + for candidate in ipaddress.ip_network("10.253.0.0/16").subnets(new_prefix=28): + if any(candidate.overlaps(used) for used in allocated if used.version == 4): + continue + result = subprocess.run(["docker", "network", "create", "--internal", "--label", f"{LABEL}={name}", + "--subnet", str(candidate), name], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode == 0: + return candidate + if "overlap" not in result.stdout.lower(): + raise RuntimeError(f"docker network create failed: {result.stdout.strip()}") + raise RuntimeError("No free 10.253.0.0/16 test subnet for the regression network") + + class Host: def __init__(self, url): self.url = url @@ -75,7 +107,6 @@ def run(target, artifacts, browser=False): output.mkdir(parents=True, exist_ok=True) name = "je-regression-" + uuid.uuid4().hex[:12] host = None - network_created = container_created = False with tempfile.TemporaryDirectory(prefix=name) as temporary: temp = pathlib.Path(temporary) temp.chmod(0o755) @@ -98,22 +129,14 @@ def run(target, artifacts, browser=False): fixture.setsampwidth(2) fixture.setframerate(8000) fixture.writeframes(b"\x00\x00" * 8000) - network_ids = command("docker", "network", "ls", "-q").splitlines() - existing = json.loads(command("docker", "network", "inspect", *network_ids)) - allocated = [ipaddress.ip_network(entry["Subnet"]) for network in existing - for entry in (network.get("IPAM", {}).get("Config") or []) if entry.get("Subnet")] - subnet = next(candidate for candidate in ipaddress.ip_network("10.253.0.0/16").subnets(new_prefix=28) - if not any(candidate.overlaps(used) for used in allocated if used.version == 4)) - command("docker", "network", "create", "--internal", "--subnet", str(subnet), name) - network_created = True + create_network(name) (temp / "cache").mkdir() - command("docker", "run", "-d", "--name", name, "--network", name, + command("docker", "run", "-d", "--name", name, "--label", f"{LABEL}={name}", "--network", name, "--user", f"{os.getuid()}:{os.getgid()}", "--volume", f"{temp / 'cache'}:/cache", "--volume", f"{temp / 'config'}:/config", "--volume", f"{media}:/media:ro", "--env", "JELLYFIN_PublishedServerUrl=http://localhost", IMAGES[target]) - container_created = True inspection = json.loads(command("docker", "inspect", name))[0] address = inspection["NetworkSettings"]["Networks"][name]["IPAddress"] host = Host("http://" + address + ":8096") @@ -230,25 +253,23 @@ def persisted(): (output / "report.json").write_text(json.dumps({"target": target, "image": IMAGES[target], "passed": host.passed if host else [], "status": "failed", "error": str(error)}, indent=2)) raise finally: - # Attempt all cleanup even if log collection or one removal fails. + # Attempt all cleanup even if log collection or one removal fails, and + # whether or not creation reported success: a create that failed or was + # interrupted part-way can still leave the uniquely named resource behind. cleanup_errors = [] - if container_created: - try: - logs = command("docker", "logs", name) + try: + logs = owned("docker", "logs", name) + if logs is not None: for value in locals().get("users", []) + ([locals()["admin"]] if "admin" in locals() else []): logs = logs.replace(value["AccessToken"], "[REDACTED]") if "password" in locals(): logs = logs.replace(password, "[REDACTED]") (output / "server.log").write_text(logs) - except Exception as error: - cleanup_errors.append(str(error)) - try: - command("docker", "rm", "-f", name) - except Exception as error: - cleanup_errors.append(str(error)) - if network_created: + except Exception as error: + cleanup_errors.append(str(error)) + for removal in (("docker", "rm", "-f", name), ("docker", "network", "rm", name)): try: - command("docker", "network", "rm", name) + owned(*removal) except Exception as error: cleanup_errors.append(str(error)) if cleanup_errors: From 9fc64a4535ffc855e1322b8c88798b8236c94731 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:42 +0800 Subject: [PATCH 016/111] ci: allow the test-only Moq package in dependency review Moq 4.20.72 (BSD-3-Clause) is referenced only by tests/backend and failed the license allow-list. It is excluded by package URL, which keeps BSD-3-Clause disallowed for everything else. It was the only package the review flagged. --- .github/workflows/dependency-review.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 16cb11912..6e3e5fd5a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -32,5 +32,6 @@ jobs: # the rest are MIT/Apache-2.0). `deny-licenses` is deprecated upstream in favor # of this allow-list model: https://github.com/actions/dependency-review-action/issues/997 allow-licenses: MIT, Apache-2.0, GPL-3.0-only - # Allow the CI-only TruffleHog GitHub Action despite its AGPL license classification. - allow-dependencies-licenses: pkg:githubactions/trufflesecurity/trufflehog + # Allow the CI-only TruffleHog GitHub Action despite its AGPL license classification, + # and the test-only Moq mocking library (BSD-3-Clause; tests/backend, never shipped). + allow-dependencies-licenses: pkg:githubactions/trufflesecurity/trufflehog, pkg:nuget/Moq From 5a7cc4b40443181a123cae7dc5d5e548928f7903 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:42 +0800 Subject: [PATCH 017/111] ci(inventory): stop the inventory gate failing on ordinary edits The inventory recorded line counts, line numbers and locale key counts, so almost any production edit or Weblate update made it stale. Because the check ran first in the backend job, it also skipped the backend tests. The inventory now only changes when a file, route, setting, scheduled task, locale file or storage literal is added, removed or renamed; the check runs as its own job; and CONTRIBUTING explains how to regenerate it. --- .github/workflows/regression.yml | 9 +- CONTRIBUTING.md | 1 + tests/docs/production-inventory.json | 3373 ++++++++------------------ tests/inventory/generate.py | 24 +- 4 files changed, 977 insertions(+), 2430 deletions(-) diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 8afc3cfe0..837d7d334 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -16,6 +16,14 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Separate job: a stale inventory fails this check without skipping the tests. + inventory: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - run: python3 tests/inventory/generate.py --check + backend: runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -30,7 +38,6 @@ jobs: dotnet-version: | 9.0.x 10.0.x - - run: python3 tests/inventory/generate.py --check - run: python3 tests/run.py backend --target ${{ matrix.target }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4801cdd54..3ba96cb67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,7 @@ Pull requests run automated backend regressions for both Jellyfin targets, JavaS | **Dependency Review** | Flags newly-introduced dependencies with known vulnerabilities or incompatible licenses | Only relevant if your PR changes `.csproj` package references | | **Security Scan** | Scans the diff for accidentally-committed secrets (API keys, tokens, credentials) with TruffleHog | `git diff` your changes yourself before pushing if you're unsure | | **Translation Checks** | For any locale file you touched under `js/locales/`, verifies it has valid JSON and the same key set as `en.json` (no missing/extra keys) | Diff your changed locale file's keys against `js/locales/en.json` by hand, or just keep the two in sync as you edit | +| **Production inventory** | Part of the regression workflow: fails when `tests/docs/production-inventory.json` no longer lists the plugin's files, HTTP routes, configuration properties, scheduled tasks, locale files or storage literals. Ordinary edits and translation updates don't change it; adding, removing or renaming one of those does | Run `python3 tests/inventory/generate.py` and commit the regenerated file (`--check` only verifies it) | Two more workflows exist but aren't part of the PR gate: **Check Unused Translation Keys** and **OpenSSF Scorecard** are both maintainer-triggered/scheduled, not run against your PR - a scorecard badge or unused-key report you might see elsewhere in the repo isn't something your PR needs to pass. diff --git a/tests/docs/production-inventory.json b/tests/docs/production-inventory.json index 0f258f9a5..7764adfb2 100644 --- a/tests/docs/production-inventory.json +++ b/tests/docs/production-inventory.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "extraction": "Lexical inventory only; entries do not imply behavioral coverage. Public property types/defaults and route attributes require semantic review.", "build_targets": [ { @@ -16,1698 +16,1359 @@ "files": [ { "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/age-rating-colours.json", - "category": "Assets", - "lines": 1 + "category": "Assets" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/icons.json", - "category": "Assets", - "lines": 1 + "category": "Assets" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/manifest.json", - "category": "Assets", - "lines": 1389 + "category": "Assets" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/quality-colours.json", - "category": "Assets", - "lines": 1 + "category": "Assets" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/vector-icons.json", - "category": "Assets", - "lines": 1 + "category": "Assets" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "category": "Configuration", - "lines": 1287 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "category": "Configuration", - "lines": 401 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "category": "Configuration", - "lines": 938 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.css", - "category": "Configuration", - "lines": 2318 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.html", - "category": "Configuration", - "lines": 10937 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/whats-new-seed.json", - "category": "Configuration", - "lines": 268 + "category": "Configuration" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "category": "Controllers", - "lines": 11836 + "category": "Controllers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ActivityPlaybackConsumer.cs", - "category": "EventHandlers", - "lines": 90 + "category": "EventHandlers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", - "category": "EventHandlers", - "lines": 231 + "category": "EventHandlers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/MaintenancePlaybackReminder.cs", - "category": "EventHandlers", - "lines": 77 + "category": "EventHandlers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/SpoilerAutoEnableEvents.cs", - "category": "EventHandlers", - "lines": 141 + "category": "EventHandlers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/UserTopologyEvents.cs", - "category": "EventHandlers", - "lines": 107 + "category": "EventHandlers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/ItemRepositoryExtensions.cs", - "category": "Extensions", - "lines": 37 + "category": "Extensions" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/JellyfinDbContextExtensions.cs", - "category": "Extensions", - "lines": 69 + "category": "Extensions" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/UserManagerExtensions.cs", - "category": "Extensions", - "lines": 62 + "category": "Extensions" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs", - "category": "Helpers", - "lines": 139 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/AudioLanguageTagHelper.cs", - "category": "Helpers", - "lines": 156 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/BuildAttestation.cs", - "category": "Helpers", - "lines": 49 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrPermissionHelper.cs", - "category": "Helpers", - "lines": 19 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrUserImportHelper.cs", - "category": "Helpers", - "lines": 100 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalRatingDecision.cs", - "category": "Helpers", - "lines": 63 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalTagDecision.cs", - "category": "Helpers", - "lines": 94 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrCertificationExtractor.cs", - "category": "Helpers", - "lines": 166 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrHttpHelper.cs", - "category": "Helpers", - "lines": 262 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrTagSignatureExtractor.cs", - "category": "Helpers", - "lines": 84 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MatroskaLanguageReader.cs", - "category": "Helpers", - "lines": 548 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MediaStreamLanguageResolver.cs", - "category": "Helpers", - "lines": 96 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ProviderHelper.cs", - "category": "Helpers", - "lines": 78 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ReviewVisibility.cs", - "category": "Helpers", - "lines": 81 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/TagEpisodeSelector.cs", - "category": "Helpers", - "lines": 110 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/UserHelper.cs", - "category": "Helpers", - "lines": 38 + "category": "Helpers" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs", - "category": "JellyfinEnhanced.cs", - "lines": 701 + "category": "JellyfinEnhanced.cs" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Logger.cs", - "category": "Logger.cs", - "lines": 121 + "category": "Logger.cs" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AnalyticsData.cs", - "category": "Model", - "lines": 113 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrInstance.cs", - "category": "Model", - "lines": 38 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrItem.cs", - "category": "Model", - "lines": 183 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrType.cs", - "category": "Model", - "lines": 9 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AwardsData.cs", - "category": "Model", - "lines": 69 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrPermission.cs", - "category": "Model", - "lines": 35 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrUser.cs", - "category": "Model", - "lines": 15 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/MdblistRatings.cs", - "category": "Model", - "lines": 85 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/TagCacheEntry.cs", - "category": "Model", - "lines": 235 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/WhatsNewData.cs", - "category": "Model", - "lines": 29 + "category": "Model" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/ActivityPage.html", - "category": "PluginPages", - "lines": 59 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/BookmarksPage.html", - "category": "PluginPages", - "lines": 57 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/CalendarPage.html", - "category": "PluginPages", - "lines": 66 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/DownloadsPage.html", - "category": "PluginPages", - "lines": 134 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/HiddenContentPage.html", - "category": "PluginPages", - "lines": 61 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/RecommendationsPage.html", - "category": "PluginPages", - "lines": 57 + "category": "PluginPages" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginServiceRegistrator.cs", - "category": "PluginServiceRegistrator.cs", - "lines": 198 + "category": "PluginServiceRegistrator.cs" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AnalyticsReportTask.cs", - "category": "ScheduledTasks", - "lines": 57 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs", - "category": "ScheduledTasks", - "lines": 315 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AudioLanguageTagsSyncTask.cs", - "category": "ScheduledTasks", - "lines": 123 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/BuildTagCacheTask.cs", - "category": "ScheduledTasks", - "lines": 73 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ClearTranslationCacheTask.cs", - "category": "ScheduledTasks", - "lines": 57 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs", - "category": "ScheduledTasks", - "lines": 374 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrUserImportTask.cs", - "category": "ScheduledTasks", - "lines": 107 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs", - "category": "ScheduledTasks", - "lines": 652 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchRunner.cs", - "category": "ScheduledTasks", - "lines": 157 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchTask.cs", - "category": "ScheduledTasks", - "lines": 82 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsSyncTask.cs", - "category": "ScheduledTasks", - "lines": 181 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/RefreshCdnAssetsTask.cs", - "category": "ScheduledTasks", - "lines": 73 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/SpoilerApplyExistingTitlesTask.cs", - "category": "ScheduledTasks", - "lines": 48 + "category": "ScheduledTasks" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFavoriteMonitor.cs", - "category": "Services", - "lines": 80 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFeedBuilder.cs", - "category": "Services", - "lines": 237 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityService.cs", - "category": "Services", - "lines": 54 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", - "category": "Services", - "lines": 700 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestMonitor.cs", - "category": "Services", - "lines": 168 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs", - "category": "Services", - "lines": 688 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestMonitor.cs", - "category": "Services", - "lines": 238 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestService.cs", - "category": "Services", - "lines": 716 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/BrandingAssetStartupFilter.cs", - "category": "Services", - "lines": 244 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", - "category": "Services", - "lines": 704 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs", - "category": "Services", - "lines": 215 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs", - "category": "Services", - "lines": 564 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs", - "category": "Services", - "lines": 85 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/Identity/RequestIdentityService.cs", - "category": "Services", - "lines": 544 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ItemStatsService.cs", - "category": "Services", - "lines": 369 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs", - "category": "Services", - "lines": 625 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceScheduleService.cs", - "category": "Services", - "lines": 159 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs", - "category": "Services", - "lines": 685 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/CompositeImageCache.cs", - "category": "Services", - "lines": 320 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativeClientPolicy.cs", - "category": "Services", - "lines": 84 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativePosterTagStamper.cs", - "category": "Services", - "lines": 247 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagComposer.cs", - "category": "Services", - "lines": 224 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagDataProvider.cs", - "category": "Services", - "lines": 280 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagImageFilter.cs", - "category": "Services", - "lines": 574 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagModel.cs", - "category": "Services", - "lines": 92 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagResolver.cs", - "category": "Services", - "lines": 172 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagReviewRatings.cs", - "category": "Services", - "lines": 180 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs", - "category": "Services", - "lines": 532 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", - "category": "Services", - "lines": 98 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagUserCache.cs", - "category": "Services", - "lines": 49 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs", - "category": "Services", - "lines": 248 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", - "category": "Services", - "lines": 385 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagCss.cs", - "category": "Services", - "lines": 226 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagImageScaler.cs", - "category": "Services", - "lines": 130 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagPlan.cs", - "category": "Services", - "lines": 381 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagRenderer.cs", - "category": "Services", - "lines": 778 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagText.cs", - "category": "Services", - "lines": 415 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagTileCache.cs", - "category": "Services", - "lines": 227 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/AgeRatingRules.cs", - "category": "Services", - "lines": 94 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/GenreIcons.cs", - "category": "Services", - "lines": 134 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/JsText.cs", - "category": "Services", - "lines": 340 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageFlagRules.cs", - "category": "Services", - "lines": 404 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs", - "category": "Services", - "lines": 298 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/QualityTagRules.cs", - "category": "Services", - "lines": 480 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/RatingRules.cs", - "category": "Services", - "lines": 86 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs", - "category": "Services", - "lines": 4183 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/RadarrService.cs", - "category": "Services", - "lines": 149 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ReviewAuthorCache.cs", - "category": "Services", - "lines": 62 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs", - "category": "Services", - "lines": 177 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrParentalFilter.cs", - "category": "Services", - "lines": 1393 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrScanTriggerService.cs", - "category": "Services", - "lines": 246 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SonarrService.cs", - "category": "Services", - "lines": 147 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageBlurService.cs", - "category": "Services", - "lines": 391 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageTagDecoration.cs", - "category": "Services", - "lines": 140 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableArmer.cs", - "category": "Services", - "lines": 193 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableFilter.cs", - "category": "Services", - "lines": 95 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs", - "category": "Services", - "lines": 1325 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerExistingTitlesApplier.cs", - "category": "Services", - "lines": 491 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerFieldStripFilter.cs", - "category": "Services", - "lines": 1387 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityService.cs", - "category": "Services", - "lines": 209 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityTagFilter.cs", - "category": "Services", - "lines": 333 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerLibraryAddAutoEnabler.cs", - "category": "Services", - "lines": 256 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerNextUnwatchedService.cs", - "category": "Services", - "lines": 236 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs", - "category": "Services", - "lines": 380 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerTagDataStripper.cs", - "category": "Services", - "lines": 581 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerUserResolver.cs", - "category": "Services", - "lines": 322 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/StartupService.cs", - "category": "Services", - "lines": 161 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheMonitor.cs", - "category": "Services", - "lines": 157 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCachePendingChanges.cs", - "category": "Services", - "lines": 58 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs", - "category": "Services", - "lines": 2111 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbCompanyTvDiscover.cs", - "category": "Services", - "lines": 201 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs", - "category": "Services", - "lines": 855 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs", - "category": "Services", - "lines": 273 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs", - "category": "Services", - "lines": 199 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs", - "category": "Services", - "lines": 440 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", - "category": "Services", - "lines": 280 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs", - "category": "Services", - "lines": 486 + "category": "Services" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-links.js", - "category": "js", - "lines": 786 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-tag-links.js", - "category": "js", - "lines": 220 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-custom-tab.js", - "category": "js", - "lines": 153 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", - "category": "js", - "lines": 276 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js", - "category": "js", - "lines": 489 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-init.js", - "category": "js", - "lines": 347 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-events.js", - "category": "js", - "lines": 502 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-views.js", - "category": "js", - "lines": 500 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-styles.js", - "category": "js", - "lines": 1124 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-custom-tab.js", - "category": "js", - "lines": 184 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-actions.js", - "category": "js", - "lines": 109 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-data.js", - "category": "js", - "lines": 373 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-init.js", - "category": "js", - "lines": 482 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-cards.js", - "category": "js", - "lines": 559 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-helpers.js", - "category": "js", - "lines": 416 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render.js", - "category": "js", - "lines": 485 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-styles.js", - "category": "js", - "lines": 601 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/awards/awards.js", - "category": "js", - "lines": 547 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/component-scripts.json", - "category": "js", - "lines": 214 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js", - "category": "js", - "lines": 925 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js", - "category": "js", - "lines": 407 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/lifecycle.js", - "category": "js", - "lines": 220 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/media-language.js", - "category": "js", - "lines": 346 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/navigation.js", - "category": "js", - "lines": 318 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/session.js", - "category": "js", - "lines": 281 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js", - "category": "js", - "lines": 667 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/ui-kit.js", - "category": "js", - "lines": 229 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js", - "category": "js", - "lines": 1186 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js", - "category": "js", - "lines": 1230 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-init.js", - "category": "js", - "lines": 127 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js", - "category": "js", - "lines": 402 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-modals.js", - "category": "js", - "lines": 351 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-page.js", - "category": "js", - "lines": 329 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js", - "category": "js", - "lines": 344 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js", - "category": "js", - "lines": 345 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-styles.js", - "category": "js", - "lines": 903 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "category": "js", - "lines": 1269 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", - "category": "js", - "lines": 233 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js", - "category": "js", - "lines": 403 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/features-random-button.js", - "category": "js", - "lines": 241 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/header-actions.js", - "category": "js", - "lines": 498 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/helpers.js", - "category": "js", - "lines": 750 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-buttons.js", - "category": "js", - "lines": 233 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-custom-tab.js", - "category": "js", - "lines": 145 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js", - "category": "js", - "lines": 528 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-dialogs.js", - "category": "js", - "lines": 378 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js", - "category": "js", - "lines": 573 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-init.js", - "category": "js", - "lines": 104 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-admin.js", - "category": "js", - "lines": 486 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-cards.js", - "category": "js", - "lines": 465 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-init.js", - "category": "js", - "lines": 109 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-nav.js", - "category": "js", - "lines": 368 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-render.js", - "category": "js", - "lines": 476 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-state.js", - "category": "js", - "lines": 154 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-styles.js", - "category": "js", - "lines": 542 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-panel.js", - "category": "js", - "lines": 333 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-save.js", - "category": "js", - "lines": 275 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-styles.js", - "category": "js", - "lines": 388 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-home.js", - "category": "js", - "lines": 588 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-multiselect.js", - "category": "js", - "lines": 234 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/icons.js", - "category": "js", - "lines": 266 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js", - "category": "js", - "lines": 830 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-page.js", - "category": "js", - "lines": 356 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js", - "category": "js", - "lines": 267 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/native-tabs.js", - "category": "js", - "lines": 681 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/auto-skip.js", - "category": "js", - "lines": 556 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/osd-rating.js", - "category": "js", - "lines": 225 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js", - "category": "js", - "lines": 983 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback-rating-badge.js", - "category": "js", - "lines": 287 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback.js", - "category": "js", - "lines": 1500 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/subtitles.js", - "category": "js", - "lines": 364 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-entry-points.js", - "category": "js", - "lines": 217 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-hidden-content.js", - "category": "js", - "lines": 123 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", - "category": "js", - "lines": 185 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "category": "js", - "lines": 861 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", - "category": "js", - "lines": 188 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-template.js", - "category": "js", - "lines": 715 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js", - "category": "js", - "lines": 371 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-release-notes.js", - "category": "js", - "lines": 182 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/detail-button.js", - "category": "js", - "lines": 155 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/dialog.js", - "category": "js", - "lines": 67 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/identity.js", - "category": "js", - "lines": 57 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/ids.js", - "category": "js", - "lines": 30 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js", - "category": "js", - "lines": 58 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/index.js", - "category": "js", - "lines": 51 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/seerr-toggle.js", - "category": "js", - "lines": 80 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/settings-tab.js", - "category": "js", - "lines": 126 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/snooze.js", - "category": "js", - "lines": 52 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js", - "category": "js", - "lines": 247 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/styles.js", - "category": "js", - "lines": 61 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/watched-refresh.js", - "category": "js", - "lines": 74 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/themer.js", - "category": "js", - "lines": 196 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", - "category": "js", - "lines": 232 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/ui-styles.js", - "category": "js", - "lines": 317 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/active-streams.js", - "category": "js", - "lines": 1279 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-custom-tab.js", - "category": "js", - "lines": 161 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-page.js", - "category": "js", - "lines": 576 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-activity-icons.js", - "category": "js", - "lines": 473 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-ratings.js", - "category": "js", - "lines": 255 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/login-image.js", - "category": "js", - "lines": 508 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-icons.js", - "category": "js", - "lines": 404 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-revisions.js", - "category": "js", - "lines": 60 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/theme-selector.js", - "category": "js", - "lines": 449 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/api.js", - "category": "js", - "lines": 979 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/collection-discovery.js", - "category": "js", - "lines": 221 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-base.js", - "category": "js", - "lines": 1418 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-filter-utils.js", - "category": "js", - "lines": 678 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/genre-discovery.js", - "category": "js", - "lines": 170 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/network-discovery.js", - "category": "js", - "lines": 257 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/person-discovery.js", - "category": "js", - "lines": 224 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/tag-discovery.js", - "category": "js", - "lines": 115 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/hss-discovery-handler.js", - "category": "js", - "lines": 47 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/issue-reporter.js", - "category": "js", - "lines": 1114 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/item-details.js", - "category": "js", - "lines": 755 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js", - "category": "js", - "lines": 848 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/modal.js", - "category": "js", - "lines": 321 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions-tv.js", - "category": "js", - "lines": 273 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions.js", - "category": "js", - "lines": 498 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-badges.js", - "category": "js", - "lines": 256 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-data.js", - "category": "js", - "lines": 174 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js", - "category": "js", - "lines": 260 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-render.js", - "category": "js", - "lines": 438 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-seasons.js", - "category": "js", - "lines": 418 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-styles.js", - "category": "js", - "lines": 1408 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-catalog.js", - "category": "js", - "lines": 135 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-category.js", - "category": "js", - "lines": 476 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-custom-tab.js", - "category": "js", - "lines": 139 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-data.js", - "category": "js", - "lines": 99 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-init.js", - "category": "js", - "lines": 133 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-page.js", - "category": "js", - "lines": 101 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-render.js", - "category": "js", - "lines": 476 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-styles.js", - "category": "js", - "lines": 227 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/request-manager.js", - "category": "js", - "lines": 15 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seamless-scroll.js", - "category": "js", - "lines": 595 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-detail-link.js", - "category": "js", - "lines": 177 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-status.js", - "category": "js", - "lines": 256 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-badges.js", - "category": "js", - "lines": 373 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js", - "category": "js", - "lines": 339 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-cards.js", - "category": "js", - "lines": 729 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-icons.js", - "category": "js", - "lines": 43 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-popover.js", - "category": "js", - "lines": 320 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-quota.js", - "category": "js", - "lines": 178 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-request-modals.js", - "category": "js", - "lines": 302 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js", - "category": "js", - "lines": 458 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-season-modal.js", - "category": "js", - "lines": 559 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-styles.js", - "category": "js", - "lines": 435 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json", - "category": "js", - "lines": 724 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js", - "category": "js", - "lines": 215 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/mdblist-ratings.js", - "category": "js", - "lines": 393 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/splashscreen.js", - "category": "js", - "lines": 459 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "category": "js", - "lines": 1367 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ageratingtags.js", - "category": "js", - "lines": 313 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/genretags.js", - "category": "js", - "lines": 340 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/languagetags.js", - "category": "js", - "lines": 397 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/peopletags.js", - "category": "js", - "lines": 1075 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/qualitytags.js", - "category": "js", - "lines": 1072 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ratingtags.js", - "category": "js", - "lines": 521 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-cache-store.js", - "category": "js", - "lines": 361 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-pipeline.js", - "category": "js", - "lines": 1627 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/userreviewtags.js", - "category": "js", - "lines": 541 + "category": "js" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json", - "category": "packages.jf10.lock.json", - "lines": 348 + "category": "packages.jf10.lock.json" }, { "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json", - "category": "packages.jf12.lock.json", - "lines": 305 + "category": "packages.jf12.lock.json" } ], "routes": [ @@ -1716,7 +1377,6 @@ "path": "/JellyfinEnhanced/jellyseerr/status", "method": "GetJellyseerrStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1134, "following_attributes": [ "Authorize" ] @@ -1726,7 +1386,6 @@ "path": "/JellyfinEnhanced/jellyseerr/validate", "method": "ValidateJellyseerr", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1178, "following_attributes": [ "Authorize" ] @@ -1736,7 +1395,6 @@ "path": "/JellyfinEnhanced/jellyseerr/trigger-recently-added-scan", "method": "TriggerJellyseerrRecentlyAddedScan", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1242, "following_attributes": [ "Authorize" ] @@ -1746,7 +1404,6 @@ "path": "/JellyfinEnhanced/jellyseerr/user-status", "method": "GetJellyseerrUserStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1303, "following_attributes": [ "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", "Authorize" @@ -1757,7 +1414,6 @@ "path": "/JellyfinEnhanced/jellyseerr/permission-audit", "method": "GetPermissionAudit", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1359, "following_attributes": [ "Authorize" ] @@ -1767,7 +1423,6 @@ "path": "/JellyfinEnhanced/jellyseerr/search", "method": "JellyseerrSearch", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1483, "following_attributes": [ "Authorize" ] @@ -1777,7 +1432,6 @@ "path": "/JellyfinEnhanced/jellyseerr/sonarr", "method": "GetSonarrInstances", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1516, "following_attributes": [ "Authorize" ] @@ -1787,7 +1441,6 @@ "path": "/JellyfinEnhanced/jellyseerr/radarr", "method": "GetRadarrInstances", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1523, "following_attributes": [ "Authorize" ] @@ -1797,7 +1450,6 @@ "path": "/JellyfinEnhanced/jellyseerr/sonarr/lookup/{tmdbId}", "method": "GetSonarrLookup", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1534, "following_attributes": [ "Authorize" ] @@ -1807,7 +1459,6 @@ "path": "/JellyfinEnhanced/jellyseerr/settings/{type}", "method": "GetJellyseerrArrSettings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1548, "following_attributes": [ "Authorize" ] @@ -1817,7 +1468,6 @@ "path": "/JellyfinEnhanced/host-compat", "method": "GetHostCompatibility", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1605, "following_attributes": [ "Authorize" ] @@ -1827,7 +1477,6 @@ "path": "/JellyfinEnhanced/jellyfin-urls", "method": "GetJellyfinNetworkUrls", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1634, "following_attributes": [ "Authorize" ] @@ -1837,7 +1486,6 @@ "path": "/JellyfinEnhanced/jellyseerr/{type}/{serverId}", "method": "GetServiceDetails", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1646, "following_attributes": [ "Authorize" ] @@ -1847,7 +1495,6 @@ "path": "/JellyfinEnhanced/jellyseerr/request", "method": "JellyseerrRequest", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1661, "following_attributes": [ "Authorize" ] @@ -1857,7 +1504,6 @@ "path": "/JellyfinEnhanced/jellyseerr/request", "method": "GetJellyseerrRequests", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1793, "following_attributes": [ "Authorize" ] @@ -1867,7 +1513,6 @@ "path": "/JellyfinEnhanced/jellyseerr/quota", "method": "GetJellyseerrQuota", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1801, "following_attributes": [ "Authorize" ] @@ -1877,7 +1522,6 @@ "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}", "method": "GetTvShow", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1932, "following_attributes": [ "Authorize" ] @@ -1887,7 +1531,6 @@ "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/season/{seasonNumber}", "method": "GetTvSeason", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1939, "following_attributes": [ "Authorize" ] @@ -1897,7 +1540,6 @@ "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}", "method": "GetMovie", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1946, "following_attributes": [ "Authorize" ] @@ -1907,7 +1549,6 @@ "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/similar", "method": "GetSimilarMovies", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1953, "following_attributes": [ "Authorize" ] @@ -1917,7 +1558,6 @@ "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/recommendations", "method": "GetRecommendedMovies", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1960, "following_attributes": [ "Authorize" ] @@ -1927,7 +1567,6 @@ "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/ratingscombined", "method": "GetMovieRatingsCombined", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1967, "following_attributes": [ "Authorize" ] @@ -1937,7 +1576,6 @@ "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/similar", "method": "GetSimilarTvShows", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1974, "following_attributes": [ "Authorize" ] @@ -1947,7 +1585,6 @@ "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/recommendations", "method": "GetRecommendedTvShows", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1981, "following_attributes": [ "Authorize" ] @@ -1957,7 +1594,6 @@ "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/ratings", "method": "GetTvRatingsCombined", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1988, "following_attributes": [ "Authorize" ] @@ -1967,7 +1603,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv/network/{networkId}", "method": "DiscoverTvByNetwork", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 1995, "following_attributes": [ "Authorize" ] @@ -1977,7 +1612,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/movies/studio/{studioId}", "method": "DiscoverMoviesByStudio", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2002, "following_attributes": [ "Authorize" ] @@ -1987,7 +1621,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv/studio/{studioId}", "method": "DiscoverTvByStudio", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2017, "following_attributes": [ "Authorize" ] @@ -1997,7 +1630,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/trending", "method": "DiscoverTrending", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2109, "following_attributes": [ "Authorize" ] @@ -2007,7 +1639,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/movies", "method": "DiscoverMovies", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2116, "following_attributes": [ "Authorize" ] @@ -2017,7 +1648,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv", "method": "DiscoverTv", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2123, "following_attributes": [ "Authorize" ] @@ -2027,7 +1657,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/movies/upcoming", "method": "DiscoverUpcomingMovies", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2130, "following_attributes": [ "Authorize" ] @@ -2037,7 +1666,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv/upcoming", "method": "DiscoverUpcomingTv", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2137, "following_attributes": [ "Authorize" ] @@ -2047,7 +1675,6 @@ "path": "/JellyfinEnhanced/studio/{studioId}", "method": "GetStudioInfo", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2144, "following_attributes": [ "Authorize" ] @@ -2057,7 +1684,6 @@ "path": "/JellyfinEnhanced/boxset/{boxsetId}", "method": "GetBoxSetInfo", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2178, "following_attributes": [ "Authorize" ] @@ -2067,7 +1693,6 @@ "path": "/JellyfinEnhanced/person/{personId}", "method": "GetPersonInfo", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2212, "following_attributes": [ "Authorize" ] @@ -2077,7 +1702,6 @@ "path": "/JellyfinEnhanced/people/info", "method": "GetPeopleInfo", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2256, "following_attributes": [ "Authorize" ] @@ -2087,7 +1711,6 @@ "path": "/JellyfinEnhanced/genre/{genreId}", "method": "GetGenreInfo", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2568, "following_attributes": [ "Authorize" ] @@ -2097,7 +1720,6 @@ "path": "/JellyfinEnhanced/jellyseerr/person/{personId}", "method": "GetJellyseerrPerson", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2594, "following_attributes": [ "Authorize" ] @@ -2107,7 +1729,6 @@ "path": "/JellyfinEnhanced/jellyseerr/person/{personId}/combined_credits", "method": "GetJellyseerrPersonCredits", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2613, "following_attributes": [ "Authorize" ] @@ -2117,7 +1738,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv/genre/{genreId}", "method": "DiscoverTvByGenre", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2629, "following_attributes": [ "Authorize" ] @@ -2127,7 +1747,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/movies/genre/{genreId}", "method": "DiscoverMoviesByGenre", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2636, "following_attributes": [ "Authorize" ] @@ -2137,7 +1756,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/tv/keyword/{keywordId}", "method": "DiscoverTvByKeyword", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2643, "following_attributes": [ "Authorize" ] @@ -2147,7 +1765,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/movies/keyword/{keywordId}", "method": "DiscoverMoviesByKeyword", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2650, "following_attributes": [ "Authorize" ] @@ -2157,7 +1774,6 @@ "path": "/JellyfinEnhanced/tmdb/search/person", "method": "SearchTmdbPerson", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2657, "following_attributes": [ "Authorize" ] @@ -2167,7 +1783,6 @@ "path": "/JellyfinEnhanced/tmdb/search/keyword", "method": "SearchTmdbKeyword", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2668, "following_attributes": [ "Authorize" ] @@ -2177,7 +1792,6 @@ "path": "/JellyfinEnhanced/tmdb/genres/movie", "method": "GetTmdbMovieGenres", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2679, "following_attributes": [ "Authorize" ] @@ -2187,7 +1801,6 @@ "path": "/JellyfinEnhanced/tmdb/genres/tv", "method": "GetTmdbTvGenres", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2686, "following_attributes": [ "Authorize" ] @@ -2197,7 +1810,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/movie", "method": "GetMovieGenreSlider", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2693, "following_attributes": [ "Authorize" ] @@ -2207,7 +1819,6 @@ "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/tv", "method": "GetTvGenreSlider", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2700, "following_attributes": [ "Authorize" ] @@ -2217,7 +1828,6 @@ "path": "/JellyfinEnhanced/jellyseerr/overrideRule", "method": "GetOverrideRules", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2707, "following_attributes": [ "Authorize" ] @@ -2227,7 +1837,6 @@ "path": "/JellyfinEnhanced/jellyseerr/collection/{collectionId}", "method": "GetCollection", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2714, "following_attributes": [ "Authorize" ] @@ -2237,7 +1846,6 @@ "path": "/JellyfinEnhanced/jellyseerr/user", "method": "GetJellyseerrUsers", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2721, "following_attributes": [ "Authorize" ] @@ -2247,7 +1855,6 @@ "path": "/JellyfinEnhanced/jellyseerr/request/tv/{tmdbId}/seasons", "method": "RequestTvSeasons", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2736, "following_attributes": [ "Authorize" ] @@ -2257,7 +1864,6 @@ "path": "/JellyfinEnhanced/jellyseerr/watchlist", "method": "GetJellyseerrWatchlist", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2773, "following_attributes": [ "Authorize" ] @@ -2267,7 +1873,6 @@ "path": "/JellyfinEnhanced/jellyseerr/sync-watchlist", "method": "SyncJellyseerrWatchlist", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2787, "following_attributes": [ "Authorize" ] @@ -2277,7 +1882,6 @@ "path": "/JellyfinEnhanced/jellyseerr/import-users", "method": "ImportJellyseerrUsers", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 2902, "following_attributes": [ "Authorize" ] @@ -2287,7 +1891,6 @@ "path": "/JellyfinEnhanced/jellyseerr/settings/partial-requests", "method": "GetJellyseerrPartialRequestsSetting", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3230, "following_attributes": [ "Authorize" ] @@ -2297,7 +1900,6 @@ "path": "/JellyfinEnhanced/tmdb/validate", "method": "ValidateTmdb", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3300, "following_attributes": [ "Authorize" ] @@ -2307,7 +1909,6 @@ "path": "/JellyfinEnhanced/script", "method": "GetMainScript", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3344, "following_attributes": [] }, { @@ -2315,7 +1916,6 @@ "path": "/JellyfinEnhanced/js/{**path}", "method": "GetScript", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3346, "following_attributes": [] }, { @@ -2323,7 +1923,6 @@ "path": "/JellyfinEnhanced/Configuration/configPage.css", "method": "GetConfigPageStylesheet", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3349, "following_attributes": [] }, { @@ -2331,7 +1930,6 @@ "path": "/JellyfinEnhanced/version", "method": "GetVersion", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3355, "following_attributes": [] }, { @@ -2339,7 +1937,6 @@ "path": "/JellyfinEnhanced/private-config", "method": "GetPrivateConfig", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3358, "following_attributes": [ "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", "Authorize" @@ -2350,7 +1947,6 @@ "path": "/JellyfinEnhanced/public-config", "method": "GetPublicConfig", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3422, "following_attributes": [] }, { @@ -2358,7 +1954,6 @@ "path": "/JellyfinEnhanced/bootstrap", "method": "GetBootstrap", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3762, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" @@ -2369,7 +1964,6 @@ "path": "/JellyfinEnhanced/bundle.js", "method": "GetScriptBundle", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3860, "following_attributes": [] }, { @@ -2377,7 +1971,6 @@ "path": "/JellyfinEnhanced/bundle.js.map", "method": "GetScriptBundleMap", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3863, "following_attributes": [] }, { @@ -2385,7 +1978,6 @@ "path": "/JellyfinEnhanced/tmdb/{**apiPath}", "method": "ProxyTmdbRequest", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3893, "following_attributes": [ "Authorize" ] @@ -2395,7 +1987,6 @@ "path": "/JellyfinEnhanced/watch-providers", "method": "GetWatchProvidersBatch", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4002, "following_attributes": [ "Authorize" ] @@ -2405,7 +1996,6 @@ "path": "/JellyfinEnhanced/usage/track", "method": "TrackUsage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4127, "following_attributes": [ "Authorize" ] @@ -2415,7 +2005,6 @@ "path": "/JellyfinEnhanced/usage/preview", "method": "PreviewUsageReport", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4183, "following_attributes": [ "Authorize" ] @@ -2425,7 +2014,6 @@ "path": "/JellyfinEnhanced/audio-language-tags", "method": "GetAudioLanguageTags", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4234, "following_attributes": [ "Authorize" ] @@ -2435,7 +2023,6 @@ "path": "/JellyfinEnhanced/awards/{mediaType}/{tmdbId}", "method": "GetAwards", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4265, "following_attributes": [ "Authorize" ] @@ -2445,7 +2032,6 @@ "path": "/JellyfinEnhanced/mdblist-ratings/{mediaType}/{tmdbId}", "method": "GetMdblistRatings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4303, "following_attributes": [ "Authorize" ] @@ -2455,7 +2041,6 @@ "path": "/JellyfinEnhanced/mdblist-ratings/account-status", "method": "GetMdblistAccountStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4346, "following_attributes": [ "Authorize" ] @@ -2465,7 +2050,6 @@ "path": "/JellyfinEnhanced/locales", "method": "GetAvailableLocales", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4368, "following_attributes": [ "Authorize", "ResponseCache(Duration = 86400)" @@ -2476,7 +2060,6 @@ "path": "/JellyfinEnhanced/cdn/{source}/{**path}", "method": "GetCdnAsset", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4398, "following_attributes": [] }, { @@ -2484,7 +2067,6 @@ "path": "/JellyfinEnhanced/fonts/{name}", "method": "GetBundledFont", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4447, "following_attributes": [] }, { @@ -2492,7 +2074,6 @@ "path": "/JellyfinEnhanced/locales/{lang}.json", "method": "GetLocale", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4466, "following_attributes": [] }, { @@ -2500,7 +2081,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/settings.json", "method": "GetUserSettingsSettings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4673, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" @@ -2511,7 +2091,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/shortcuts.json", "method": "GetUserSettingsShortcuts", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4786, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" @@ -2522,7 +2101,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/elsewhere.json", "method": "GetUserSettingsElsewhere", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4801, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" @@ -2533,7 +2111,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/settings.json", "method": "SaveUserSettingsSettings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4816, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2544,7 +2121,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/shortcuts.json", "method": "SaveUserSettingsShortcuts", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4870, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2555,7 +2131,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json", "method": "GetUserBookmark", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4894, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", @@ -2567,7 +2142,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json", "method": "SaveUserBookmark", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4910, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2578,7 +2152,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json/add", "method": "AddUserBookmark", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4948, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2589,7 +2162,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json/{bookmarkId}", "method": "RemoveUserBookmark", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4998, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2600,7 +2172,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/elsewhere.json", "method": "SaveUserSettingsElsewhere", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5038, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2611,7 +2182,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/hidden-content.json", "method": "GetUserHiddenContent", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5062, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", @@ -2623,7 +2193,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/hidden-content.json", "method": "SaveUserHiddenContent", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5133, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2634,7 +2203,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/spoilerblur.json", "method": "GetUserSpoilerBlur", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5187, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", @@ -2646,7 +2214,6 @@ "path": "/JellyfinEnhanced/user-settings/{userId}/spoilerblur.json", "method": "SaveUserSpoilerBlur", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5216, "following_attributes": [ "Authorize", "Produces(\"application/json\")", @@ -2659,7 +2226,6 @@ "path": "/JellyfinEnhanced/admin/hidden-content-users", "method": "GetHiddenContentUsers", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5331, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2670,7 +2236,6 @@ "path": "/JellyfinEnhanced/admin/hidden-content/{userId}", "method": "GetUserHiddenContentForAdmin", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5395, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2681,7 +2246,6 @@ "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/unhide", "method": "AdminUnhideForUser", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5440, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2692,7 +2256,6 @@ "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/hide", "method": "AdminHideForUser", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5511, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2703,7 +2266,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/health", "method": "GetSpoilerBlurHealth", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5607, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2714,7 +2276,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/health/{targetUserId}", "method": "AckSpoilerBlurCorruption", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5642, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2725,7 +2286,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/preview", "method": "PreviewSpoilerApplyExisting", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5679, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2736,7 +2296,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/apply-existing", "method": "StartSpoilerApplyExisting", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5706, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2747,7 +2306,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/status", "method": "GetSpoilerApplyExistingStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5739, "following_attributes": [ "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", "Authorize", @@ -2759,7 +2317,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/series", "method": "GetSpoilerBlurSeries", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5757, "following_attributes": [ "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", "Authorize", @@ -2771,7 +2328,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/user-prefs", "method": "GetSpoilerBlurUserPrefs", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5799, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2782,7 +2338,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/user-prefs", "method": "SetSpoilerBlurUserPrefs", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5833, "following_attributes": [ "Authorize", "Produces(\"application/json\")", @@ -2795,7 +2350,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/series/{seriesId}", "method": "EnableSpoilerBlurForSeries", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5890, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2806,7 +2360,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/series/{seriesId}", "method": "DisableSpoilerBlurForSeries", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5979, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2817,7 +2370,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/movies/{movieId}", "method": "EnableSpoilerBlurForMovie", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6043, "following_attributes": [ "Authorize", "RequestSizeLimit(8 * 1024)", @@ -2829,7 +2381,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/movies/{movieId}", "method": "DisableSpoilerBlurForMovie", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6138, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2840,7 +2391,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/collections/{collectionId}", "method": "EnableSpoilerBlurForCollection", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6200, "following_attributes": [ "Authorize", "RequestSizeLimit(8 * 1024)", @@ -2852,7 +2402,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/collections/{collectionId}", "method": "DisableSpoilerBlurForCollection", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6288, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2863,7 +2412,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", "method": "EnableSpoilerBlurPending", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6350, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2874,7 +2422,6 @@ "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", "method": "DisableSpoilerBlurPending", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6600, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2885,7 +2432,6 @@ "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", "method": "HideFromContinueWatching", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6805, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2896,7 +2442,6 @@ "path": "/JellyfinEnhanced/next-up/hide/{itemId}", "method": "HideFromNextUp", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6810, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2907,7 +2452,6 @@ "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", "method": "UnhideFromContinueWatching", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6929, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2918,7 +2462,6 @@ "path": "/JellyfinEnhanced/next-up/hide/{itemId}", "method": "UnhideFromNextUp", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6934, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2929,7 +2472,6 @@ "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", "method": "GetItemReviews", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7109, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2940,7 +2482,6 @@ "path": "/JellyfinEnhanced/reviews/ratings", "method": "GetReviewRatings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7179, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2951,7 +2492,6 @@ "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", "method": "UpsertReview", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7286, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2962,7 +2502,6 @@ "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", "method": "AdminUpsertReview", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7331, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2973,7 +2512,6 @@ "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", "method": "DeleteReview", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7378, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2984,7 +2522,6 @@ "path": "/JellyfinEnhanced/reviews/admin/all", "method": "AdminListReviews", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7502, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -2995,7 +2532,6 @@ "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", "method": "AdminDeleteReview", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7605, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3006,7 +2542,6 @@ "path": "/JellyfinEnhanced/reset-all-users-settings", "method": "ResetAllUsersSettings", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7645, "following_attributes": [ "Authorize" ] @@ -3016,7 +2551,6 @@ "path": "/JellyfinEnhanced/tag-cache/rebuild", "method": "RebuildTagCache", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7807, "following_attributes": [ "Authorize" ] @@ -3026,7 +2560,6 @@ "path": "/JellyfinEnhanced/tag-cache/{userId}", "method": "GetTagCache", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7826, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3037,7 +2570,6 @@ "path": "/JellyfinEnhanced/activity", "method": "GetActivityFeed", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7966, "following_attributes": [ "Authorize", "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", @@ -3049,7 +2581,6 @@ "path": "/JellyfinEnhanced/whats-new", "method": "GetWhatsNew", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7994, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3060,7 +2591,6 @@ "path": "/JellyfinEnhanced/whats-new/dismiss", "method": "DismissWhatsNew", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8010, "following_attributes": [ "Authorize" ] @@ -3070,7 +2600,6 @@ "path": "/JellyfinEnhanced/tag-data/{userId}", "method": "GetTagData", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8020, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3081,7 +2610,6 @@ "path": "/JellyfinEnhanced/item-stats/{userId}/{itemId}", "method": "GetItemStatsByItemId", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8464, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3092,7 +2620,6 @@ "path": "/JellyfinEnhanced/native-poster-tags/preview/{itemId}", "method": "PreviewNativePosterTags", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8498, "following_attributes": [ "Authorize" ] @@ -3102,7 +2629,6 @@ "path": "/JellyfinEnhanced/file-size/{userId}/{itemId}", "method": "GetFileSizeByItemId", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8576, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3113,7 +2639,6 @@ "path": "/JellyfinEnhanced/watch-progress/{userId}/{itemId}", "method": "GetWatchProgressByItemId", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8597, "following_attributes": [ "Authorize", "Produces(\"application/json\")" @@ -3124,7 +2649,6 @@ "path": "/JellyfinEnhanced/jellyseerr/issue", "method": "GetJellyseerrIssues", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8618, "following_attributes": [ "Authorize" ] @@ -3134,7 +2658,6 @@ "path": "/JellyfinEnhanced/jellyseerr/issue/{id}", "method": "GetJellyseerrIssueById", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8657, "following_attributes": [ "Authorize" ] @@ -3144,7 +2667,6 @@ "path": "/JellyfinEnhanced/jellyseerr/issue", "method": "ReportJellyseerrIssue", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8674, "following_attributes": [ "Authorize" ] @@ -3154,7 +2676,6 @@ "path": "/JellyfinEnhanced/UploadBrandingImage", "method": "UploadBrandingImage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8681, "following_attributes": [ "Authorize" ] @@ -3164,7 +2685,6 @@ "path": "/JellyfinEnhanced/BrandingStatus", "method": "GetBrandingStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8748, "following_attributes": [ "Authorize" ] @@ -3174,7 +2694,6 @@ "path": "/JellyfinEnhanced/BrandingImage", "method": "GetBrandingImage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8763, "following_attributes": [ "Authorize" ] @@ -3184,7 +2703,6 @@ "path": "/JellyfinEnhanced/DeleteBrandingImage", "method": "DeleteBrandingImage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8789, "following_attributes": [ "Authorize" ] @@ -3194,7 +2712,6 @@ "path": "/JellyfinEnhanced/arr/validate/sonarr", "method": "ValidateSonarr", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8836, "following_attributes": [ "Authorize" ] @@ -3204,7 +2721,6 @@ "path": "/JellyfinEnhanced/arr/validate/radarr", "method": "ValidateRadarr", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8843, "following_attributes": [ "Authorize" ] @@ -3214,7 +2730,6 @@ "path": "/JellyfinEnhanced/arr/identify-url", "method": "IdentifyUrl", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 8889, "following_attributes": [ "Authorize" ] @@ -3224,7 +2739,6 @@ "path": "/JellyfinEnhanced/arr/series-slug", "method": "GetSeriesSlug", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9010, "following_attributes": [ "Authorize" ] @@ -3234,7 +2748,6 @@ "path": "/JellyfinEnhanced/arr/series-slugs", "method": "GetSeriesSlugs", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9047, "following_attributes": [ "Authorize" ] @@ -3244,7 +2757,6 @@ "path": "/JellyfinEnhanced/arr/links", "method": "GetArrLinks", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9132, "following_attributes": [ "Authorize" ] @@ -3254,7 +2766,6 @@ "path": "/JellyfinEnhanced/arr/movie-instances", "method": "GetMovieInstances", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9385, "following_attributes": [ "Authorize" ] @@ -3264,7 +2775,6 @@ "path": "/JellyfinEnhanced/active-streams/sessions", "method": "GetActiveSessions", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9437, "following_attributes": [ "Authorize" ] @@ -3274,7 +2784,6 @@ "path": "/JellyfinEnhanced/active-streams/broadcast", "method": "BroadcastMessage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9516, "following_attributes": [ "Authorize" ] @@ -3284,7 +2793,6 @@ "path": "/JellyfinEnhanced/arr/queue", "method": "GetDownloadQueue", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9770, "following_attributes": [ "Authorize" ] @@ -3294,7 +2802,6 @@ "path": "/JellyfinEnhanced/arr/history", "method": "GetDownloadHistory", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 9966, "following_attributes": [ "Authorize" ] @@ -3304,7 +2811,6 @@ "path": "/JellyfinEnhanced/arr/requests", "method": "GetRequests", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 10163, "following_attributes": [ "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", "Authorize" @@ -3315,7 +2821,6 @@ "path": "/JellyfinEnhanced/arr/requests/{requestId}/approve", "method": "ActOnRequest", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 10556, "following_attributes": [ "HttpPost(\"arr/requests/{requestId}/decline\")", "Authorize" @@ -3326,7 +2831,6 @@ "path": "/JellyfinEnhanced/arr/requests/{requestId}/decline", "method": "ActOnRequest", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 10557, "following_attributes": [ "Authorize" ] @@ -3336,7 +2840,6 @@ "path": "/JellyfinEnhanced/arr/calendar", "method": "GetCalendarEvents", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 10607, "following_attributes": [ "Authorize" ] @@ -3346,7 +2849,6 @@ "path": "/JellyfinEnhanced/arr/calendar/user-data", "method": "GetCalendarUserDataForEvents", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11193, "following_attributes": [ "Authorize" ] @@ -3356,7 +2858,6 @@ "path": "/JellyfinEnhanced/proxy/avatar", "method": "ProxyAvatar", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11506, "following_attributes": [ "Authorize" ] @@ -3366,7 +2867,6 @@ "path": "/JellyfinEnhanced/items/by-providers", "method": "GetItemIdByProviders", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11650, "following_attributes": [] }, { @@ -3374,7 +2874,6 @@ "path": "/JellyfinEnhanced/MaintenanceMode/Status", "method": "GetMaintenanceStatus", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11677, "following_attributes": [] }, { @@ -3382,7 +2881,6 @@ "path": "/JellyfinEnhanced/MaintenanceMode/Enable", "method": "EnableMaintenanceMode", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11701, "following_attributes": [] }, { @@ -3390,7 +2888,6 @@ "path": "/JellyfinEnhanced/MaintenanceMode/Disable", "method": "DisableMaintenanceMode", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11723, "following_attributes": [] }, { @@ -3398,7 +2895,6 @@ "path": "/JellyfinEnhanced/MaintenanceMode/Users", "method": "GetMaintenanceModeUsers", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11732, "following_attributes": [] }, { @@ -3406,7 +2902,6 @@ "path": "/JellyfinEnhanced/MaintenanceMode/Broadcast", "method": "BroadcastMaintenanceMessage", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11745, "following_attributes": [] }, { @@ -3414,7 +2909,6 @@ "path": "/JellyfinEnhanced/{viewName}", "method": "GetView", "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 11775, "following_attributes": [] } ], @@ -3425,8 +2919,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 10 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "Shortcut", @@ -3434,8 +2927,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 11 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "Shortcut", @@ -3443,8 +2935,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 12 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "Shortcut", @@ -3452,8 +2943,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 13 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3461,8 +2951,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 441 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3470,8 +2959,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"This server is currently undergoing maintenance. Please try again.\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 443 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3479,8 +2967,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"Server undergoing maintenance.\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 445 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3488,8 +2975,7 @@ "type": "string", "initializer": "\"disable_accounts\"", "constructor_assignment": "\"disable_accounts\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 448 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3497,8 +2983,7 @@ "type": "string", "initializer": "\"all\"", "constructor_assignment": "\"all\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 455 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3506,8 +2991,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 457 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3515,8 +2999,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 459 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3524,8 +3007,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 461 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3533,8 +3015,7 @@ "type": "string", "initializer": "\"00:00\"", "constructor_assignment": "\"00:00\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 463 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3542,8 +3023,7 @@ "type": "string", "initializer": "\"08:00\"", "constructor_assignment": "\"08:00\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 465 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3551,8 +3031,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"The library is updating", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 467 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3560,8 +3039,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"The library is updating", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 469 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3569,8 +3047,7 @@ "type": "string", "initializer": "\"none\"", "constructor_assignment": "\"none\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 472 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3578,8 +3055,7 @@ "type": "int", "initializer": null, "constructor_assignment": "1500", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 475 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3587,8 +3063,7 @@ "type": "int", "initializer": null, "constructor_assignment": "15000", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 476 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3596,8 +3071,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 477 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3605,8 +3079,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"/web/assets/img/banner-light.png\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 478 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3614,8 +3087,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 479 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3623,8 +3095,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 485 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3632,8 +3103,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 491 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3641,8 +3111,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 494 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3650,8 +3119,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 495 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3659,8 +3127,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"US\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 496 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3668,8 +3135,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 497 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3677,8 +3143,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 498 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3686,8 +3151,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 499 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3695,8 +3159,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 500 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3704,8 +3167,7 @@ "type": "long", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 501 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3713,8 +3175,7 @@ "type": "long", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 502 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3722,8 +3183,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 505 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3731,8 +3191,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 506 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3740,8 +3199,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 507 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3749,8 +3207,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 508 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3758,8 +3215,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 509 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3767,8 +3223,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 510 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3776,8 +3231,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 511 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3785,8 +3239,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 512 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3794,8 +3247,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 513 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3803,8 +3255,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 514 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3812,8 +3263,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 515 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3821,8 +3271,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 516 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3830,8 +3279,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"percentage\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 517 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3839,8 +3287,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"hours\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 518 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3848,8 +3295,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 519 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3857,8 +3303,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 520 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3866,8 +3311,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 521 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3875,8 +3319,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 522 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3884,8 +3327,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 523 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3893,8 +3335,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 524 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3902,8 +3343,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 525 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3911,8 +3351,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 526 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3920,8 +3359,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 535 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3929,8 +3367,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 536 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3938,8 +3375,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 540 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3947,8 +3383,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 542 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3956,8 +3391,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 544 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3965,8 +3399,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 550 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3974,8 +3407,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 556 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3983,8 +3415,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 565 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -3992,8 +3423,7 @@ "type": "int", "initializer": "400", "constructor_assignment": "400", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 571 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4001,8 +3431,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 577 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4010,8 +3439,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 579 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4019,8 +3447,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 580 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4028,8 +3455,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 581 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4037,8 +3463,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 586 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4046,8 +3471,7 @@ "type": "List", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 587 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4055,8 +3479,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 588 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4064,8 +3487,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 589 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4073,8 +3495,7 @@ "type": "int", "initializer": "5", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 590 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4082,8 +3503,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 591 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4091,8 +3511,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 592 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4100,8 +3519,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 593 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4109,8 +3527,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 594 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4118,8 +3535,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 595 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4127,8 +3543,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 596 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4136,8 +3551,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 597 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4145,8 +3559,7 @@ "type": "int", "initializer": "1", "constructor_assignment": "1", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 598 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4154,8 +3567,7 @@ "type": "int", "initializer": "2", "constructor_assignment": "2", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 599 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4163,8 +3575,7 @@ "type": "int", "initializer": "3", "constructor_assignment": "3", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 600 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4172,8 +3583,7 @@ "type": "int", "initializer": "4", "constructor_assignment": "4", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 601 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4181,8 +3591,7 @@ "type": "int", "initializer": "5", "constructor_assignment": "5", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 602 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4190,8 +3599,7 @@ "type": "int", "initializer": "6", "constructor_assignment": "6", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 603 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4199,8 +3607,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 604 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4208,8 +3615,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 605 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4217,8 +3623,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 606 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4226,8 +3631,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 607 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4235,8 +3639,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 609 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4244,8 +3647,7 @@ "type": "string", "initializer": null, "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 616 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4253,8 +3655,7 @@ "type": "int", "initializer": null, "constructor_assignment": "30", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 617 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4262,8 +3663,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 618 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4271,8 +3671,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 619 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4280,8 +3679,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 620 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4289,8 +3687,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 621 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4298,8 +3695,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 622 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4307,8 +3703,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 623 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4316,8 +3711,7 @@ "type": "int", "initializer": null, "constructor_assignment": "2", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 624 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4325,8 +3719,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 625 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4334,8 +3727,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 626 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4343,8 +3735,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 627 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4352,8 +3743,7 @@ "type": "string", "initializer": "\"top-left\"", "constructor_assignment": "\"top-left\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 629 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4361,8 +3751,7 @@ "type": "string", "initializer": "\"top-right\"", "constructor_assignment": "\"top-right\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 631 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4370,8 +3759,7 @@ "type": "string", "initializer": "\"bottom-left\"", "constructor_assignment": "\"bottom-left\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 633 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4379,8 +3767,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 646 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4388,8 +3775,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 655 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4397,8 +3783,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 662 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4406,8 +3791,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 669 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4415,8 +3799,7 @@ "type": "string", "initializer": "\"bottom-right\"", "constructor_assignment": "\"bottom-right\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 671 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4424,8 +3807,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 676 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4433,8 +3815,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 677 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4442,8 +3823,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 678 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4451,8 +3831,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 679 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4460,8 +3839,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 680 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4469,8 +3847,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 681 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4478,8 +3855,7 @@ "type": "string", "initializer": "\"bottom-right\"", "constructor_assignment": "\"bottom-right\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 683 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4487,8 +3863,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 684 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4496,8 +3871,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 685 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4505,8 +3879,7 @@ "type": "string", "initializer": null, "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 686 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4514,8 +3887,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 689 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4523,8 +3895,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 690 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4532,8 +3903,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 691 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4541,8 +3911,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 692 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4550,8 +3919,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 693 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4559,8 +3927,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 694 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4568,8 +3935,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 695 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4577,8 +3943,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 696 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4586,8 +3951,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 697 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4595,8 +3959,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 698 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4604,8 +3967,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 699 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4613,8 +3975,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 700 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4622,8 +3983,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 701 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4631,8 +3991,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 702 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4640,8 +3999,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 703 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4649,8 +4007,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 704 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4658,8 +4015,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 705 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4667,8 +4023,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 706 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4676,8 +4031,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 707 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4685,8 +4039,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 708 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4694,8 +4047,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 709 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4703,8 +4055,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 710 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4712,8 +4063,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 711 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4721,8 +4071,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 712 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4730,8 +4079,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 713 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4739,8 +4087,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 714 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4748,8 +4095,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 715 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4757,8 +4103,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 719 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4766,8 +4111,7 @@ "type": "int", "initializer": "60", "constructor_assignment": "60", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 720 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4775,8 +4119,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 721 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4784,8 +4127,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4793,8 +4135,7 @@ "type": "int", "initializer": null, "constructor_assignment": "10", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 723 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4802,8 +4143,7 @@ "type": "int", "initializer": null, "constructor_assignment": "30", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 724 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4811,8 +4151,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 727 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4820,8 +4159,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 728 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4829,8 +4167,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 729 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4838,8 +4175,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 730 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4847,8 +4183,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 731 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4856,8 +4191,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 739 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4865,8 +4199,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 740 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4874,8 +4207,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 741 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4883,8 +4215,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 742 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4892,8 +4223,7 @@ "type": "string", "initializer": "\"[]\"", "constructor_assignment": "\"[]\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 745 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4901,8 +4231,7 @@ "type": "string", "initializer": "\"[]\"", "constructor_assignment": "\"[]\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 746 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4910,8 +4239,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 750 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4919,8 +4247,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 751 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4928,8 +4255,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 752 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4937,8 +4263,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 756 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4946,8 +4271,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 757 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4955,8 +4279,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 758 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4964,8 +4287,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 759 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4973,8 +4295,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 760 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4982,8 +4303,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 761 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -4991,8 +4311,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 764 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5000,8 +4319,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 765 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5009,8 +4327,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 766 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5018,8 +4335,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"JE Arr Tag: \"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 767 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5027,8 +4343,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 768 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5036,8 +4351,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 769 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5045,8 +4359,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 770 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5054,8 +4367,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 771 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5063,8 +4375,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 772 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5072,8 +4383,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 775 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5081,8 +4391,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"JE Language: \"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 776 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5090,8 +4399,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 777 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5099,8 +4407,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 780 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5108,8 +4415,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 781 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5117,8 +4423,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 784 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5126,8 +4431,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 787 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5135,8 +4439,7 @@ "type": "int", "initializer": null, "constructor_assignment": "2", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 788 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5144,8 +4447,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 789 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5153,8 +4455,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 792 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5162,8 +4463,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"OnMinutesWatched\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 793 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5171,8 +4471,7 @@ "type": "int", "initializer": null, "constructor_assignment": "20", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 794 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5180,8 +4479,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 795 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5189,8 +4487,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"default\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 796 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5198,8 +4495,7 @@ "type": "int", "initializer": null, "constructor_assignment": "-1", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 797 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5207,8 +4503,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 798 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5216,8 +4511,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 799 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5225,8 +4519,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 800 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5234,8 +4527,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 803 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5243,8 +4535,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 804 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5252,8 +4543,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 805 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5261,8 +4551,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 806 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5270,8 +4559,7 @@ "type": "int", "initializer": null, "constructor_assignment": "365", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 807 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5279,8 +4567,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 810 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5288,8 +4575,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 811 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5297,8 +4583,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 814 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5306,8 +4591,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 815 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5315,8 +4599,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 816 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5324,8 +4607,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 824 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5333,8 +4615,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 832 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5342,8 +4623,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 841 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5351,8 +4631,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 844 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5360,8 +4639,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"emoji\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 846 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5369,8 +4647,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 849 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5378,8 +4655,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 850 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5387,8 +4663,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 851 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5396,8 +4671,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 852 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5405,8 +4679,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 853 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5414,8 +4687,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 854 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5423,8 +4695,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 855 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5432,8 +4703,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 856 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5441,8 +4711,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 861 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5450,8 +4719,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 862 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5459,8 +4727,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 863 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5468,8 +4735,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 864 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5477,8 +4743,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 873 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5486,8 +4751,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 874 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5495,8 +4759,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 875 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5504,8 +4767,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 883 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5513,8 +4775,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 884 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5522,8 +4783,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 886 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5531,8 +4791,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 889 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5540,8 +4799,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 890 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5549,8 +4807,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 891 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5558,8 +4815,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 899 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5567,8 +4823,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 900 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5576,8 +4831,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 902 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5585,8 +4839,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 903 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5594,8 +4847,7 @@ "type": "int", "initializer": null, "constructor_assignment": "30", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 904 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5603,8 +4855,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 905 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5612,8 +4863,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 906 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5621,8 +4871,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 907 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5630,8 +4879,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 908 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5639,8 +4887,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 909 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5648,8 +4895,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 912 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5657,8 +4903,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 913 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5666,8 +4911,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 914 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5675,8 +4919,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 922 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5684,8 +4927,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 923 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5693,8 +4935,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 925 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5702,8 +4943,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"Monday\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 926 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5711,8 +4951,7 @@ "type": "string", "initializer": null, "constructor_assignment": "\"5pm/5:30pm\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 927 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5720,8 +4959,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 928 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5729,8 +4967,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 929 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5738,8 +4975,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 930 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5747,8 +4983,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 931 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5756,8 +4991,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 932 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5765,8 +4999,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 935 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5774,8 +5007,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 936 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5783,8 +5015,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 937 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5792,8 +5023,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 938 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5801,8 +5031,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 939 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5810,8 +5039,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 941 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5819,8 +5047,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 944 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5828,8 +5055,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 945 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5837,8 +5063,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 946 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5846,8 +5071,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 954 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5855,8 +5079,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 955 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5864,8 +5087,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 957 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5873,8 +5095,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 960 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5882,8 +5103,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 963 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5891,8 +5111,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 964 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5900,8 +5119,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 965 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5909,8 +5127,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 966 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5918,8 +5135,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 967 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5927,8 +5143,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 968 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5936,8 +5151,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 969 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5945,8 +5159,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 970 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5954,8 +5167,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 971 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5963,8 +5175,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 972 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5972,8 +5183,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 973 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5981,8 +5191,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 974 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5990,8 +5199,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 975 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -5999,8 +5207,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 976 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6008,8 +5215,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 977 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6017,8 +5223,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 978 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6026,8 +5231,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 979 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6035,8 +5239,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 985 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6044,8 +5247,7 @@ "type": "int", "initializer": "40", "constructor_assignment": "40", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 989 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6053,8 +5255,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1001 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6062,8 +5263,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1002 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6071,8 +5271,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1003 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6080,8 +5279,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1004 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6089,8 +5287,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1005 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6098,8 +5295,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1009 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6107,8 +5303,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1010 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6116,8 +5311,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1014 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6125,8 +5319,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1019 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6134,8 +5327,7 @@ "type": "string", "initializer": "\"GuestStars\"", "constructor_assignment": "\"GuestStars\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1020 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6143,8 +5335,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1024 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6152,8 +5343,7 @@ "type": "string", "initializer": "\"hide\"", "constructor_assignment": "\"hide\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1030 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6161,8 +5351,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1035 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6170,8 +5359,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1039 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6179,8 +5367,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1045 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6188,8 +5375,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1051 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6197,8 +5383,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1058 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6206,8 +5391,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1059 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6215,8 +5399,7 @@ "type": "string", "initializer": "\"\"", "constructor_assignment": "\"\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1060 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6224,8 +5407,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1066 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6233,8 +5415,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1072 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6242,8 +5423,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1079 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6251,8 +5431,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1084 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6260,8 +5439,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1085 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6269,8 +5447,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1086 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6278,8 +5455,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1087 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6287,8 +5463,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1088 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6296,8 +5471,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1089 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6305,8 +5479,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1090 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6314,8 +5487,7 @@ "type": "string", "initializer": "\"Spoiler Guard activated\"", "constructor_assignment": "\"Spoiler Guard activated\"", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1093 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6323,8 +5495,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1102 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6332,8 +5503,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1104 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6341,8 +5511,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1106 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6350,8 +5519,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": "true", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1108 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6359,8 +5527,7 @@ "type": "bool", "initializer": null, "constructor_assignment": "false", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1116 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6368,8 +5535,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1118 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6377,8 +5543,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1120 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6386,8 +5551,7 @@ "type": "int", "initializer": "15", "constructor_assignment": "15", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1122 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6395,8 +5559,7 @@ "type": "long", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1124 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6404,8 +5567,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1126 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6413,8 +5575,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1128 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6422,8 +5583,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1130 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6431,8 +5591,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": "string.Empty", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1132 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "PluginConfiguration", @@ -6440,8 +5599,7 @@ "type": "int", "initializer": null, "constructor_assignment": "0", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", - "line": 1141 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" }, { "class": "UserSettings", @@ -6449,8 +5607,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 9 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6458,8 +5615,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 10 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6467,8 +5623,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 11 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6476,8 +5631,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 12 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6485,8 +5639,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 13 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6494,8 +5647,7 @@ "type": "int", "initializer": "5", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 14 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6503,8 +5655,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 15 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6512,8 +5663,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 16 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6521,8 +5671,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 17 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6530,8 +5679,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 18 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6539,8 +5687,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 19 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6548,8 +5695,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 20 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6557,8 +5703,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 21 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6566,8 +5711,7 @@ "type": "string", "initializer": "\"#FFFFFFFF\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 22 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6575,8 +5719,7 @@ "type": "string", "initializer": "\"#00000000\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 23 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6584,8 +5727,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 24 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6593,8 +5735,7 @@ "type": "int", "initializer": "85", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 25 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6602,8 +5743,7 @@ "type": "int", "initializer": "50", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 26 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6611,8 +5751,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 27 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6620,8 +5759,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 28 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6629,8 +5767,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 29 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6638,8 +5775,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 30 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6647,8 +5783,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 31 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6656,8 +5791,7 @@ "type": "string", "initializer": "\"\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 32 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6665,8 +5799,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 33 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6674,8 +5807,7 @@ "type": "string", "initializer": "\"percentage\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 34 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6683,8 +5815,7 @@ "type": "string", "initializer": "\"hours\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 35 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6692,8 +5823,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 36 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6701,8 +5831,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 37 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6710,8 +5839,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 39 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6719,8 +5847,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 40 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6728,8 +5855,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 41 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6737,8 +5863,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 42 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6746,8 +5871,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 43 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6755,8 +5879,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 44 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6764,8 +5887,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 45 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6773,8 +5895,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 46 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6782,8 +5903,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 47 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6791,8 +5911,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 48 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6800,8 +5919,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 49 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6809,8 +5927,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 50 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6818,8 +5935,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 51 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6827,8 +5943,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 52 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6836,8 +5951,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 53 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6845,8 +5959,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 54 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6854,8 +5967,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 55 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6863,8 +5975,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 56 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6872,8 +5983,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 57 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6881,8 +5991,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 58 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6890,8 +5999,7 @@ "type": "string", "initializer": "\"top-left\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 59 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6899,8 +6007,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 63 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6908,8 +6015,7 @@ "type": "string", "initializer": "\"top-right\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 64 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6917,8 +6023,7 @@ "type": "string", "initializer": "\"bottom-left\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 65 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6926,8 +6031,7 @@ "type": "string", "initializer": "\"bottom-right\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 66 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6935,8 +6039,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 67 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6944,8 +6047,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 68 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6953,8 +6055,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 69 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6962,8 +6063,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 70 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6971,8 +6071,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 71 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6980,8 +6079,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 72 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6989,8 +6087,7 @@ "type": "string", "initializer": "\"bottom-right\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 73 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -6998,8 +6095,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 74 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7007,8 +6103,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 75 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7016,8 +6111,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 76 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7025,8 +6119,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 77 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7034,8 +6127,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 78 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7043,8 +6135,7 @@ "type": "string", "initializer": "\"list\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 79 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSettings", @@ -7052,8 +6143,7 @@ "type": "string", "initializer": "\"agenda\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 80 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurSeriesEntry", @@ -7061,8 +6151,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 89 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurSeriesEntry", @@ -7070,8 +6159,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 92 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurSeriesEntry", @@ -7079,8 +6167,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 94 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurMovieEntry", @@ -7088,8 +6175,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 102 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurMovieEntry", @@ -7097,8 +6183,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 103 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurMovieEntry", @@ -7106,8 +6191,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 104 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurCollectionEntry", @@ -7115,8 +6199,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 116 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurCollectionEntry", @@ -7124,8 +6207,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 117 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurCollectionEntry", @@ -7133,8 +6215,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 118 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurPendingEntry", @@ -7142,8 +6223,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 131 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurPendingEntry", @@ -7151,8 +6231,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 132 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurPendingEntry", @@ -7160,8 +6239,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 133 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurPendingEntry", @@ -7169,8 +6247,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 134 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7178,8 +6255,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 143 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7187,8 +6263,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 144 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7196,8 +6271,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 145 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7205,8 +6279,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 146 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7214,8 +6287,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 147 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7223,8 +6295,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 149 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7232,8 +6303,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 150 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7241,8 +6311,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 151 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7250,8 +6319,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 152 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7259,8 +6327,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 153 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7268,8 +6335,7 @@ "type": "bool?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 160 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "SpoilerBlurUserPrefs", @@ -7277,8 +6343,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 163 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserSpoilerBlur", @@ -7286,8 +6351,7 @@ "type": "SpoilerBlurUserPrefs", "initializer": "new SpoilerBlurUserPrefs()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 225 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserShortcuts", @@ -7295,8 +6359,7 @@ "type": "List", "initializer": "new List()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 230 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7304,8 +6367,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 235 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7313,8 +6375,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 236 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7322,8 +6383,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 237 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7331,8 +6391,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 238 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7340,8 +6399,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 239 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7349,8 +6407,7 @@ "type": "double", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 240 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7358,8 +6415,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 241 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7367,8 +6423,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 242 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7376,8 +6431,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 243 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7385,8 +6439,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 244 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7394,8 +6447,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 245 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "BookmarkItem", @@ -7403,8 +6455,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 246 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserBookmark", @@ -7412,8 +6463,7 @@ "type": "Dictionary", "initializer": "new Dictionary()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 251 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ElsewhereSettings", @@ -7421,8 +6471,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 256 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ElsewhereSettings", @@ -7430,8 +6479,7 @@ "type": "List", "initializer": "new List()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 257 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ElsewhereSettings", @@ -7439,8 +6487,7 @@ "type": "List", "initializer": "new List()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 258 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "PendingWatchlistItem", @@ -7448,8 +6495,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 263 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "PendingWatchlistItem", @@ -7457,8 +6503,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 264 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "PendingWatchlistItem", @@ -7466,8 +6511,7 @@ "type": "System.DateTime", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 265 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "PendingWatchlistItems", @@ -7475,8 +6519,7 @@ "type": "List", "initializer": "new List()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 270 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ProcessedWatchlistItem", @@ -7484,8 +6527,7 @@ "type": "int", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 275 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ProcessedWatchlistItem", @@ -7493,8 +6535,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 276 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ProcessedWatchlistItem", @@ -7502,8 +6543,7 @@ "type": "System.DateTime", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 277 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ProcessedWatchlistItem", @@ -7511,8 +6551,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 284 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ProcessedWatchlistItems", @@ -7520,8 +6559,7 @@ "type": "List", "initializer": "new List()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 289 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7529,8 +6567,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 294 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7538,8 +6575,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 295 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7547,8 +6583,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 296 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7556,8 +6591,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 297 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7565,8 +6599,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 298 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7574,8 +6607,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 299 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7583,8 +6615,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 300 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7592,8 +6623,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 301 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7601,8 +6631,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 302 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7610,8 +6639,7 @@ "type": "int?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 303 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentItem", @@ -7619,8 +6647,7 @@ "type": "string", "initializer": "\"global\"", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 304 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7628,8 +6655,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 309 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7637,8 +6663,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 310 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7646,8 +6671,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 311 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7655,8 +6679,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 312 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7664,8 +6687,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 313 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7673,8 +6695,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 314 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7682,8 +6703,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 315 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7691,8 +6711,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 316 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7700,8 +6719,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 317 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7709,8 +6727,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 318 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7718,8 +6735,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 319 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7727,8 +6743,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 320 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7736,8 +6751,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 321 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7745,8 +6759,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 322 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7754,8 +6767,7 @@ "type": "bool", "initializer": "true", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 323 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7763,8 +6775,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 324 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "HiddenContentSettings", @@ -7772,8 +6783,7 @@ "type": "bool", "initializer": "false", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 325 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserHiddenContent", @@ -7781,8 +6791,7 @@ "type": "Dictionary", "initializer": "new Dictionary()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 330 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserHiddenContent", @@ -7790,8 +6799,7 @@ "type": "HiddenContentSettings", "initializer": "new HiddenContentSettings()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 331 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7799,8 +6807,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 340 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7808,8 +6815,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 341 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7817,8 +6823,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 343 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7826,8 +6831,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 344 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7835,8 +6839,7 @@ "type": "double?", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 346 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7844,8 +6847,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 347 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "UserReview", @@ -7853,8 +6855,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 348 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "AllReviewsStore", @@ -7862,8 +6863,7 @@ "type": "Dictionary", "initializer": "new Dictionary()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 357 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7871,8 +6871,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 368 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7880,8 +6879,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 370 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7889,8 +6887,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 372 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7898,8 +6895,7 @@ "type": "string", "initializer": "string.Empty", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 373 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7907,8 +6903,7 @@ "type": "bool", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 382 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "ActivityEntry", @@ -7916,8 +6911,7 @@ "type": "double", "initializer": null, "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 388 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" }, { "class": "AllActivityStore", @@ -7925,685 +6919,265 @@ "type": "Dictionary", "initializer": "new Dictionary()", "constructor_assignment": null, - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", - "line": 399 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" } ], "storage_references": [ - { - "literal": "reviews.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "line": 398 - }, - { - "literal": "reviews.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "line": 549 - }, { "literal": "activity.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "line": 773 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs" }, { "literal": "processed-watchlist-items.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "line": 905 - }, - { - "literal": "processed-watchlist-items.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", - "line": 911 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3807 - }, - { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3808 - }, - { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3808 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3809 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3809 - }, - { - "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3810 - }, - { - "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3810 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 3811 - }, - { - "literal": "locales/{lang}.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4466 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs" }, { - "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{sanitizedLang}.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4470 + "literal": "reviews.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs" }, { "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{baseLang}.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4480 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "user-settings/{userId}/settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4673 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4695 + "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{sanitizedLang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4764 + "literal": "Saved enhanced bookmarks for {ResolveUserDisplay(authorizedUserId)} to bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4769 + "literal": "Saved hidden content for {ResolveUserDisplay(authorizedUserId)} to hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "user-settings/{userId}/shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4786 + "literal": "Saved user elsewhere settings for {ResolveUserDisplay(authorizedUserId)} to elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4797 + "literal": "Saved user shortcuts for {ResolveUserDisplay(authorizedUserId)} to shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "user-settings/{userId}/elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4801 + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4812 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "user-settings/{userId}/settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4816 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4830 + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4855 + "literal": "locales/{lang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4861 - }, - { - "literal": "user-settings/{userId}/shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4870 - }, - { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4883 - }, - { - "literal": "Saved user shortcuts for {ResolveUserDisplay(authorizedUserId)} to shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4884 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4885 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "user-settings/{userId}/bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4894 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4906 - }, - { - "literal": "user-settings/{userId}/bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4910 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4923 - }, - { - "literal": "Saved enhanced bookmarks for {ResolveUserDisplay(authorizedUserId)} to bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4924 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4925 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 4969 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5017 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "user-settings/{userId}/elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5038 - }, - { - "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5051 - }, - { - "literal": "Saved user elsewhere settings for {ResolveUserDisplay(authorizedUserId)} to elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5052 - }, - { - "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5053 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "user-settings/{userId}/hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5062 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5086 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5091 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5106 - }, - { - "literal": "user-settings/{userId}/hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5133 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5151 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5157 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5171 - }, - { - "literal": "Saved hidden content for {ResolveUserDisplay(authorizedUserId)} to hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5174 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5175 + "literal": "user-settings/{userId}/settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { - "literal": "user-settings/{userId}/spoilerblur.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5187 + "literal": "user-settings/{userId}/shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "user-settings/{userId}/spoilerblur.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5216 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5369 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" }, { "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5423 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5470 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 5544 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6874 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6878 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 6959 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7732 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", - "line": 7745 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", - "line": 81 - }, - { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", - "line": 207 + "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs" }, { "literal": "config.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs", - "line": 401 + "file": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs" }, { "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", - "line": 413 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs" }, { "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", - "line": 416 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs" }, { "literal": ".meta.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", - "line": 507 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs" }, { "literal": ".meta.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", - "line": 649 - }, - { - "literal": ".meta.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", - "line": 650 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs" }, { "literal": "component-scripts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs", - "line": 48 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs" }, { "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs", - "line": 19 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs" }, { "literal": "https://raw.githubusercontent.com/n00bcodr/jellyfin-plugins/main/manifest.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs", - "line": 48 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs" }, { "literal": "maintenance-state.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs", - "line": 71 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs" }, { "literal": "mdblist-ratings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs", - "line": 82 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", - "line": 18 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs" }, { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", - "line": 33 + "literal": "age-rating-colours.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" }, { "literal": "icons.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", - "line": 211 - }, - { - "literal": "vector-icons.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", - "line": 228 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" }, { "literal": "quality-colours.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", - "line": 247 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" }, { - "literal": "age-rating-colours.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", - "line": 267 + "literal": "vector-icons.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" }, { "literal": "spoilerblur.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs", - "line": 45 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs" }, { "literal": "tag-cache.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs", - "line": 229 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs" }, { "literal": "tmdb-cache.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs", - "line": 167 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs" }, { "literal": "usage-counters.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs", - "line": 53 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs" }, { "literal": "Jellyfin.Plugin.JellyfinEnhanced.Configuration.whats-new-seed.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", - "line": 35 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" }, { "literal": "config-schema-snapshot.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", - "line": 57 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" }, { "literal": "whats-new.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", - "line": 58 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" }, { "literal": "awards.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs", - "line": 83 + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", - "line": 59 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", - "line": 72 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js" }, { "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js", - "line": 444 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js", - "line": 810 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js", - "line": 262 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "line": 275 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "line": 305 - }, - { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "line": 336 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js" }, { "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "line": 386 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js" }, { "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", - "line": 427 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", - "line": 58 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js" }, { "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", - "line": 62 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", - "line": 62 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js", - "line": 145 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js" }, { "literal": "en.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", - "line": 60 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", - "line": 142 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 54 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 150 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 157 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 172 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 213 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 244 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 296 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 340 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 389 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 514 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 572 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 592 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 602 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 616 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 617 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 651 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 687 - }, - { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", - "line": 813 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js" }, { "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", - "line": 65 - }, - { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", - "line": 157 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js" }, { "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js", - "line": 258 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js" }, { "literal": "/JellyfinEnhanced/locales/en.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", - "line": 175 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js" }, { - "literal": "settings.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "line": 907 + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" }, { - "literal": "shortcuts.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "line": 908 + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" }, { - "literal": "bookmark.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "line": 909 + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" }, { - "literal": "elsewhere.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "line": 910 + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" }, { - "literal": "hidden-content.json", - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", - "line": 911 + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" } ], "scheduled_tasks": [ @@ -8670,136 +7244,103 @@ ], "locales": [ { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json" }, { - "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json", - "top_level_keys": 722 + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json" } ], "counts": { @@ -8807,7 +7348,7 @@ "files": 339, "routes": 167, "configuration_properties": 501, - "storage_references": 135, + "storage_references": 64, "scheduled_tasks": 12, "locales": 33 } diff --git a/tests/inventory/generate.py b/tests/inventory/generate.py index 61ed81938..25ced1fa2 100644 --- a/tests/inventory/generate.py +++ b/tests/inventory/generate.py @@ -2,7 +2,9 @@ """Inventory production surfaces for coverage review; this is not a behavioral test. Extraction is deliberately conservative and lexical (not a C# parser). The output -retains paths/line numbers so reviewers can inspect overloads and attributes. +keeps file paths but no line numbers, line counts or locale key counts, so it only +changes when a production file, route, setting, scheduled task or storage reference +is added, removed or renamed (not on every edit or translation update). Run with --check in CI to detect an inventory needing regeneration. """ import argparse @@ -16,10 +18,6 @@ OUTPUT = ROOT / 'tests/docs/production-inventory.json' -def line_number(text, offset): - return text.count('\n', 0, offset) + 1 - - def inventory(): project = ET.parse(PRODUCTION / 'JellyfinEnhanced.csproj') targets = [] @@ -28,7 +26,7 @@ def inventory(): if selector and group.findtext('TargetFramework'): targets.append({'selector': selector[1], 'framework': group.findtext('TargetFramework'), 'jellyfin_reference': group.findtext('JellyfinVersion')}) - result = {'schema_version': 1, + result = {'schema_version': 2, 'extraction': 'Lexical inventory only; entries do not imply behavioral coverage. Public property types/defaults and route attributes require semantic review.', 'build_targets': targets, 'files': [], 'routes': [], 'configuration_properties': [], @@ -41,7 +39,7 @@ def inventory(): continue text = path.read_text(encoding='utf-8-sig') category = path.relative_to(PRODUCTION).parts[0] - result['files'].append({'path': relative, 'category': category, 'lines': len(text.splitlines())}) + result['files'].append({'path': relative, 'category': category}) if category == 'Controllers' and path.suffix == '.cs': for match in re.finditer(r'\[Http(Get|Post|Put|Delete|Patch|Head|Options)\("([^"\n]*)"\)\]', text): tail = text[match.end():] @@ -49,7 +47,6 @@ def inventory(): attributes = tail[:method.start()] if method else '' result['routes'].append({'verb': match[1].upper(), 'path': '/JellyfinEnhanced/' + match[2], 'method': method[1] if method else None, 'file': relative, - 'line': line_number(text, match.start()), 'following_attributes': re.findall(r'\[([^\n]+)\]', attributes)}) if category == 'Configuration' and path.suffix == '.cs': classes = list(re.finditer(r'\bclass\s+(\w+)', text)) @@ -59,16 +56,17 @@ def inventory(): result['configuration_properties'].append({'class': preceding[-1][1] if preceding else None, 'name': match[2], 'type': match[1].strip(), 'initializer': match[3], 'constructor_assignment': constructor[1] if constructor else None, - 'file': relative, 'line': line_number(text, match.start())}) + 'file': relative}) if path.suffix in ('.cs', '.js'): - for match in re.finditer(r'''["']([^"'\n]{1,180}\.(?:json|xml|db|sqlite))["']''', text): - result['storage_references'].append({'literal': match[1], 'file': relative, 'line': line_number(text, match.start())}) + # One entry per literal and file: repeating a known reference is not a new one. + literals = sorted({match[1] for match in re.finditer(r'''["']([^"'\n]{1,180}\.(?:json|xml|db|sqlite))["']''', text)}) + result['storage_references'].extend({'literal': literal, 'file': relative} for literal in literals) if category == 'ScheduledTasks' and re.search(r':\s*IScheduledTask', text): result['scheduled_tasks'].append({'file': relative, 'class': path.stem, 'key': next(iter(re.findall(r'\bKey\s*=>\s*"([^"]+)"', text)), None)}) if 'js/locales/' in relative and path.suffix == '.json': - content = json.loads(text) - result['locales'].append({'file': relative, 'top_level_keys': len(content)}) + json.loads(text) # a malformed locale still fails generation + result['locales'].append({'file': relative}) result['counts'] = {key: len(value) for key, value in result.items() if isinstance(value, list)} return json.dumps(result, indent=2, ensure_ascii=False) + '\n' From 149b6b67329b88fd88aed4f98bbea5c3a40cb1ce Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:42 +0800 Subject: [PATCH 018/111] ci(security): scan both build targets for vulnerable packages The audit installed only the .NET 9 SDK, so restoring the net10 default target failed and the grep for vulnerable packages passed on empty output. It now installs .NET 9 and 10, restores and lists jf12 and jf10 separately with pipefail, and fails on any restore error, reported problem or vulnerable package in the JSON output. --- .github/workflows/security-scan.yml | 47 +++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 5709bc5fd..40629a404 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -56,20 +56,49 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 with: - dotnet-version: '9.0.x' + dotnet-version: | + 9.0.x + 10.0.x + # Both build targets: jf12 (net10.0, the default) and jf10 (net9.0). JellyfinTarget is read + # from the environment as an MSBuild property. A restore or listing failure fails the step. - name: List vulnerable packages - run: dotnet list Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj package --vulnerable --include-transitive 2>&1 | tee vulnerable-packages.txt + shell: bash + run: | + set -euo pipefail + for target in jf12 jf10; do + echo "::group::$target" + JellyfinTarget=$target dotnet restore Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj + JellyfinTarget=$target dotnet list Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj package \ + --vulnerable --include-transitive --no-restore --format json > "vulnerable-$target.json" + cat "vulnerable-$target.json" + echo "::endgroup::" + done - name: Check for vulnerabilities + shell: bash run: | - if grep -q "has the following vulnerable packages" vulnerable-packages.txt; then - echo "::error::Vulnerable packages found!" - cat vulnerable-packages.txt - exit 1 - else - echo "No vulnerable packages found." - fi + python3 - vulnerable-jf12.json vulnerable-jf10.json <<'EOF' + import json, sys + failed = False + for path in sys.argv[1:]: + report = json.load(open(path)) + projects = report.get('projects') or [] + if not projects: + print(f'::error::{path}: no project was scanned') + failed = True + for problem in report.get('problems') or []: + print(f"::error::{path}: {problem.get('text', problem)}") + failed = True + for project in projects: + for framework in project.get('frameworks') or []: + for package in (framework.get('topLevelPackages') or []) + (framework.get('transitivePackages') or []): + print(f"::error::{path}: {framework.get('framework')} {package.get('id')} {package.get('resolvedVersion')} has known vulnerabilities") + failed = True + if failed: + sys.exit(1) + print('No vulnerable packages found for jf12 or jf10.') + EOF # SARIF upload for GitHub Security tab upload-sarif: From d8f4f3cce2e03424cbc81439918078a5a1c0be84 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:49 +0800 Subject: [PATCH 019/111] test: raise coverage floors to the measured values and fix stale comments The backend floors (38% lines / 27% branches) sat ten points under the measured 48.3% / 36.3% on both targets, so they could not catch a real drop; they are now 47% / 35%. The frontend floors move from 20/15/21/21 to 28/20/29/30 (statements/branches/functions/lines) against the measured 29.5/21.3/30.3/31.0. The poster harness project comments no longer claim CI never builds them. --- tests/docs/README.md | 2 +- tests/frontend/README.md | 5 +++-- tests/frontend/helpers/coverage.mjs | 2 +- tests/poster/parity/PosterTagParity/PosterTagParity.csproj | 4 ++-- tests/poster/rendering/PosterTagsHarness.csproj | 3 ++- tests/runner/coverage.py | 6 +++--- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/docs/README.md b/tests/docs/README.md index f566e487a..1e103626e 100644 --- a/tests/docs/README.md +++ b/tests/docs/README.md @@ -58,7 +58,7 @@ Use distinct `--artifacts-path` directories for concurrent .NET builds, especial The runner writes commands, exit status, durations, and log locations to `artifacts/regression/summary.json`. Backend TRX/Cobertura reports are under `artifacts/regression/backend/`; frontend HTML/LCOV coverage is under `artifacts/frontend-coverage/`; browser reports/traces/screenshots are under `artifacts/browser/`. Host and poster reports include the exact scenarios exercised. Generated artifacts are ignored by Git. `--artifacts` relocates runner/backend/host/poster/mutation reports; frontend and browser tools keep the fixed paths above. The clean-tree command copies those fixed reports back before removing its temporary source tree and records source hashes for the exact local snapshot tested. -Coverage includes unexecuted production modules; thresholds prevent measured coverage from silently dropping but do not certify feature completeness. Backend floors are 38% lines and 27% branches, below the initial integrated 41%/29% baseline; frontend global floors are 20% statements, 15% branches, 21% functions and 21% lines (below the measured 22%/17%/23%/23% baseline); the frontend runner declares its global and core-module floors alongside report generation. No production code is excluded merely to raise these values. Review the behavioral matrix alongside reports. The generated inventory is a lexical list of production files, routes, settings, tasks, and storage references—not a behavioral test. +Coverage includes unexecuted production modules; thresholds prevent measured coverage from silently dropping but do not certify feature completeness. Backend floors are 47% lines and 35% branches, just under the measured 48.3%/36.3% on both targets; frontend global floors are 28% statements, 20% branches, 29% functions and 30% lines, just under the measured 29.5%/21.3%/30.3%/31.0%. The frontend runner declares its global and core-module floors alongside report generation. No production code is excluded merely to raise these values. Review the behavioral matrix alongside reports. The generated inventory is a lexical list of production files, routes, settings, tasks, and storage references—not a behavioral test. The [historical issue/PR audit](history-audit.md) records proven existing catches, newly repaired blind spots, and unproven reports. Historical replay also runs in scheduled/manual extended CI and the full `all` suite. diff --git a/tests/frontend/README.md b/tests/frontend/README.md index 509a37059..e3c8bdb20 100644 --- a/tests/frontend/README.md +++ b/tests/frontend/README.md @@ -19,8 +19,9 @@ scripts are outside this JS coverage report. Reports are written to `artifacts/frontend-coverage` (HTML, LCOV, JSON summary). Browser-suite execution is not merged into this unit/DOM report. -Minimum floors are 20% statements, 21% lines, 15% branches, and 21% -functions across the whole production inventory; these deliberately expose the +Minimum floors are 28% statements, 30% lines, 20% branches, and 29% +functions across the whole production inventory (just under the measured +29.5% / 31.0% / 21.3% / 30.3%); these deliberately expose the large remaining untested surface. The API, DOM observer, lifecycle, navigation, and session modules individually require 60% statements and 45% branches. These measured regression floors are not a completeness target. Raise them when diff --git a/tests/frontend/helpers/coverage.mjs b/tests/frontend/helpers/coverage.mjs index 0410e2220..36e0d1db7 100644 --- a/tests/frontend/helpers/coverage.mjs +++ b/tests/frontend/helpers/coverage.mjs @@ -34,7 +34,7 @@ for (const format of ['text-summary', 'json-summary', 'lcovonly', 'html']) repor // implying that this small percentage is comprehensive behavioral coverage. const totals = map.getCoverageSummary(); let failed = false; -for (const [metric, minimum] of Object.entries({ statements: 20, branches: 15, functions: 21, lines: 21 })) { +for (const [metric, minimum] of Object.entries({ statements: 28, branches: 20, functions: 29, lines: 30 })) { if (totals[metric].pct < minimum) { console.error(`Global ${metric}: ${totals[metric].pct}% < ${minimum}%`); failed = true; } } for (const name of ['api-client', 'dom-observer', 'lifecycle', 'navigation', 'session']) { diff --git a/tests/poster/parity/PosterTagParity/PosterTagParity.csproj b/tests/poster/parity/PosterTagParity/PosterTagParity.csproj index 2502164cc..652d881c1 100644 --- a/tests/poster/parity/PosterTagParity/PosterTagParity.csproj +++ b/tests/poster/parity/PosterTagParity/PosterTagParity.csproj @@ -1,8 +1,8 @@ + it lives outside Jellyfin.Plugin.JellyfinEnhanced/. Run through ../run.mjs, which + tests/runner/poster.py (and scheduled/manual CI via tests/run.py poster) invokes. --> Exe net10.0 diff --git a/tests/poster/rendering/PosterTagsHarness.csproj b/tests/poster/rendering/PosterTagsHarness.csproj index 6d2a530e6..1966c7a9f 100644 --- a/tests/poster/rendering/PosterTagsHarness.csproj +++ b/tests/poster/rendering/PosterTagsHarness.csproj @@ -1,7 +1,8 @@

    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�Tj@5so$jDJ=`I}8{W<-D?xA1OuaFh-Yr2o_ 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