diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3a886a8f3..cb72ce031 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,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/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 diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml new file mode 100644 index 000000000..3cf01599b --- /dev/null +++ b/.github/workflows/regression.yml @@ -0,0 +1,179 @@ +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: + # 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 + 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 + # Dependabot bumps package versions but cannot update the per-target lock files + # (packages.jf10/jf12.lock.json), so the locked restore below would stop with NU1004. + # Regenerate this target's lock files and fail with the fix instead. + - name: Check NuGet lock files (Dependabot) + if: github.event.pull_request.user.login == 'dependabot[bot]' + shell: bash + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + for project in Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj tests/backend/JE.Tests.csproj; do + dotnet restore "$project" -p:JellyfinTarget="$TARGET" --force-evaluate + done + if ! git diff --quiet -- '*.lock.json'; then + git --no-pager diff -- '*.lock.json' + echo "::error::The $TARGET NuGet lock files are out of date after this dependency update. Regenerate both targets' lock files as described in CONTRIBUTING.md (NuGet lock files) and push them to this branch." + exit 1 + fi + - 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/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 21a7784c6..6d423be7a 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -55,20 +55,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: 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..6ae520df5 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 | |---|---|---| @@ -167,12 +167,16 @@ Every PR runs a few automated checks (GitHub Actions, `.github/workflows/`). The | **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 | +| **NuGet lock files** | The backend regression jobs restore in locked mode from per-target lock files (`packages.jf12.lock.json`, `packages.jf10.lock.json`) beside the plugin and test projects, so a changed package reference fails with NU1004 until they are regenerated. Dependabot can't update these files, so its NuGet PRs fail a dedicated check with this fix | For each `JellyfinTarget` (`jf12`, `jf10`), run `dotnet restore Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj -p:JellyfinTarget= --force-evaluate` and the same for `tests/backend/JE.Tests.csproj`, then commit the four lock files | +| **Production inventory** | Part of the regression workflow: fails when `tests/docs/production-inventory.json` no longer matches the plugin. Ordinary edits and translation updates don't change it. It does change when you add, remove or rename a production file, locale, HTTP route (or its method), configuration property, scheduled task (any class implementing `IScheduledTask`) or storage literal; when a route's following attributes such as `[Authorize]` change; when a configuration property's type, default value or constructor assignment changes; or when a build target's `TargetFramework` or `JellyfinVersion` changes | 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. ## 🧪 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..ce099d30c 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(); } @@ -908,22 +911,55 @@ public void SaveProcessedWatchlistItems(Guid userId, ProcessedWatchlistItems ite SaveUserConfiguration(userId.ToString(), "processed-watchlist-items.json", items); } + /// Records a processed watchlist item for a user unless the same TMDB id and media + /// type is already recorded. Runs under the per-user file lock, so the scheduled + /// sync, a manual sync and the watchlist monitor cannot drop each other's entries. + /// Returns true when the item was added; a failed write is logged, not thrown, so + /// one user's unreadable file never stops a sync or monitor pass. + public bool MarkWatchlistItemProcessed(Guid userId, int tmdbId, string mediaType, string source) + { + try + { + return RmwUserConfiguration(userId.ToString(), "processed-watchlist-items.json", items => + { + if (items.Items.Any(p => p.TmdbId == tmdbId && p.MediaType == mediaType)) + { + return 0; + } + + items.Items.Add(new ProcessedWatchlistItem + { + TmdbId = tmdbId, + MediaType = mediaType, + ProcessedAt = System.DateTime.UtcNow, + Source = source + }); + return 1; + }) > 0; + } + catch (Exception ex) + { + _logger.Error($"Error recording processed watchlist item {mediaType}:{tmdbId} for user {userId}: {ex.Message}"); + return false; + } + } + /// Cleans up old processed watchlist items (older than specified days). public void CleanupOldProcessedWatchlistItems(Guid userId, int daysToKeep = 365) { try { - var items = GetProcessedWatchlistItems(userId); var cutoffDate = System.DateTime.UtcNow.AddDays(-daysToKeep); + var removed = RmwUserConfiguration(userId.ToString(), "processed-watchlist-items.json", items => + { + var originalCount = items.Items.Count; + items.Items = items.Items.Where(item => item.ProcessedAt > cutoffDate).ToList(); + return originalCount - items.Items.Count; + }); - var originalCount = items.Items.Count; - var itemsToKeep = items.Items.Where(item => item.ProcessedAt > cutoffDate).ToList(); - - if (itemsToKeep.Count != originalCount) + if (removed > 0) { - items.Items = itemsToKeep; - SaveProcessedWatchlistItems(userId, items); - _logger.Info($"Cleaned up {originalCount - itemsToKeep.Count} old processed watchlist items for user {userId}"); + _logger.Info($"Cleaned up {removed} old processed watchlist items for user {userId}"); } } catch (Exception ex) diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs index b865458d7..fce320470 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs @@ -1903,7 +1903,7 @@ private async Task EnrichQuotaWithResetAsync(JObject quota, string seerrUserId, continue; } - if (!DateTime.TryParse(createdEl.GetString(), null, + if (!DateTime.TryParse(createdEl.GetString(), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out var createdAt)) { continue; @@ -2470,8 +2470,8 @@ private async Task BuildPersonInfoAsync(BaseItem person, DateTime? itemP name = person.Name, tmdbId = tmdbId, type = person.GetType().Name, - birthDate = birthDate?.ToString("yyyy-MM-dd"), - deathDate = endDate?.ToString("yyyy-MM-dd"), + birthDate = birthDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + deathDate = endDate?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), birthPlace = birthPlace, isDeceased = isDeceased, currentAge = currentAge, @@ -2511,7 +2511,7 @@ private async Task BuildPersonInfoAsync(BaseItem person, DateTime? itemP // Parse birth date if (jsonElement.TryGetProperty("birthday", out var birthdayProp) && birthdayProp.ValueKind != JsonValueKind.Null && - DateTime.TryParse(birthdayProp.GetString(), out var birth)) + TryParseIso(birthdayProp.GetString()) is DateTime birth) { birthDate = birth; } @@ -2520,7 +2520,7 @@ private async Task BuildPersonInfoAsync(BaseItem person, DateTime? itemP if (jsonElement.TryGetProperty("deathday", out var deathdayProp) && deathdayProp.ValueKind != JsonValueKind.Null && deathdayProp.GetString() is string deathStr && - DateTime.TryParse(deathStr, out var death)) + TryParseIso(deathStr) is DateTime death) { deathDate = death; } @@ -2803,93 +2803,31 @@ public async Task SyncJellyseerrWatchlist() _logger.Info("[Manual Watchlist Sync] Starting manual Seerr watchlist sync..."); - int itemsProcessed = 0; - int itemsAdded = 0; - var errors = new List(); - - foreach (var user in _userManager.GetAllUsers()) + // The scheduled task's own pass, so a manual sync honours the same + // blocked users, re-addition prevention and requested-media settings. + var summary = await new ScheduledTasks.JellyseerrWatchlistSyncTask( + _libraryManager, _userManager, _userDataManager, _httpClientFactory, _userConfigurationManager, _logger) + .RunAsync(null, CancellationToken.None); + if (summary == null) { - try - { - _logger.Info($"[Manual Watchlist Sync] Processing user: {user.Username} ({user.Id})"); - - // Get Seerr user ID for this Jellyfin user - var jellyseerrUserId = await GetJellyseerrUserId(user.Id.ToString()); - if (string.IsNullOrEmpty(jellyseerrUserId)) - { - _logger.Warning($"[Manual Watchlist Sync] Could not find Seerr user for {user.Username}"); - continue; - } - - // Get watchlist from Seerr - var watchlistItems = await GetJellyseerrWatchlistForUser(jellyseerrUserId); - if (watchlistItems == null || watchlistItems.Count == 0) - { - _logger.Info($"[Manual Watchlist Sync] No watchlist items found for {user.Username}"); - watchlistItems = new List(); - } - - _logger.Info($"[Manual Watchlist Sync] Found {watchlistItems.Count} watchlist items for {user.Username}"); - - var requestItems = await GetJellyseerrRequestsForUser(jellyseerrUserId); - if (requestItems != null && requestItems.Count > 0) - { - _logger.Info($"[Manual Watchlist Sync] Found {requestItems.Count} request items for {user.Username}"); - watchlistItems.AddRange(requestItems); - } - - var processedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Process each watchlist item - foreach (var item in watchlistItems) - { - itemsProcessed++; - - var key = $"{item.MediaType}:{item.TmdbId}"; - if (!processedKeys.Add(key)) - { - continue; - } + return BadRequest(new { error = "Jellyseerr URL or API key is not configured" }); + } - // Find the item in Jellyfin library by TMDB ID - var libraryItem = FindItemByTmdbId(item.TmdbId, item.MediaType); - if (libraryItem != null) - { - var userData = _userDataManager.GetUserData(user, libraryItem); - if (userData == null) - { - _logger.Warning($"[Manual Watchlist Sync] User data was null for '{libraryItem.Name}' and user {user.Username}; skipping."); - } - else if (userData.Likes != true) - { - userData.Likes = true; - _userDataManager.SaveUserData(user, libraryItem, userData, UserDataSaveReason.UpdateUserRating, default); - itemsAdded++; - _logger.Info($"[Manual Watchlist Sync] Added '{libraryItem.Name}' to watchlist for {user.Username}"); - } - } - else - { - // Item not in library yet - WatchlistMonitor will automatically add it when it arrives - _logger.Debug($"[Manual Watchlist Sync] Item TMDB {item.TmdbId} ({item.MediaType}) not in library yet for {user.Username} - will be auto-added by WatchlistMonitor when available"); - } - } - } - catch (Exception ex) - { - _logger.Error($"[Manual Watchlist Sync] Error processing user {user.Username}: {ex.Message}"); - errors.Add("Failed to sync watchlist for a user."); - } + if (summary.SeerrUnreachable) + { + return StatusCode(502, new { error = "Could not load the user list from any configured Jellyseerr URL." }); } - _logger.Info($"[Manual Watchlist Sync] Sync complete. Processed: {itemsProcessed}, Added: {itemsAdded}"); + _logger.Info($"[Manual Watchlist Sync] Sync complete. Processed: {summary.ItemsProcessed}, Added: {summary.ItemsAdded}"); return Ok(new { success = true, - itemsProcessed, - itemsAdded, - errors = errors.Count > 0 ? errors : null + itemsProcessed = summary.ItemsProcessed, + itemsAdded = summary.ItemsAdded, + errors = summary.FailedUsers > 0 + ? Enumerable.Repeat("Failed to sync watchlist for a user.", summary.FailedUsers).ToList() + : null }); } catch (Exception ex) @@ -2997,72 +2935,6 @@ public async Task ImportJellyseerrUsers() } } - private async Task?> GetJellyseerrWatchlistForUser(string userId) - { - try - { - var config = JellyfinEnhanced.Instance?.Configuration; - if (config == null || string.IsNullOrEmpty(config.JellyseerrUrls) || string.IsNullOrEmpty(config.JellyseerrApiKey)) - { - return null; - } - - var urls = config.JellyseerrUrls.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - var httpClient = Helpers.Jellyseerr.SeerrHttpHelper.CreateClient(_httpClientFactory); - - foreach (var url in urls) - { - var trimmedUrl = url.Trim(); - try - { - var requestUri = $"{trimmedUrl.TrimEnd('/')}/api/v1/user/{userId}/watchlist"; - using var request = Helpers.Jellyseerr.SeerrHttpHelper.BuildRequest( - HttpMethod.Get, requestUri, config.JellyseerrApiKey); - using var response = await httpClient.SendAsync(request); - var (content, error) = await Helpers.Jellyseerr.SeerrHttpHelper.ReadResponseAsync(response, requestUri); - - if (error == null && content != null) - { - var json = JsonDocument.Parse(content); - - if (json.RootElement.TryGetProperty("results", out var results)) - { - var items = new List(); - foreach (var item in results.EnumerateArray()) - { - if (item.TryGetProperty("tmdbId", out var tmdbId) && - item.TryGetProperty("mediaType", out var mediaType)) - { - items.Add(new WatchlistItem - { - TmdbId = tmdbId.GetInt32(), - MediaType = mediaType.GetString() ?? "movie" - }); - } - } - return items; - } - } - else if (error != null) - { - _logger.Warning($"Failed to get watchlist from {trimmedUrl}: code={error.Code} status={error.HttpStatus} cf-ray={error.CfRay} — {error.Message}"); - } - } - catch (Exception ex) - { - _logger.Warning($"Failed to get watchlist from {trimmedUrl}: {ex.Message}"); - continue; - } - } - } - catch (Exception ex) - { - _logger.Error($"Error getting Seerr watchlist: {ex}"); - } - - return null; - } - private async Task?> GetJellyseerrRequestsForUser(string userId) { try @@ -3202,25 +3074,6 @@ private bool BelongsToUser(JsonElement requestElement, string jellyseerrUserId) }; } - private BaseItem? FindItemByTmdbId(int tmdbId, string mediaType) - { - var query = new InternalItemsQuery - { - HasTmdbId = true, - IncludeItemTypes = mediaType == "tv" ? new[] { Jellyfin.Data.Enums.BaseItemKind.Series } : new[] { Jellyfin.Data.Enums.BaseItemKind.Movie } - }; - - var items = _libraryManager.GetItemList(query); - return items.FirstOrDefault(i => - { - if (i.ProviderIds != null && i.ProviderIds.TryGetValue("Tmdb", out var tmdbIdStr)) - { - return tmdbIdStr == tmdbId.ToString(); - } - return false; - }); - } - private class WatchlistItem { public int TmdbId { get; set; } @@ -4660,6 +4513,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); @@ -7858,8 +7714,10 @@ public IActionResult GetTagCache(Guid userId, [FromQuery] long? since = null) // stripping for them. Strict-read so corruption is observable (rate-limited // warn) rather than silently passing through. Resolved BEFORE the cache // read so a delta request can include the guarded entries (below). + // Loaded for the resolved user, not the route value: an empty route id + // resolves to the caller, whose own guard must still apply. var spPolicy = Services.SpoilerTagDataStripper.IsConfigured(spCfg) - ? Services.SpoilerTagDataStripper.CreatePolicy(spCfg, LoadSpoilerStateForTagStrip(userId)) + ? Services.SpoilerTagDataStripper.CreatePolicy(spCfg, LoadSpoilerStateForTagStrip(user.Id)) : null; // A delta (?since=) only carries entries the library changed, but a @@ -8119,7 +7977,8 @@ List SelectTagDataMediaSources( Services.SpoilerTagStripPolicy? spoilerPolicy = null; if (Services.SpoilerTagDataStripper.IsConfigured(spoilerCfg)) { - spoilerPolicy = Services.SpoilerTagDataStripper.CreatePolicy(spoilerCfg, LoadSpoilerStateForTagStrip(userId)); + // The resolved user's state (see GetTagCache): an empty route id is the caller. + spoilerPolicy = Services.SpoilerTagDataStripper.CreatePolicy(spoilerCfg, LoadSpoilerStateForTagStrip(user.Id)); } var stripTagsEnabled = spoilerPolicy != null; var spStripGenres = spoilerPolicy?.StripGenres == true; @@ -10473,16 +10332,16 @@ public async Task GetRequests([FromQuery] int take = 20, [FromQue var today = DateTime.UtcNow.Date; // For TV shows, use nextAirDate - if (r.type == "tv" && !string.IsNullOrEmpty(r.nextAirDate) && DateTime.TryParse(r.nextAirDate, out var airDate) && airDate.Date > today) + if (r.type == "tv" && !string.IsNullOrEmpty(r.nextAirDate) && TryParseIso(r.nextAirDate) is DateTime airDate && airDate.Date > today) { bestDate = airDate; } else { // For movies, use digital or theatrical date - if (!string.IsNullOrEmpty(r.digitalReleaseDate) && DateTime.TryParse(r.digitalReleaseDate, out var dd) && dd.Date > today) + if (!string.IsNullOrEmpty(r.digitalReleaseDate) && TryParseIso(r.digitalReleaseDate) is DateTime dd && dd.Date > today) bestDate = dd; - if (!string.IsNullOrEmpty(r.theatricalReleaseDate) && DateTime.TryParse(r.theatricalReleaseDate, out var td) && td.Date > today) + if (!string.IsNullOrEmpty(r.theatricalReleaseDate) && TryParseIso(r.theatricalReleaseDate) is DateTime td && td.Date > today) { if (bestDate == null || td < bestDate) bestDate = td; @@ -10573,14 +10432,15 @@ private async Task IsMissingFromArrAsync(string? type, int? tmdbId, int? t private static bool IsOrphanedRequest(string? mediaStatus, string? createdAt) { if (mediaStatus != "Approved" && mediaStatus != "Processing") return false; - return DateTimeOffset.TryParse(createdAt, out var created) && created < DateTimeOffset.UtcNow.AddMinutes(-10); + return DateTimeOffset.TryParse(createdAt, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var created) + && created < DateTimeOffset.UtcNow.AddMinutes(-10); } // Coming soon: approved or processing with a future release date. Processing lists the rest. private static bool IsComingSoonRequest(string? status, string? type, string? nextAirDate, string? digitalReleaseDate, string? theatricalReleaseDate) { var today = DateTime.UtcNow.Date; - static bool IsFuture(string? value, DateTime day) => !string.IsNullOrEmpty(value) && DateTime.TryParse(value, out var date) && date.Date > day; + static bool IsFuture(string? value, DateTime day) => TryParseIso(value) is DateTime date && date.Date > day; var normalized = (status ?? "").ToLower(); if (type == "tv") @@ -10660,7 +10520,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(); } @@ -10668,7 +10531,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(); } @@ -10898,7 +10762,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. @@ -11163,8 +11027,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/Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs b/Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs index 83a6487e2..48fd09de3 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs @@ -123,6 +123,9 @@ public Task OnEvent(PlaybackStartEventArgs eventArgs) if (changed > 0) { + // The response filter caches each user's hidden content for 30s; drop it so the + // resumed item is back in Continue Watching on the next request, as after any other write. + HiddenContentResponseFilter.InvalidateUser(userId.ToString("N")); _logger.Info($"CW: dropped/demoted {changed} hidden-content entr{(changed == 1 ? "y" : "ies")} for user {userId} on resume of item {item.Id}"); // Attribute to RCW specifically (not HC) so this counter answers // "did Remove Continue Watching actually remove something for a real diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs b/Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs index aefd9748f..0e6957aba 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs @@ -47,6 +47,8 @@ public static class ArrUrlGuard if (IPAddress.TryParse(host, out var literalIp)) { + // Only the container host aliases may reach 169.254.0.0/16; a literal link-local + // URL (e.g. http://169.254.1.2/) is blocked like any other metadata-style address. return !IsBlockedIp(literalIp, allowLinkLocal: false); } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj b/Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj index 929411ea8..d3978747d 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 @@ - + - + + @@ -98,4 +101,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..922308808 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs @@ -64,6 +64,28 @@ public IEnumerable GetDefaultTriggers() } public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + await RunAsync(progress, cancellationToken).ConfigureAwait(false); + } + + /// + /// Totals of one sync pass, reported by the manual sync route. SeerrUnreachable + /// is set when no configured Jellyseerr URL returned its user list, so nothing ran. + /// + internal sealed record SyncSummary(int ItemsProcessed, int ItemsAdded, int FailedUsers, bool SeerrUnreachable = false); + + /// + /// One sync pass over every user, honouring the same settings whoever starts it: + /// JellyseerrImportBlockedUsers, PreventWatchlistReAddition and + /// AddRequestedMediaToWatchlist. The scheduled run and the admin + /// POST jellyseerr/sync-watchlist route both use it, so a manual sync cannot + /// bypass them. + /// + /// + /// The pass totals, or null when sync is disabled or Seerr is not configured. When no + /// configured URL returns the Seerr user list, the totals are zero and SeerrUnreachable is set. + /// + internal async Task RunAsync(IProgress? progress, CancellationToken cancellationToken) { var config = JellyfinEnhanced.Instance?.Configuration; @@ -71,35 +93,55 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can { _logger.Info("[Seerr→Jellyfin Watchlist Sync] Sync is disabled in plugin configuration."); progress?.Report(100); - return; + return null; } if (string.IsNullOrEmpty(config.JellyseerrUrls) || string.IsNullOrEmpty(config.JellyseerrApiKey)) { _logger.Warning("[Seerr→Jellyfin Watchlist Sync] Jellyseerr URL or API key not configured."); progress?.Report(100); - return; + return null; } _logger.Info("[Seerr→Jellyfin Watchlist Sync] Starting Jellyseerr watchlist sync task..."); progress?.Report(0); - var urls = config.JellyseerrUrls.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - var jellyseerrUrl = urls.FirstOrDefault()?.Trim(); + var urls = config.JellyseerrUrls.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(u => u.Trim()) + .Where(u => u.Length > 0) + .ToList(); - if (string.IsNullOrEmpty(jellyseerrUrl)) + if (urls.Count == 0) { _logger.Warning("[Seerr→Jellyfin Watchlist Sync] No valid Jellyseerr URL found."); progress?.Report(100); - return; + return null; } var httpClient = Helpers.Jellyseerr.SeerrHttpHelper.CreateClient(_httpClientFactory); - var jellyseerrUserMap = await GetJellyseerrUserMap(httpClient, jellyseerrUrl, config.JellyseerrApiKey); - if (jellyseerrUserMap.Count == 0) + // Use the first configured URL that answers, as the Seerr proxy and the + // auto-request services do (WatchlistMonitor and the Jellyfin→Seerr sync + // task use only the first URL): the first one whose user list loads + // serves the whole pass. + string? jellyseerrUrl = null; + Dictionary? jellyseerrUserMap = null; + foreach (var url in urls) + { + cancellationToken.ThrowIfCancellationRequested(); + jellyseerrUserMap = await GetJellyseerrUserMap(httpClient, url, config.JellyseerrApiKey); + if (jellyseerrUserMap != null) + { + jellyseerrUrl = url; + break; + } + } + + if (jellyseerrUrl == null || jellyseerrUserMap == null) { - _logger.Warning("[Seerr→Jellyfin Watchlist Sync] Unable to build Jellyseerr user map."); + _logger.Warning($"[Seerr→Jellyfin Watchlist Sync] Unable to load the Jellyseerr user list from any of {urls.Count} configured URL(s); nothing synced."); + progress?.Report(100); + return new SyncSummary(0, 0, 0, SeerrUnreachable: true); } // Get all Jellyfin users, then filter out the JellyseerrImportBlockedUsers @@ -120,6 +162,8 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can var totalUsers = jellyfinUsers.Count; var processedUsers = 0; var totalItemsAdded = 0; + var totalItemsProcessed = 0; + var failedUsers = 0; foreach (var jellyfinUser in jellyfinUsers) { @@ -183,6 +227,7 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can foreach (var item in combinedItems) { cancellationToken.ThrowIfCancellationRequested(); + totalItemsProcessed++; var key = $"{item.MediaType}:{item.TmdbId}"; if (!processedKeys.Add(key)) @@ -230,9 +275,14 @@ 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}"); + failedUsers++; } processedUsers++; @@ -244,6 +294,7 @@ public async Task ExecuteAsync(IProgress progress, CancellationToken can _logger.Info($"================================================================================================================================="); _logger.Info($"[Seerr→Jellyfin Watchlist Sync] Completed. Added {totalItemsAdded} total items across {processedUsers} users"); progress?.Report(100); + return new SyncSummary(totalItemsProcessed, totalItemsAdded, failedUsers); } private static string NormalizeUserId(string? userId) @@ -251,7 +302,9 @@ private static string NormalizeUserId(string? userId) return string.IsNullOrEmpty(userId) ? string.Empty : userId.Replace("-", string.Empty); } - private async Task> GetJellyseerrUserMap(HttpClient httpClient, string jellyseerrUrl, string apiKey) + // Null when this URL did not return a complete user list (error status, a body + // without results, or an exception), so the caller can try the next URL. + private async Task?> GetJellyseerrUserMap(HttpClient httpClient, string jellyseerrUrl, string apiKey) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -273,15 +326,16 @@ private async Task> GetJellyseerrUserMap(HttpClient h if (error != null) { - _logger.Warning($"[Seerr→Jellyfin Watchlist Sync] Failed to get users from Jellyseerr: code={error.Code} status={error.HttpStatus} cf-ray={error.CfRay} — {error.Message}"); - return result; + _logger.Warning($"[Seerr→Jellyfin Watchlist Sync] Failed to get users from Jellyseerr at {jellyseerrUrl}: code={error.Code} status={error.HttpStatus} cf-ray={error.CfRay} — {error.Message}"); + return null; } var usersResponse = JsonSerializer.Deserialize(content!); if (!usersResponse.TryGetProperty("results", out var usersArray)) { - return result; + _logger.Warning($"[Seerr→Jellyfin Watchlist Sync] User list from {jellyseerrUrl} has no results array."); + return null; } int pageCount = 0; @@ -320,7 +374,8 @@ private async Task> GetJellyseerrUserMap(HttpClient h } catch (Exception ex) { - _logger.Error($"[Seerr→Jellyfin Watchlist Sync] Error getting Jellyseerr user map: {ex.Message}"); + _logger.Error($"[Seerr→Jellyfin Watchlist Sync] Error getting Jellyseerr user map from {jellyseerrUrl}: {ex.Message}"); + return null; } return result; @@ -593,18 +648,7 @@ private Task ProcessWatchlistItem(JUser user, WatchlistItem // Mark as processed if prevention is enabled and not already marked if (config?.PreventWatchlistReAddition == true) { - var processedItems = _userConfigurationManager.GetProcessedWatchlistItems(user.Id); - if (!processedItems.Items.Any(p => p.TmdbId == watchlistItem.TmdbId && p.MediaType == watchlistItem.MediaType)) - { - processedItems.Items.Add(new ProcessedWatchlistItem - { - TmdbId = watchlistItem.TmdbId, - MediaType = watchlistItem.MediaType, - ProcessedAt = System.DateTime.UtcNow, - Source = "existing" - }); - _userConfigurationManager.SaveProcessedWatchlistItems(user.Id, processedItems); - } + _userConfigurationManager.MarkWatchlistItemProcessed(user.Id, watchlistItem.TmdbId, watchlistItem.MediaType, "existing"); } return Task.FromResult(WatchlistItemResult.AlreadyInWatchlist); @@ -617,15 +661,7 @@ private Task ProcessWatchlistItem(JUser user, WatchlistItem // Mark as processed if prevention is enabled if (config?.PreventWatchlistReAddition == true) { - var processedItems = _userConfigurationManager.GetProcessedWatchlistItems(user.Id); - processedItems.Items.Add(new ProcessedWatchlistItem - { - TmdbId = watchlistItem.TmdbId, - MediaType = watchlistItem.MediaType, - ProcessedAt = System.DateTime.UtcNow, - Source = "sync" - }); - _userConfigurationManager.SaveProcessedWatchlistItems(user.Id, processedItems); + _userConfigurationManager.MarkWatchlistItemProcessed(user.Id, watchlistItem.TmdbId, watchlistItem.MediaType, "sync"); } _logger.Info($"[Seerr→Jellyfin Watchlist Sync] ✓ Added to watchlist: {item.Name} for user {user.Username}"); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs index 55cef8dd3..7a80835cf 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs @@ -323,11 +323,16 @@ private class QualityProfileSettings if (config.AutoMovieRequestCheckReleaseDate && nextPart.TryGetProperty("releaseDate", out var releaseDateProp)) { var releaseDateStr = releaseDateProp.GetString(); - if (!string.IsNullOrEmpty(releaseDateStr) && DateTime.TryParse(releaseDateStr, out var releaseDate)) + // Seerr sends ISO dates ("2001-01-01"); a culture-sensitive parse reads the year + // in the server's calendar (Persian, Thai Buddhist) and misjudges the release. + // A date-only value parses as the server's local midnight, so a title counts as + // released from the start of its release day in the server's time zone. + if (!string.IsNullOrEmpty(releaseDateStr) && DateTime.TryParse(releaseDateStr, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var releaseDate)) { if (releaseDate > DateTime.Now) { - _logger.Debug($"[Auto-Movie-Request] Next movie is not yet released (release date: {releaseDate:yyyy-MM-dd}), skipping"); + _logger.Debug($"[Auto-Movie-Request] Next movie is not yet released (release date: {releaseDateStr}), skipping"); return null; } } 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/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs index 5b6bbefc6..50a6f147e 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs @@ -60,9 +60,44 @@ 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; + + // A restore that keeps failing leaves users locked out, so the schedule tick keeps retrying it, + // but with backoff: the first retry runs on the next tick, then the wait doubles from 30 s up + // to an hour. Tracked in memory only; a restart retries at once. Each failure streak is logged + // at Error once, later attempts at Warning (per-user detail at Debug). + private static readonly TimeSpan RestoreRetryBaseDelay = TimeSpan.FromSeconds(30); + private static readonly TimeSpan RestoreRetryMaxDelay = TimeSpan.FromHours(1); + private readonly TimeProvider _timeProvider; + // Consecutive restore attempts that left a user unrestored; changed under _gate. + private int _restoreFailureStreak; + // UTC ticks before which the schedule tick skips the pending-restore retry; 0 = retry now. + private long _restoreRetryNotBeforeTicks; + + // Set when a fail-open checkpoint (or the enable journal) could not be written, so the file is + // behind the in-memory state. A stale active journal left there would act again after a + // restart, so the schedule tick keeps rewriting the in-memory state, with the same backoff as + // restores, until a save lands. Every successful save clears it. Changed under _gate. + private volatile bool _journalDirty; + // Consecutive tick rewrites of a dirty journal that failed; changed under _gate. + private int _journalFailureStreak; + // UTC ticks before which the schedule tick skips the dirty-journal rewrite; 0 = retry now. + private long _journalRetryNotBeforeTicks; public MaintenanceModeService(IUserManager userManager, ISessionManager sessionManager, IApplicationPaths appPaths, Logger logger) + : this(userManager, sessionManager, appPaths, logger, TimeProvider.System) { + } + + internal MaintenanceModeService(IUserManager userManager, ISessionManager sessionManager, IApplicationPaths appPaths, Logger logger, TimeProvider timeProvider) + { + _timeProvider = timeProvider; _userManager = userManager; _sessionManager = sessionManager; _logger = logger; @@ -85,16 +120,23 @@ 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())) + if (_journalDirty) await RetryDirtyJournalAsync().ConfigureAwait(false); + var current = LoadState(); + // A window that ran out always ends; a pending restore waits out its retry backoff. + var retryDue = _timeProvider.GetUtcNow().UtcTicks >= Interlocked.Read(ref _restoreRetryNotBeforeTicks); + if (IsExpired(current) || (!current.IsActive && HasPendingRestores(current) && retryDue)) { // 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 +189,14 @@ 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, failOpen: false).ConfigureAwait(false); + LogCarriedRestores(currentState); + } if (currentState.IsActive) { bool sameAction = currentState.Action == action; @@ -194,7 +244,13 @@ 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."); - await RestoreUsersAsync(currentState).ConfigureAwait(false); + // 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, failOpen: false).ConfigureAwait(false); + LogCarriedRestores(currentState); } bool doAccounts = action == "disable_accounts" || action == "both"; @@ -219,44 +275,61 @@ 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 + // transaction, but an existing filesystem failure is detectable. + SaveState(currentState); 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; - if (doAccounts && !dto.Policy.IsDisabled) { dto.Policy.IsDisabled = true; - accountDisabled.Add(user.Id.ToString()); - changed = true; + accountChanged = true; } if (doRemote && dto.Policy.EnableRemoteAccess) { dto.Policy.EnableRemoteAccess = false; - remoteDisabled.Add(user.Id.ToString()); - changed = true; + remoteChanged = true; } - if (changed) + if (accountChanged || remoteChanged) { await _userManager.UpdatePolicyAsync(user.Id, dto.Policy).ConfigureAwait(false); - _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 @@ -314,16 +387,20 @@ private async Task DisableCoreAsync(Func shouldDisable, var state = LoadState(); if (!state.IsActive) { + if (HasPendingRestores(state)) + await RestoreUsersAsync(state, failOpen: true).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. + // 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; + 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 @@ -333,7 +410,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 @@ -481,40 +558,152 @@ 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) .Distinct() .ToList(); + var failed = 0; var accountSet = new HashSet(state.AccountDisabledUserIds); var remoteSet = new HashSet(state.RemoteDisabledUserIds); 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); + 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) continue; + 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) + { + _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) + { + failed++; + var message = $"[Maintenance] Failed to restore user {idStr}: {ex.Message}"; + if (_restoreFailureStreak == 0) _logger.Error(message); + else _logger.Debug(message); + continue; + } + // On the enable path persistence failures abort the transition rather + // than being swallowed as policy failures. + state.AccountDisabledUserIds.Remove(idStr); + state.RemoteDisabledUserIds.Remove(idStr); + Checkpoint(state, failOpen); + } - var dto = _userManager.GetUserDto(user, string.Empty); - if (dto.Policy == null) continue; + RecordRestoreOutcome(failed); + } + + /// + /// Updates the restore failure streak and the next automatic retry time after a restore pass. + /// A clean pass resets both; a failing one doubles the wait (none for the first retry, then + /// 30 s up to an hour) and, after the first failure of the streak, logs one Warning. + /// + /// Users this pass could not restore. + private void RecordRestoreOutcome(int failed) + { + if (failed == 0) + { + _restoreFailureStreak = 0; + Interlocked.Exchange(ref _restoreRetryNotBeforeTicks, 0); + return; + } - if (accountSet.Contains(idStr)) dto.Policy.IsDisabled = false; - if (remoteSet.Contains(idStr)) dto.Policy.EnableRemoteAccess = true; + _restoreFailureStreak++; + var delay = TimeSpan.Zero; + if (_restoreFailureStreak > 1) + { + delay = RetryDelay(_restoreFailureStreak - 2); + _logger.Warning($"[Maintenance] {failed} user(s) still could not be restored (attempt {_restoreFailureStreak}); " + + $"retrying in {delay.TotalMinutes.ToString("0.#", CultureInfo.InvariantCulture)} min."); + } + + Interlocked.Exchange(ref _restoreRetryNotBeforeTicks, (_timeProvider.GetUtcNow() + delay).UtcTicks); + } - await _userManager.UpdatePolicyAsync(userId, dto.Policy).ConfigureAwait(false); - _logger.Info($"[Maintenance] Restored user '{user.Username}'"); + /// The retry wait after doublings of 30 s, capped at an hour. + private static TimeSpan RetryDelay(int doublings) + => TimeSpan.FromTicks(Math.Min(RestoreRetryBaseDelay.Ticks << Math.Min(doublings, 20), RestoreRetryMaxDelay.Ticks)); + + /// + /// Rewrites the in-memory state after a fail-open checkpoint could not save it, so a stale + /// journal on disk cannot re-disable or re-restore users after a restart. The first rewrite + /// runs on the next tick, then the wait doubles from 30 s up to an hour. The original write + /// failure was already logged at Error, so each failed rewrite logs one Warning. + /// + private async Task RetryDirtyJournalAsync() + { + if (_timeProvider.GetUtcNow().UtcTicks < Interlocked.Read(ref _journalRetryNotBeforeTicks)) return; + await _gate.WaitAsync().ConfigureAwait(false); + try + { + var cached = _cached; + if (!_journalDirty || cached == null) return; + try + { + WriteState(cached); } catch (Exception ex) { - _logger.Error($"[Maintenance] Failed to restore user {idStr}: {ex.Message}"); + _journalFailureStreak++; + var delay = RetryDelay(_journalFailureStreak - 1); + Interlocked.Exchange(ref _journalRetryNotBeforeTicks, (_timeProvider.GetUtcNow() + delay).UtcTicks); + _logger.Warning($"[Maintenance] The maintenance state still could not be saved (attempt {_journalFailureStreak}): {ex.Message}; " + + $"retrying in {delay.TotalMinutes.ToString("0.#", CultureInfo.InvariantCulture)} min."); + return; } + _logger.Info("[Maintenance] Saved the maintenance state that a failed write had left only in memory."); + } + finally + { + _gate.Release(); } } + /// + /// 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) { @@ -526,34 +715,179 @@ private static bool AffectedUserSetsEqual(List? a, List? b) private MaintenanceState LoadState() { var cached = _cached; - if (cached != null) return cached; - try + if (cached != null) return CopyState(cached); + // 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(); + } + } + _stateLoadError = null; + _reportedLoadFailure = null; + cached ??= new MaintenanceState(); + cached.AccountDisabledUserIds = (cached.AccountDisabledUserIds ?? new()).Distinct().ToList(); + cached.RemoteDisabledUserIds = (cached.RemoteDisabledUserIds ?? new()).Distinct().ToList(); + _cached = cached; + 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 load state: {ex.Message}"); + _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() + { + 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 + }; + + /// + /// Saves a restore-path checkpoint. A write failure is already logged by . + /// The restore it records has happened either way, so the in-memory state takes the change and + /// the schedule tick rewrites the file (a stale entry would restore that user again later, even + /// after an admin disabled them by hand). With restoration carries + /// on; otherwise the failure propagates and aborts the enable-path transition. + /// + private void Checkpoint(MaintenanceState state, bool failOpen) + { + try + { + SaveState(state); + } + catch (Exception) + { + _cached = CopyState(state); + _journalDirty = true; + if (!failOpen) throw; + } + } + + /// + /// 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); + _journalDirty = true; + throw; } - cached ??= new MaintenanceState(); - _cached = cached; - return cached; } private void SaveState(MaintenanceState state) { - _cached = state; try { - File.WriteAllText(_stateFilePath, JsonConvert.SerializeObject(state, Formatting.Indented)); + WriteState(state); } catch (Exception ex) { _logger.Error($"[Maintenance] Failed to save state: {ex.Message}"); + throw; + } + } + + /// + /// Atomically replaces the state file and the cache with , and ends any + /// dirty-journal retry. Throws without logging; logs for its callers. + /// + private void WriteState(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 + { + File.WriteAllText(temporaryPath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.Move(temporaryPath, _stateFilePath, overwrite: true); + _cached = CopyState(state); + _journalDirty = false; + _journalFailureStreak = 0; + Interlocked.Exchange(ref _journalRetryNotBeforeTicks, 0); + } + 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/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/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs index 04dff8d51..835d8131c 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs @@ -118,16 +118,21 @@ 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). + /// then the language-independent ones (heploc -> alalc97, lojban dropped, aaland fills an empty + /// region with AX). A script variant (baku1926, colb1945, luna1918, petr1708) becomes that script + /// only when no script or region is present after alias expansion and it sorts first among the + /// remaining variants; ICU then drops the whole variant sequence. Otherwise it stays a variant: + /// "az-baku1926-fonipa" is "Azerbaijani (Baku)", but "az-baku1926-1994" is + /// "Azerbaijani (1994_BAKU1926)" and "az-baku1926-heploc" is "Azerbaijani (ALALC97_BAKU1926)". /// 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 result = new List(variants.Count); foreach (var variant in variants) { - if (VariantAliases.TryGetValue(originalLanguage + "-" + variant, out var pairAlias)) + // Display names never apply a language+variant alias to a script variant (sh-petr1708 stays + // "Serbian (Latin, PETR1708)"); those pairs only exist in getCanonicalLocales. + if (!IsScriptVariant(variant) && 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)) @@ -141,14 +146,15 @@ internal static class LanguageNames if (aliasVariants is not null) result.AddRange(aliasVariants); } else if (!GenericVariantAliases.TryGetValue(variant, out var replacement) - || replacement.StartsWith("u-va-", StringComparison.Ordinal)) + || replacement.StartsWith("u-va-", StringComparison.Ordinal) + || IsScriptVariant(variant)) { - result.Add(variant); // posix stays a variant, named "Computer" + result.Add(variant); // posix stays a variant, named "Computer"; a script variant is decided below } - else if (replacement.Length == 4 && char.IsAsciiLetterUpper(replacement[0])) + else if (IsRegionSubtag(replacement)) { - if (soleSubtag) script = replacement; - else result.Add(variant); + // A region alias fills an empty region and is otherwise dropped (en-US-aaland -> en-US). + region ??= replacement; } else if (replacement.Length > 0) { @@ -156,9 +162,35 @@ internal static class LanguageNames } } + if (script is null && region is null && result.Count > 0) + { + var first = result[0]; + foreach (var variant in result) + { + if (string.CompareOrdinal(variant, first) < 0) first = variant; + } + + if (IsScriptVariant(first)) + { + script = GenericVariantAliases[first]; + return null; + } + } + return result.Count == 0 ? null : result; } + /// True for a variant whose alias is a script (baku1926 -> Baku, petr1708 -> Petr). + private static bool IsScriptVariant(string variant) + => GenericVariantAliases.TryGetValue(variant, out var replacement) + && replacement.Length == 4 && char.IsAsciiLetterUpper(replacement[0]) + && char.IsAsciiLetterLower(replacement[1]) && char.IsAsciiLetterLower(replacement[2]) && char.IsAsciiLetterLower(replacement[3]); + + /// 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 2fc332d62..031f1606a 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. // @@ -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/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 8e0516937..544a6df09 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs +++ b/Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs @@ -627,6 +627,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") @@ -664,6 +665,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/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/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 fabcaf28f..3739121d2 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"); } @@ -227,32 +234,13 @@ private async Task ProcessItemForWatchlist(ItemChangeEventArgs e, string eventTy // Mark as processed if prevention is enabled if (config.PreventWatchlistReAddition) { - var processedItems = _userConfigurationManager.GetProcessedWatchlistItems(user.Id); - processedItems.Items.Add(new ProcessedWatchlistItem - { - TmdbId = tmdbId, - MediaType = mediaType, - ProcessedAt = System.DateTime.UtcNow, - Source = "monitor" - }); - _userConfigurationManager.SaveProcessedWatchlistItems(user.Id, processedItems); + _userConfigurationManager.MarkWatchlistItemProcessed(user.Id, tmdbId, mediaType, "monitor"); } } else if (userData != null && userData.Likes == true && config.PreventWatchlistReAddition) { // Item is already in watchlist, mark as processed if not already marked - var processedItems = _userConfigurationManager.GetProcessedWatchlistItems(user.Id); - if (!processedItems.Items.Any(p => p.TmdbId == tmdbId && p.MediaType == mediaType)) - { - processedItems.Items.Add(new ProcessedWatchlistItem - { - TmdbId = tmdbId, - MediaType = mediaType, - ProcessedAt = System.DateTime.UtcNow, - Source = "existing" - }); - _userConfigurationManager.SaveProcessedWatchlistItems(user.Id, processedItems); - } + _userConfigurationManager.MarkWatchlistItemProcessed(user.Id, tmdbId, mediaType, "existing"); } } @@ -406,8 +394,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/arr/calendar/calendar-page-data.js b/Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js index f7eee16a1..a4ec331d5 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js @@ -105,10 +105,11 @@ _customTabContainer: null, }; - // Watched state, favorites and requested-item highlighting are per-user — - // clear them on a user switch so the calendar re-derives everything for - // the new user on its next render. + // Events (filtered server-side by library access), watched state, favorites + // and requested-item highlighting are per-user — clear them on a user switch + // so the calendar re-derives everything for the new user on its next render. JE.session?.onUserChange('calendar-page', () => { + state.events = []; state.userDataMap.clear(); state.requestedItems.clear(); state.requestedLoaded = false; @@ -158,12 +159,17 @@ * Fetch calendar events from backend */ async function fetchCalendarEvents(startDate, endDate) { + // The backend filters events by the signed-in user's library access — a + // response (or failure) resolving after a user switch must not replace the + // new user's events or toast the previous user's instance errors. + const epoch = JE.session ? JE.session.getEpoch() : 0; try { const query = new URLSearchParams({ start: startDate.toISOString(), end: endDate.toISOString(), }); const data = await JE.core.api.plugin(`/arr/calendar?${query.toString()}`); + if (JE.session && !JE.session.isCurrent(epoch)) return null; state.events = (data.events || []).filter((evt) => evt && evt.releaseDate); // Surface per-instance errors from the backend envelope so a misconfigured or // unreachable arr instance doesn't silently leave the calendar looking fine. @@ -171,7 +177,7 @@ return data; } catch (error) { console.error(`${logPrefix} Failed to fetch calendar events:`, error); - state.events = []; + if (!JE.session || JE.session.isCurrent(epoch)) state.events = []; return null; } } 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..71922d282 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(); @@ -407,6 +415,11 @@ if (!spec.cache || !state.localStorageEnabled) return; const CACHE_KEY = spec.cache.key; const TIMESTAMP_KEY = `${CACHE_KEY}Timestamp`; + // The current key can itself start with a legacy stem (language + // tags: "…languageTagsCache-v2" vs the old "…languageTagsCache"), + // so every current-generation key — including the owner sentinel, + // whose loss would wipe the cache on every reload — is kept. + const currentKeys = [CACHE_KEY, TIMESTAMP_KEY, `${CACHE_KEY}:identity-owner`]; // A renderer can accumulate several generations of dead keys // (un-namespaced, then namespaced-v1, …), so accept one stem or a // list of them — replacing the stem would orphan the older one. @@ -419,7 +432,7 @@ const key = localStorage.key(i); if (key && legacyStems.some(legacy => key.startsWith(`${legacy}-`) || key === legacy || key === `${legacy}Timestamp`) && - key !== CACHE_KEY && key !== TIMESTAMP_KEY) { + !currentKeys.includes(key)) { stale.push(key); } } diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js index 66d21eac9..f1403fd22 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js @@ -307,7 +307,19 @@ // Delete button handler deleteBtn.addEventListener('click', async () => { const bookmarkId = deleteBtn.dataset.bookmarkId; - await JE.bookmarks.delete(bookmarkId); + deleteBtn.disabled = true; + let deleted; + try { + deleted = await JE.bookmarks.delete(bookmarkId); + } finally { + deleteBtn.disabled = false; + } + // null: already gone (e.g. a second click), so neither failure nor success. + if (deleted === null) return; + if (!deleted) { + JE.toast(JE.t('toast_bookmark_delete_failed'), 3000); + return; + } JE.toast(JE.t('toast_bookmark_deleted'), 2000); // Re-render diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js index 85294bf11..24ee67401 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js @@ -258,8 +258,8 @@ if (label) label.innerHTML = 'refresh'; try { - JE.userConfig.bookmark.bookmarks = {}; - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); + // Queued behind the user's other bookmark mutations; restores them if the save fails. + if (!await JE.bookmarks.deleteAll()) return; JE.toast(JE.t('bookmark_deleted_all'), 3000); renderBookmarksLibrary(container); } catch (error) { diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js index 966a5e925..098111ad3 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js @@ -239,13 +239,9 @@ episodeNumber: fullItem.Type === 'Episode' ? (fullItem.IndexNumber ?? null) : null }; - // Delete old bookmarks BEFORE syncing to prevent race condition with re-render - for (const bm of oldGroup.bookmarks) { - delete JE.userConfig.bookmark.bookmarks[bm.id]; - } - - // Sync bookmarks to new item (no offset) - const synced = await JE.bookmarks.syncBookmarks(oldGroup.bookmarks, newDetails, 0); + // Move the bookmarks to the new item (no offset). The old records are removed + // in the same save, before the re-render it triggers, and come back if it fails. + const synced = await JE.bookmarks.syncBookmarks(oldGroup.bookmarks, newDetails, 0, { replaceOriginals: true }); JE.toast(JE.t('bookmark_migrated').replace('{count}', synced.length).replace('{name}', fullItem.Name), 4000); diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js index 5ac9092b1..d5fcbe029 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js @@ -21,6 +21,26 @@ const escapeHtml = JE.escapeHtml; + // A save serialises the whole bookmark map when it is called, but the POST waits + // behind earlier saves. If two mutations overlapped, the first one's rollback could + // undo the second's saved change or bring back a record the server no longer has. + // So each user's mutations run one at a time: mutate, save and, on failure, roll + // back before the next one touches memory. + const mutationQueues = new WeakMap(); + + /** + * Runs a bookmark mutation once every earlier mutation for the same owner has settled. + * @template T + * @param {object} owner - The `JE.userConfig` object the mutation writes to. + * @param {() => Promise} mutation - Mutates memory, saves and rolls back its own change on failure. + * @returns {Promise} The mutation's own result or rejection. + */ + function queueBookmarkMutation(owner, mutation) { + const run = (mutationQueues.get(owner) || Promise.resolve()).then(mutation); + mutationQueues.set(owner, run.catch(() => {})); + return run; + } + /** * New bookmark data structure: * { @@ -59,18 +79,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 +115,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 +170,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 +244,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,138 +256,235 @@ // 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; } - const bookmarkId = generateBookmarkId(); - const now = new Date().toISOString(); - - const bookmark = { - itemId: details.itemId || '', - tmdbId: details.tmdbId || '', - tvdbId: details.tvdbId || '', - mediaType: details.mediaType || '', - name: details.name || '', - timestamp: timestamp, - label: label || '', - createdAt: now, - updatedAt: now, - syncedFrom: '', - seasonNumber: details.seasonNumber ?? null, - episodeNumber: details.episodeNumber ?? null - }; + return queueBookmarkMutation(owner, async () => { + // An earlier queued mutation may have outlived this user's session. + if (!isCurrentOwner()) return null; + const bookmarkId = generateBookmarkId(); + const now = new Date().toISOString(); + + const bookmark = { + itemId: details.itemId || '', + tmdbId: details.tmdbId || '', + tvdbId: details.tvdbId || '', + mediaType: details.mediaType || '', + name: details.name || '', + timestamp: timestamp, + label: label || '', + createdAt: now, + updatedAt: now, + syncedFrom: '', + seasonNumber: details.seasonNumber ?? null, + episodeNumber: details.episodeNumber ?? null + }; - // Initialize bookmark structure if needed - if (!JE.userConfig.bookmark) { - JE.userConfig.bookmark = { bookmarks: {} }; - } - if (!JE.userConfig.bookmark.bookmarks) { - JE.userConfig.bookmark.bookmarks = {}; - } + // Initialize bookmark structure if needed + if (!owner.bookmark) { + owner.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); - console.log(`${logPrefix} Bookmark added:`, bookmarkId, bookmark); - emitBookmarksUpdated('add'); - return { id: bookmarkId, ...bookmark }; - } catch (e) { - console.error(`${logPrefix} Failed to save bookmark:`, e); - delete JE.userConfig.bookmark.bookmarks[bookmarkId]; - throw e; - } + try { + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); + console.log(`${logPrefix} Bookmark added:`, bookmarkId, bookmark); + if (isCurrentOwner()) emitBookmarksUpdated('add'); + return { id: bookmarkId, ...bookmark }; + } catch (e) { + console.error(`${logPrefix} Failed to save bookmark:`, e); + delete bookmarks[bookmarkId]; + throw e; + } + }); } /** * 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() }); + return queueBookmarkMutation(owner, async () => { + // Re-check after any earlier queued mutation: the user or the record may be gone. + if (!isCurrentOwner() || !owner.bookmark?.bookmarks?.[bookmarkId]) { + console.warn(`${logPrefix} Bookmark not found:`, bookmarkId); + return false; + } + 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); - console.log(`${logPrefix} Bookmark updated:`, bookmarkId); - emitBookmarksUpdated('update'); - return true; - } catch (e) { - console.error(`${logPrefix} Failed to update bookmark:`, e); - return false; - } + try { + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); + console.log(`${logPrefix} Bookmark updated:`, bookmarkId); + 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; + } + }); } /** - * Delete a bookmark + * Delete a bookmark. + * @param {string} bookmarkId - The bookmark record ID. + * @returns {Promise} true once the deletion is saved; false when the save + * failed and the record was restored; null when there was nothing to delete (already + * gone, e.g. a double-clicked delete, or the user changed). */ 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; + return null; } - delete JE.userConfig.bookmark.bookmarks[bookmarkId]; + return queueBookmarkMutation(owner, async () => { + // Re-check after any earlier queued mutation: the user or the record may be gone. + if (!isCurrentOwner() || !owner.bookmark?.bookmarks?.[bookmarkId]) { + console.warn(`${logPrefix} Bookmark not found:`, bookmarkId); + return null; + } + const bookmarks = owner.bookmark.bookmarks; + const original = bookmarks[bookmarkId]; + delete bookmarks[bookmarkId]; - try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); - console.log(`${logPrefix} Bookmark deleted:`, bookmarkId); - emitBookmarksUpdated('delete'); - return true; - } catch (e) { - console.error(`${logPrefix} Failed to delete bookmark:`, e); - return false; - } + try { + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); + console.log(`${logPrefix} Bookmark deleted:`, bookmarkId); + 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; + } + }); } /** - * Sync bookmarks from old item ID to new item ID - * Creates duplicates with new item ID, keeps old ones + * Delete every bookmark of the current user, queued behind the user's other mutations. + * @returns {Promise} true once saved; false if the user changed before it ran. + * Rejects after restoring the previous bookmarks when the save fails. */ - async function syncBookmarks(oldBookmarks, newItemDetails, timeOffset = 0) { - const synced = []; - const now = new Date().toISOString(); - - for (const oldBookmark of oldBookmarks) { - const newBookmarkId = generateBookmarkId(); - const newTimestamp = Math.max(0, oldBookmark.timestamp + timeOffset); - - const newBookmark = { - itemId: newItemDetails.itemId, - tmdbId: newItemDetails.tmdbId, - tvdbId: newItemDetails.tvdbId, - mediaType: newItemDetails.mediaType, - name: newItemDetails.name, - timestamp: newTimestamp, - label: oldBookmark.label || '', - createdAt: oldBookmark.createdAt || now, - updatedAt: now, - syncedFrom: oldBookmark.itemId, // Track where it came from - seasonNumber: newItemDetails.seasonNumber ?? null, - episodeNumber: newItemDetails.episodeNumber ?? null - }; + async function deleteAllBookmarks() { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); + return queueBookmarkMutation(owner, async () => { + if (!isCurrentOwner()) return false; + if (!owner.bookmark) owner.bookmark = { bookmarks: {} }; + const previous = owner.bookmark.bookmarks; + const cleared = {}; + owner.bookmark.bookmarks = cleared; - JE.userConfig.bookmark.bookmarks[newBookmarkId] = newBookmark; - synced.push({ id: newBookmarkId, ...newBookmark }); - } + try { + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); + console.log(`${logPrefix} All bookmarks deleted`); + return true; + } catch (e) { + if (owner.bookmark.bookmarks === cleared) owner.bookmark.bookmarks = previous; + console.error(`${logPrefix} Failed to delete all bookmarks:`, e); + throw e; + } + }); + } - try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); - console.log(`${logPrefix} Synced ${synced.length} bookmarks to new item ID`); - 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]); - throw e; - } + /** + * Sync bookmarks from old item ID to new item ID. + * Creates duplicates with the new item ID and keeps the old ones, unless + * `replaceOriginals` is set: then the old records are removed in the same save, + * and a failed save restores them along with removing the new copies. + * @param {Array<{id?: string}>} oldBookmarks - Bookmarks to copy, with their record IDs. + * @param {object} newItemDetails - Item fields for the copies. + * @param {number} [timeOffset=0] - Seconds added to each copied timestamp. + * @param {{replaceOriginals?: boolean}} [options] + * @returns {Promise>} The new bookmarks; rejects if the save fails. + */ + async function syncBookmarks(oldBookmarks, newItemDetails, timeOffset = 0, { replaceOriginals = false } = {}) { + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); + return queueBookmarkMutation(owner, async () => { + if (!isCurrentOwner()) throw new Error('Bookmark owner changed before sync'); + const bookmarks = owner.bookmark.bookmarks; + const synced = []; + const removed = []; + const now = new Date().toISOString(); + + if (replaceOriginals) { + for (const { id } of oldBookmarks) { + if (id && Object.prototype.hasOwnProperty.call(bookmarks, id)) { + removed.push([id, bookmarks[id]]); + delete bookmarks[id]; + } + } + } + + for (const oldBookmark of oldBookmarks) { + const newBookmarkId = generateBookmarkId(); + const newTimestamp = Math.max(0, oldBookmark.timestamp + timeOffset); + + const newBookmark = { + itemId: newItemDetails.itemId, + tmdbId: newItemDetails.tmdbId, + tvdbId: newItemDetails.tvdbId, + mediaType: newItemDetails.mediaType, + name: newItemDetails.name, + timestamp: newTimestamp, + label: oldBookmark.label || '', + createdAt: oldBookmark.createdAt || now, + updatedAt: now, + syncedFrom: oldBookmark.itemId, // Track where it came from + seasonNumber: newItemDetails.seasonNumber ?? null, + episodeNumber: newItemDetails.episodeNumber ?? null + }; + + bookmarks[newBookmarkId] = newBookmark; + synced.push({ id: newBookmarkId, ...newBookmark }); + } + + try { + 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; + } catch (e) { + console.error(`${logPrefix} Failed to sync bookmarks:`, e); + // Rollback + synced.forEach(bm => delete bookmarks[bm.id]); + removed.forEach(([id, original]) => { bookmarks[id] = original; }); + throw e; + } + }); } /** @@ -363,35 +493,58 @@ * per-episode ID (previously stored as the series-level value). */ async function backfillEpisodeMetadata() { - const allBookmarks = JE.userConfig?.bookmark?.bookmarks || {}; + const owner = JE.userConfig; + const epoch = JE.session ? JE.session.getEpoch() : 0; + const isCurrentOwner = () => JE.userConfig === owner + && (!JE.session || JE.session.isCurrent(epoch)); + const allBookmarks = owner?.bookmark?.bookmarks || {}; const candidates = Object.entries(allBookmarks).filter( ([, bm]) => bm?.mediaType === 'tv' && bm.itemId && bm.episodeNumber == null ); if (candidates.length === 0) return; - let changed = false; - for (const [, bm] of candidates) { + // Look the episodes up first; only the apply and save run in the mutation queue. + const found = []; + for (const [id, bm] of candidates) { try { const details = await fetchItemDetails(bm.itemId); - if (details?.episodeNumber != null) { - bm.seasonNumber = details.seasonNumber; - bm.episodeNumber = details.episodeNumber; - if (details.tvdbId) bm.tvdbId = details.tvdbId; - changed = true; - } + if (details?.episodeNumber != null) found.push([id, bm.itemId, details]); } catch (e) { // Item may no longer exist; leave it for cleanupOrphanedBookmarks() to handle } } + if (found.length === 0) return; + + await queueBookmarkMutation(owner, async () => { + if (!isCurrentOwner()) return; + const bookmarks = owner.bookmark?.bookmarks; + if (!bookmarks) return; + const applied = []; + for (const [id, itemId, details] of found) { + // An earlier queued mutation may have deleted, replaced or already filled the record. + const original = bookmarks[id]; + if (!original || original.itemId !== itemId || original.episodeNumber != null) continue; + const filled = { + ...original, + seasonNumber: details.seasonNumber, + episodeNumber: details.episodeNumber, + ...(details.tvdbId ? { tvdbId: details.tvdbId } : {}) + }; + bookmarks[id] = filled; + applied.push([id, original, filled]); + } + if (applied.length === 0) return; - if (changed) { try { - await JE.saveUserSettings('bookmark.json', JE.userConfig.bookmark); - console.log(`${logPrefix} Backfilled episode metadata for ${candidates.length} bookmark(s)`); + await JE.saveUserSettings('bookmark.json', owner.bookmark, { throwOnError: true }); + console.log(`${logPrefix} Backfilled episode metadata for ${applied.length} bookmark(s)`); } catch (e) { + applied.forEach(([id, original, filled]) => { + if (bookmarks[id] === filled) bookmarks[id] = original; + }); console.warn(`${logPrefix} Failed to save backfilled episode metadata:`, e); } - } + }); } /** @@ -433,8 +586,11 @@ // Delete orphaned bookmarks for (const bookmarkId of toDelete) { try { - await deleteBookmark(bookmarkId); - cleaned++; + // deleteBookmark reports a failed save (rolled back) as false and an + // already-gone record as null, which is neither a removal nor an error. + const deleted = await deleteBookmark(bookmarkId); + if (deleted) cleaned++; + else if (deleted === false) errors++; } catch (e) { errors++; } @@ -1020,11 +1176,16 @@ try { if (isEdit) { - await updateBookmark(existingBookmark.id, { label: labelInput }); - JE.toast(JE.t('toast_bookmark_updated'), 2000); + // A failed save is rolled back and reported as false. + if (!await updateBookmark(existingBookmark.id, { label: labelInput })) { + JE.toast(JE.t('toast_bookmark_save_failed'), 3000); + return; + } + JE.toast(JE.t('toast_bookmark_updated'), 2000); } else { - await addBookmark(timestamp, labelInput); - JE.toast(JE.t('toast_bookmark_updated'), 2000); + // null: no item, or the lookup failed (already reported) or the user changed. + if (!await addBookmark(timestamp, labelInput)) return; + JE.toast(JE.t('toast_bookmark_updated'), 2000); } // Refresh markers @@ -1052,7 +1213,19 @@ modal.querySelectorAll('.je-bookmark-btn-delete').forEach(btn => { btn.addEventListener('click', async () => { const bookmarkId = btn.dataset.bookmarkId; - await deleteBookmark(bookmarkId); + btn.disabled = true; + let deleted; + try { + deleted = await deleteBookmark(bookmarkId); + } finally { + btn.disabled = false; + } + // null: already gone (e.g. a second click), so neither failure nor success. + if (deleted === null) return; + if (!deleted) { + JE.toast(JE.t('toast_bookmark_delete_failed'), 3000); + return; + } JE.toast(JE.t('toast_bookmark_deleted'), 2000); updateBookmarkMarkersForCurrentVideo(); closeDialog(); @@ -1067,6 +1240,7 @@ add: addBookmark, update: updateBookmark, delete: deleteBookmark, + deleteAll: deleteAllBookmarks, findForItem: findBookmarksForItem, showModal: showBookmarkModal, updateMarkers: updateBookmarkMarkersForCurrentVideo, diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js index 37168e7b3..9ceffce5c 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/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js index b8ff472b5..8465a774b 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js @@ -21,6 +21,45 @@ } } + 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 shortcut editor's conflict check compares bindings the same way. + JE.internals = JE.internals || {}; + JE.internals.canonicalCombo = canonicalCombo; + + /** + * 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. @@ -34,7 +73,7 @@ const combo = JE.keyCombo(e); const video = document.querySelector('video'); - const activeShortcuts = JE.state.activeShortcuts; + const activeShortcuts = canonicalShortcuts(); // --- Global Shortcuts --- if (combo === activeShortcuts.OpenSearch) { @@ -437,4 +476,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..90cd4db88 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js @@ -18,12 +18,36 @@ }); } + /** + * Today's local calendar date as "YYYY-MM-DD", to compare with TMDB air dates (also + * calendar dates). The UTC date would already be tomorrow on a US evening. + * @returns {string} The local date. + */ function todayIso() { - return new Date().toISOString().slice(0, 10); + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; } + /** + * 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' }); } @@ -225,7 +249,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/settingspanel/ui-panel-shortcut-editor.js b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js index 6d1e2bbce..bb392a9c7 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js @@ -129,7 +129,10 @@ const labelWrapper = keyElement.parentElement.nextElementSibling; const combo = JE.keyCombo(e); - const existingAction = Object.keys(JE.state.activeShortcuts).find(name => JE.state.activeShortcuts[name] === combo); + // Stored bindings may list their modifiers in any order ("Shift+Ctrl+S"); + // compare them the way the key listener does (events.js). + const canonical = JE.internals.canonicalCombo || (value => value); + const existingAction = Object.keys(JE.state.activeShortcuts).find(name => canonical(JE.state.activeShortcuts[name]) === combo); // Player actions can't take keys Jellyfin's own player already handles, // otherwise both would fire on one press. const isReservedPlayerKey = PLAYER_ACTIONS.has(action) && JELLYFIN_PLAYER_KEYS.has(combo); 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/jellyseerr.js b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js index 710ed93f2..3b826bf26 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js @@ -490,7 +490,7 @@ const card = createJellyseerrCard(item, isJellyseerrActive, jellyseerrUserFound); itemsContainer.appendChild(card); }); - updateJellyseerrResults(results, isJellyseerrActive, jellyseerrUserFound); + updateJellyseerrResults(results, isJellyseerrActive, jellyseerrUserFound, itemsContainer); if (searchHasMore) { setupSearchInfiniteScroll(query); 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/jellyseerr/ui/ui-results.js b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js index f5cb20ae7..55b3a7b5d 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js @@ -431,14 +431,19 @@ * @param {Array} newResults - The new array of result items from the API. * @param {boolean} isJellyseerrActive - If the server is reachable. * @param {boolean} jellyseerrUserFound - If the current user is linked. + * @param {ParentNode} [container=document] - The refreshed section's container. Only + * request buttons inside it are updated, so a button elsewhere on the page (such as + * a retained item-details overview) is left alone. */ - ui.updateJellyseerrResults = function(newResults, isJellyseerrActive, jellyseerrUserFound) { - const existingButtons = document.querySelectorAll('.jellyseerr-request-button[data-tmdb-id]'); + ui.updateJellyseerrResults = function(newResults, isJellyseerrActive, jellyseerrUserFound, container = document) { + const existingButtons = container.querySelectorAll('.jellyseerr-request-button[data-tmdb-id]'); if (existingButtons.length === 0) return; existingButtons.forEach(button => { const tmdbId = button.dataset.tmdbId; - const newItem = newResults.find(item => item.id.toString() === tmdbId); + // A movie and a show can share a TMDB id, so the media type must match too. + const mediaType = button.dataset.mediaType; + const newItem = newResults.find(item => item.id.toString() === tmdbId && item.mediaType === mediaType); if (!newItem) return; const oldItemJSON = button.dataset.searchResultItem; diff --git a/Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json b/Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json index 858fb560a..e7e027e38 100644 --- a/Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json +++ b/Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json @@ -437,6 +437,7 @@ "toast_bookmark_updated": "{{icon:success}} Bookmark updated", "toast_bookmark_deleted": "{{icon:trash}} Bookmark deleted", "toast_bookmark_save_failed": "{{icon:error}} Failed to save bookmark", + "toast_bookmark_delete_failed": "{{icon:error}} Failed to delete bookmark", "toast_jumped_to_bookmark": "{{icon:bookmark}} Jumped to bookmark", "bookmark_cleanup_complete": "Cleanup complete: removed {count} orphaned bookmarks", "bookmark_cleanup_failed": "Cleanup failed", 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/docs/advanced/api.md b/docs/advanced/api.md index 9e670d6b1..c49dec5c5 100644 --- a/docs/advanced/api.md +++ b/docs/advanced/api.md @@ -600,7 +600,7 @@ The sections above document Bookmarks, Reviews, Seerr, and Admin Hidden Content | GET | `jellyseerr/user` | Calling user's Seerr profile | | POST | `jellyseerr/request/tv/{tmdbId}/seasons` | Request specific seasons of a show | | GET | `jellyseerr/watchlist` | Calling user's Seerr watchlist | -| POST | `jellyseerr/sync-watchlist` | Trigger a watchlist sync | +| POST | `jellyseerr/sync-watchlist` | Admin: run the **Sync Watchlist from Seerr to Jellyfin** task's pass now, with the same settings. Uses the first configured Seerr URL whose user list loads, and returns `502` when none does. The response has `success`, `itemsProcessed`, `itemsAdded` and `errors`. Users missing from Seerr aren't imported; the **Import Jellyfin Users to Seerr** task does that | | POST | `jellyseerr/import-users` | Admin: import Jellyfin users into Seerr | | GET | `jellyseerr/settings/partial-requests` | Whether Seerr allows partial season requests | | GET | `jellyseerr/issue`, `jellyseerr/issue/{id}` | List / get Seerr issues | 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..93124ab94 --- /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 are listed in the [coverage matrix](docs/coverage-matrix.md). 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..e2e29446c --- /dev/null +++ b/tests/backend/Api/ApiCdnRefreshTests.cs @@ -0,0 +1,84 @@ +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)); + }); + // 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); + 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..0cf4d3370 --- /dev/null +++ b/tests/backend/Api/ApiConfigurationTests.cs @@ -0,0 +1,128 @@ +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 +{ + /// + /// Fills every secret-like string setting (*ApiKey, *API_KEY, *Secret, *Token, *Password) and each + /// Sonarr/Radarr instance key with a "SECRET_" sentinel. Neither config payload may contain any of + /// them: the private (admin) payload is documented as topology only, with no API keys. + /// + private static IReadOnlyList SeedSecrets(Jellyfin.Plugin.JellyfinEnhanced.Configuration.PluginConfiguration config) + { + var seeded = new List(); + foreach (var property in config.GetType().GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + if (property.PropertyType != typeof(string) || !property.CanWrite + || !System.Text.RegularExpressions.Regex.IsMatch(property.Name, "(ApiKey|API_KEY|Secret|Token|Password)$")) continue; + property.SetValue(config, "SECRET_" + property.Name + "_SENTINEL"); + seeded.Add(property.Name); + } + config.SonarrInstances = "[{\"Name\":\"Main\",\"Url\":\"http://private-sonarr-instance.invalid\",\"ApiKey\":\"SECRET_SONARR_INSTANCE_SENTINEL\",\"Enabled\":true}]"; + config.RadarrInstances = "[{\"Name\":\"Main\",\"Url\":\"http://private-radarr-instance.invalid\",\"ApiKey\":\"SECRET_RADARR_INSTANCE_SENTINEL\",\"Enabled\":true}]"; + // The known keys must be among them, or the name filter has silently stopped matching. + Assert.Superset(new HashSet { "TMDB_API_KEY", "MdblistApiKey", "JellyseerrApiKey", "ShokoApiKey", "SonarrApiKey", "RadarrApiKey", "AnalyticsInstallSecret" }, seeded.ToHashSet()); + return seeded; + } + + [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; + SeedSecrets(config); + 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"; + SeedSecrets(fixture.Plugin.Configuration); + 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); + Assert.Contains("private-sonarr-instance", json); + Assert.Contains("private-radarr-instance", 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); + // A fresh controller, so the header checked is the bundle's own and not GetMainScript's. + var bundleController = ApiAssetTests.Controller(fixture.Core); + var first = Assert.IsType(bundleController.GetScriptBundle()); + Assert.Equal("no-store", bundleController.Response.Headers.CacheControl); + // Development mode rebuilds the bundle on every request instead of serving the cached build. + var second = Assert.IsType(ApiAssetTests.Controller(fixture.Core).GetScriptBundle()); + Assert.NotSame(first.FileContents, second.FileContents); + Assert.Equal(first.FileContents, second.FileContents); + 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..0c7dcb145 --- /dev/null +++ b/tests/backend/Api/ApiMiddlewareTests.cs @@ -0,0 +1,160 @@ +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("日本語"); + }); + // The tag goes just before , and the rewritten page is UTF-8 and says so. + Assert.Equal("日本語" + f.Plugin.BuildScriptTag() + "\n", Body(context)); + Assert.Equal("text/html;charset=utf-8", context.Response.ContentType); + 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..4093c389d --- /dev/null +++ b/tests/backend/Api/ApiSeerrTests.cs @@ -0,0 +1,244 @@ +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.VIEW_ISSUES, false)] + [InlineData(JellyseerrPermission.CREATE_ISSUES, false)] + [InlineData(JellyseerrPermission.AUTO_APPROVE, 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..0b3a0fb5b --- /dev/null +++ b/tests/backend/Core/CoreAwardsTests.cs @@ -0,0 +1,83 @@ +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; + /// 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()); + 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) + { + 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\":[]}}") { Release = new(TaskCreationOptions.RunContinuationsAsynchronously) }; + var factory = Factory(handler); + using (var service = new WikidataAwardsService(factory.Object, f.Paths.Object, f.Logger)) + { + // 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); + } + 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..24710de20 --- /dev/null +++ b/tests/backend/Core/CoreMaintenanceTests.cs @@ -0,0 +1,653 @@ +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); + // 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] + 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 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 f.Service.DisableAsync(); + 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] + 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 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); + Assert.True(f.Policies[f.Restricted.Id].IsDisabled); + Assert.Equal(2, f.Service.GetStatus().AccountDisabledUserIds.Count); + } + + [Fact] + 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); + 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.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] + public async Task CheckpointFailureAfterPolicyRestoreAbortsReconciliationButKeepsTheRestore() + { + using var f = new Fixture(); + var clock = new Clock(); + var service = new MaintenanceModeService(f.Users.Object, f.Sessions.Object, f.Core.Paths.Object, f.Core.Logger, clock); + await service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + var activeJournal = File.ReadAllText(StatePath(f)); + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((id, policy) => + { + f.Policies[id] = policy; + BreakStateFile(f); + return Task.CompletedTask; + }); + // Re-targeting restores Alice, then her checkpoint fails: the new selection is not applied... + await Assert.ThrowsAnyAsync(() => service.EnableAsync("new", 0, "none", null)); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + var status = service.GetStatus(); + Assert.Equal("both", status.Action); + Assert.False(status.IsActive); + // ...but her restore stands, so she is no longer listed to restore. + Assert.Empty(status.AccountDisabledUserIds); + Assert.Empty(status.RemoteDisabledUserIds); + + // An admin then disables Alice by hand, and the disk recovers still holding the active journal. + StorePoliciesNormally(f, f.Alice.Id); + f.Policies[f.Alice.Id].IsDisabled = true; + Directory.Delete(StatePath(f)); + File.WriteAllText(StatePath(f), activeJournal); + await service.ExpireIfDueAsync(); + // The tick saves the restored state instead of restoring Alice a second time. + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + var onDisk = JsonConvert.DeserializeObject(File.ReadAllText(StatePath(f)))!; + Assert.False(onDisk.IsActive); + Assert.Empty(onDisk.AccountDisabledUserIds); + Assert.Empty(onDisk.RemoteDisabledUserIds); + } + + 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 sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => Now; + } + + private static int CountLogLines(Fixture f, string level, string text) + => Directory.GetFiles(f.Core.Root, "JellyfinEnhanced_*.log") + .SelectMany(File.ReadAllLines) + .Count(line => line.Contains("[" + level + "]", StringComparison.Ordinal) && line.Contains(text, StringComparison.Ordinal)); + + [Fact] + public async Task PersistentRestoreFailureRetriesWithCappedBackoffAndLogsErrorOncePerStreak() + { + using var f = new Fixture(); + await f.Service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + var attempts = 0; + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).Returns((_, _) => + { + attempts++; + throw new IOException("policy store unavailable"); + }); + var clock = new Clock(); + var service = new MaintenanceModeService(f.Users.Object, f.Sessions.Object, f.Core.Paths.Object, f.Core.Logger, clock); + await service.DisableAsync(); + Assert.Equal(1, attempts); + // The first retry runs on the next tick; after that the wait doubles from 30 s up to an hour. + await service.ExpireIfDueAsync(); + Assert.Equal(2, attempts); + var expected = 2; + foreach (var seconds in new[] { 30, 60, 120, 240, 480, 960, 1920, 3600, 3600 }) + { + clock.Now += TimeSpan.FromSeconds(seconds - 1); + await service.ExpireIfDueAsync(); + Assert.Equal(expected, attempts); + clock.Now += TimeSpan.FromSeconds(1); + await service.ExpireIfDueAsync(); + Assert.Equal(++expected, attempts); + } + + // An admin ending maintenance again does not wait for the backoff. + await service.DisableAsync(); + Assert.Equal(++expected, attempts); + Assert.Equal(1, CountLogLines(f, "ERROR", "Failed to restore user")); + Assert.Equal(expected - 1, CountLogLines(f, "WARN", "still could not be restored")); + Assert.Contains(f.Alice.Id.ToString(), service.GetStatus().AccountDisabledUserIds); + + // Success ends the streak: the user is restored and the next failure is an Error again. + StorePoliciesNormally(f, f.Alice.Id); + clock.Now += TimeSpan.FromHours(1); + await service.ExpireIfDueAsync(); + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.Empty(service.GetStatus().AccountDisabledUserIds); + await 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 service.DisableAsync(); + await service.ExpireIfDueAsync(); + Assert.Equal(2, CountLogLines(f, "ERROR", "Failed to restore user")); + } + + [Fact] + public async Task RestoreThatCouldNotBeSavedIsRewrittenByTheTickOnceTheDiskRecovers() + { + using var f = new Fixture(); + var clock = new Clock(); + var service = new MaintenanceModeService(f.Users.Object, f.Sessions.Object, f.Core.Paths.Object, f.Core.Logger, clock); + await service.EnableAsync("", 0, "both", [f.Alice.Id.ToString()]); + var activeJournal = File.ReadAllText(StatePath(f)); + BreakStateFile(f); + await service.DisableAsync(); + // Restored, but only memory knows: nothing is pending, so no restore retry would save it. + Assert.False(f.Policies[f.Alice.Id].IsDisabled); + Assert.False(service.GetStatus().IsActive); + Assert.Empty(service.GetStatus().AccountDisabledUserIds); + + // While the disk stays broken the tick rewrites with the restore backoff, one Warning each. + await service.ExpireIfDueAsync(); + Assert.Equal(1, CountLogLines(f, "WARN", "still could not be saved")); + clock.Now += TimeSpan.FromSeconds(29); + await service.ExpireIfDueAsync(); + Assert.Equal(1, CountLogLines(f, "WARN", "still could not be saved")); + clock.Now += TimeSpan.FromSeconds(1); + await service.ExpireIfDueAsync(); + Assert.Equal(2, CountLogLines(f, "WARN", "still could not be saved")); + + // The disk recovers still holding the active journal, which a restart would act on again. + Directory.Delete(StatePath(f)); + File.WriteAllText(StatePath(f), activeJournal); + Assert.True(f.Reopen().GetStatus().IsActive); + clock.Now += TimeSpan.FromSeconds(60); + await service.ExpireIfDueAsync(); + var onDisk = JsonConvert.DeserializeObject(File.ReadAllText(StatePath(f)))!; + Assert.False(onDisk.IsActive); + Assert.Empty(onDisk.AccountDisabledUserIds); + Assert.Empty(onDisk.RemoteDisabledUserIds); + Assert.False(f.Reopen().GetStatus().IsActive); + Assert.Equal(1, CountLogLines(f, "INFO", "failed write had left only in memory")); + + // Clean again: later ticks leave the file alone. + File.WriteAllText(StatePath(f), activeJournal); + await service.ExpireIfDueAsync(); + Assert.Equal(activeJournal, File.ReadAllText(StatePath(f))); + } + + [Fact] + public async Task EnableJournalThatCouldNotBeSavedReachesTheDiskEvenWhileItsRestoreKeepsFailing() + { + using var f = new Fixture(); + 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", [f.Alice.Id.ToString()])); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + // Alice is disabled but only memory lists her; her restore now fails, so no checkpoint saves it. + f.Users.Setup(x => x.UpdatePolicyAsync(f.Alice.Id, It.IsAny())).ThrowsAsync(new IOException("policy store unavailable")); + Directory.Delete(StatePath(f)); + await f.Service.ExpireIfDueAsync(); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + var onDisk = f.Reopen().GetStatus(); + Assert.False(onDisk.IsActive); + Assert.Equal([f.Alice.Id.ToString()], onDisk.AccountDisabledUserIds); + } + + [Fact] + public async Task CheckpointFailureStopsTheRemainingRestores() + { + using var f = new Fixture(); + var bob = new User("bob", "default", "default") { Id = Guid.NewGuid() }; + f.Policies[bob.Id] = new UserPolicy { IsDisabled = false, EnableRemoteAccess = true }; + var all = new[] { f.Admin, f.Alice, f.Restricted, bob }; +#if NET9_0 + f.Users.SetupGet(x => x.Users).Returns(all); +#else + f.Users.Setup(x => x.GetUsers()).Returns(all); +#endif + f.Users.Setup(x => x.GetUserById(bob.Id)).Returns(bob); + f.Users.Setup(x => x.GetUserDto(bob, It.IsAny())).Returns(() => new UserDto { Id = bob.Id, Policy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(f.Policies[bob.Id]))! }); + var service = new MaintenanceModeService(f.Users.Object, f.Sessions.Object, f.Core.Paths.Object, f.Core.Logger, new Clock()); + await service.EnableAsync("", 0, "both", [f.Alice.Id.ToString(), bob.Id.ToString()]); + Assert.True(f.Policies[f.Alice.Id].IsDisabled); + Assert.True(f.Policies[bob.Id].IsDisabled); + var restored = new List(); + f.Users.Setup(x => x.UpdatePolicyAsync(It.IsAny(), It.IsAny())).Returns((id, policy) => + { + restored.Add(id); + f.Policies[id] = policy; + BreakStateFile(f); + return Task.CompletedTask; + }); + + // Re-targeting restores the first user, whose checkpoint then fails. + await Assert.ThrowsAnyAsync(() => service.EnableAsync("new", 0, "none", null)); + + // The failure aborts the pass: the second user is neither restored nor dropped from the list. + var first = Assert.Single(restored); + var second = first == bob.Id ? f.Alice.Id : bob.Id; + Assert.False(f.Policies[first].IsDisabled); + Assert.True(f.Policies[second].IsDisabled); + Assert.Equal([second.ToString()], service.GetStatus().AccountDisabledUserIds); + } + + 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)] + [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(7, 0, false)][InlineData(7, 59, false)][InlineData(8, 0, true)] + [InlineData(9, 59, true)][InlineData(10, 0, false)][InlineData(23, 0, false)] + public void SameDayWindowCoversOnlyItsOwnHours(int hour, int minute, bool active) + { + // 08:00-10:00 lasts two hours; it must not run on into the next morning. + var now = new DateTime(2026, 10, 5, hour, minute, 0); + var result = MaintenanceScheduleService.CurrentWindow(TimeSpan.FromHours(8), TimeSpan.FromHours(10), now); + Assert.Equal(active, result.HasValue); + if (active) + { + Assert.Equal(now.Date.AddHours(8), result!.Value.Start); + Assert.Equal(now.Date.AddHours(10), result.Value.End); + } + } + + [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..529efe679 --- /dev/null +++ b/tests/backend/Core/CorePersistenceTests.cs @@ -0,0 +1,187 @@ +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")); + } + + public class Payload { public int Version { get; set; } public List Items { get; set; } = new(); } + + [Fact] + public async Task ConcurrentStrictReadsNeverSeeAHalfWrittenSave() + { + using var f = new CoreFixture(); + // Large enough that each write takes a while, so a non-atomic save would be read half-written. + static Payload Make(int version) => new() { Version = version, Items = Enumerable.Range(0, 20000).Select(i => $"item-{version}-{i}-padding-padding-padding").ToList() }; + f.Manager.SaveUserConfiguration(User, "payload.json", Make(0)); + using var done = new CancellationTokenSource(); + var reads = 0; + var savesStarted = false; + // Handshakes instead of timing: the saves start only after a first read, and wait halfway + // for a read that finished after they started, however slowly the pool schedules the reader. + var firstRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readDuringSaves = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reader = Task.Run(() => + { + while (!done.IsCancellationRequested) + { + // Checked before the read starts, so only a read that began once the saves had + // started counts as one during them. + var startedAfterSaves = Volatile.Read(ref savesStarted); + // A torn read throws here (and would quarantine the user's valid file). + var read = f.Manager.GetUserConfigurationStrict(User, "payload.json"); + Assert.Equal(20000, read.Items.Count); + Assert.Equal($"item-{read.Version}-19999-padding-padding-padding", read.Items[^1]); + Interlocked.Increment(ref reads); + firstRead.TrySetResult(); + if (startedAfterSaves) readDuringSaves.TrySetResult(); + } + }); + try + { + // WhenAny with the reader so a failing first read surfaces instead of hanging. + await Task.WhenAny(firstRead.Task, reader); + Volatile.Write(ref savesStarted, true); + for (var version = 1; version <= 30 && !reader.IsCompleted; version++) + { + f.Manager.SaveUserConfiguration(User, "payload.json", Make(version)); + if (version == 15) await Task.WhenAny(readDuringSaves.Task, reader); + } + } + finally + { + done.Cancel(); + } + await reader; + Assert.True(readDuringSaves.Task.IsCompleted); + // The first read came before the saves and another began after them. + Assert.True(Volatile.Read(ref reads) > 1); + Assert.Empty(Directory.GetFiles(f.ConfigRoot, "*.corrupt-*", SearchOption.AllDirectories)); + Assert.Equal(30, f.Manager.GetUserConfigurationStrict(User, "payload.json").Version); + } + + [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"); + // 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/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..0dc60ac43 --- /dev/null +++ b/tests/backend/Core/CoreSharedStoresTests.cs @@ -0,0 +1,115 @@ +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 < 499; i++) store.Entries[$"u:{i}:Watched"] = new ActivityEntry { OccurredAt = $"{i + 1:D5}" }; + // The oldest entry has the key that sorts last and is inserted last, so only its timestamp marks it as oldest. + store.Entries["u:zzz:Watched"] = new ActivityEntry { OccurredAt = "00000" }; + 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:zzz:Watched")); + Assert.True(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..9d6dfd7cd --- /dev/null +++ b/tests/backend/Core/CoreUsageTests.cs @@ -0,0 +1,95 @@ +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"); + // 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); + 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; } + } + + [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..a9eb97a0f --- /dev/null +++ b/tests/backend/Integrations/IntegrationAnalyticsTests.cs @@ -0,0 +1,170 @@ +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(false, 0)] + [InlineData(true, 2)] + public async Task OnlyConsentLetsAnOverdueOrForcedReportReachTheNetwork(bool consent, int expectedCalls) + { + using var f = new ApiPluginFixture(); + var c = f.Plugin.Configuration; + c.AnalyticsEnabled = consent; + c.AnalyticsInstallId = "server-id"; c.AnalyticsInstallSecret = "server-secret"; + // Overdue and every version changed: only consent can stop the scheduled report. + c.AnalyticsLastReportedAt = DateTimeOffset.UtcNow.AddDays(-60).ToUnixTimeMilliseconds(); + c.AnalyticsLastReportedPluginVersion = "older"; + c.AnalyticsLastReportedJellyfinTarget = "older-target"; + c.AnalyticsLastReportedJellyfinVersion = "older-host"; + using var counters = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + using var transport = new IntegrationTransport((_, _) => Task.FromResult(IntegrationTransport.Response())); + var service = Service(f, counters, transport); + await service.ReportIfDueAsync(default); + Assert.Equal(consent ? 1 : 0, transport.Calls); + await service.ForceSendAsync(default); + Assert.Equal(expectedCalls, transport.Calls); + } + + [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..f1a2d36ee --- /dev/null +++ b/tests/backend/Integrations/IntegrationCalendarTests.cs @@ -0,0 +1,201 @@ +using System.Globalization; +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); + } + + [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) + { + 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..1f9e2e8b3 --- /dev/null +++ b/tests/backend/Integrations/IntegrationHttpTests.cs @@ -0,0 +1,151 @@ +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://169.254.1.2/")] + [InlineData("http://100.100.100.200")] + [InlineData("file:///etc/passwd")] + [InlineData("file://server/share")] + [InlineData("ftp://127.0.0.1/")] + [InlineData("gopher://127.0.0.1/")] + [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"), + // A valid empty list, so only the status check can stop the second request. + "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..94870c22a --- /dev/null +++ b/tests/backend/Integrations/IntegrationMdblistTests.cs @@ -0,0 +1,119 @@ +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())); + } +} + +[Collection("Plugin singleton")] +public class IntegrationMdblistSavedKeyTests +{ + [Fact] + public async Task TestingAnUnsavedKeyBypassesTheSavedKeysCachedStatus() + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.MdblistApiKey = "saved"; + using var transport = new IntegrationTransport((request, _) => Task.FromResult(IntegrationTransport.Response( + request.RequestUri!.Query.Contains("apikey=saved", StringComparison.Ordinal) + ? "{\"plan\":\"free\",\"rate_limit\":100,\"rate_limit_remaining\":7}" + : "{\"plan\":\"supporter\",\"rate_limit\":1000,\"rate_limit_remaining\":42}"))); + using var service = new MdblistService(transport, f.Core.Paths.Object, f.Core.Logger); + Assert.Equal(7, (await service.GetAccountStatusAsync(default))!.RateLimitRemaining); + Assert.Equal(7, (await service.GetAccountStatusAsync(default))!.RateLimitRemaining); + Assert.Equal(1, transport.Calls); // the saved key's status is cached + var tested = await service.GetAccountStatusAsync(default, apiKeyOverride: "unsaved"); + Assert.Equal(42, tested!.RateLimitRemaining); + Assert.Equal("supporter", tested.Plan); + Assert.Equal(2, transport.Calls); + Assert.Equal(7, service.RemainingQuota()); + } +} diff --git a/tests/backend/Integrations/IntegrationTmdbTests.cs b/tests/backend/Integrations/IntegrationTmdbTests.cs new file mode 100644 index 000000000..659a39aae --- /dev/null +++ b/tests/backend/Integrations/IntegrationTmdbTests.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using System.Text.Json; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +using Moq; + +namespace JE.Tests; + +[Collection("Plugin singleton")] +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) - 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); + } + + [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); + } + + [Theory] + [InlineData("fa-IR")][InlineData("th-TH")] + public async Task PersonDatesStayGregorianUnderNonGregorianCulture(string culture) + { + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.TMDB_API_KEY = "key"; + using var transport = new IntegrationTransport((request, _) => request.RequestUri!.AbsolutePath == "/3/person/7" + ? Task.FromResult(IntegrationTransport.Response("{\"birthday\":\"1950-06-15\",\"deathday\":\"2020-03-01\",\"place_of_birth\":\"Here\"}")) + : throw new InvalidOperationException("Unexpected URL " + request.RequestUri)); + using var cache = new TmdbResponseCache(transport, TimeProvider.System); + // TMDB supplies one person's dates as text; Jellyfin stores the other's as a DateTime. + var fromTmdb = new Person { Id = Guid.NewGuid(), Name = "From TMDB", ProviderIds = new() { ["Tmdb"] = "7" } }; + var fromJellyfin = new Person { Id = Guid.NewGuid(), Name = "From Jellyfin", PremiereDate = new DateTime(1980, 2, 29, 0, 0, 0, DateTimeKind.Utc) }; + var library = new Mock(); + library.Setup(l => l.GetItemById(fromTmdb.Id)).Returns(fromTmdb); + library.Setup(l => l.GetItemById(fromJellyfin.Id)).Returns(fromJellyfin); + var controller = ApiAssetTests.Controller(f.Core, library.Object, cache); + JsonElement tmdb, jellyfin; + var old = CultureInfo.CurrentCulture; + try + { + // fa-IR reads ISO years as Persian and th-TH formats them as Thai Buddhist years. + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + tmdb = JsonSerializer.SerializeToElement(Assert.IsType(await controller.GetPersonInfo(fromTmdb.Id)).Value); + jellyfin = JsonSerializer.SerializeToElement(Assert.IsType(await controller.GetPersonInfo(fromJellyfin.Id)).Value); + } + finally { CultureInfo.CurrentCulture = old; } + Assert.Equal("1950-06-15", tmdb.GetProperty("birthDate").GetString()); + Assert.Equal("2020-03-01", tmdb.GetProperty("deathDate").GetString()); + Assert.True(tmdb.GetProperty("isDeceased").GetBoolean()); + Assert.Equal(69, tmdb.GetProperty("ageAtDeath").GetInt32()); + Assert.Equal("1980-02-29", jellyfin.GetProperty("birthDate").GetString()); + Assert.False(jellyfin.GetProperty("isDeceased").GetBoolean()); + } +} diff --git a/tests/backend/Integrations/IntegrationWatchProvidersTests.cs b/tests/backend/Integrations/IntegrationWatchProvidersTests.cs new file mode 100644 index 000000000..851100940 --- /dev/null +++ b/tests/backend/Integrations/IntegrationWatchProvidersTests.cs @@ -0,0 +1,69 @@ +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 _)); + // A valid list padded to the 2048-character limit is accepted; one character more is rejected by the length guard alone. + var padded = "movie:1" + new string(',', 2048 - "movie:1".Length); + Assert.True(WatchProvidersBatch.TryParseItems(padded, out _, out _)); + Assert.False(WatchProvidersBatch.TryParseItems(padded + ",", out var rejected, out var error)); + Assert.Empty(rejected); Assert.Equal($"Too many items (max {WatchProvidersBatch.MaxItems}).", error); + } + + [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..61078cfa1 --- /dev/null +++ b/tests/backend/PosterTags/PerfRound2TagCacheResponseTests.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.Controllers; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +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 +{ + /// + /// GET /tag-cache over a real TagCacheService: two movies (one per user by + /// default) and an episode of a series Alice can put under Spoiler Guard. + /// Library access and Alice's played items are mutable per test. + /// + private sealed class Endpoint : IDisposable + { + public readonly ApiPluginFixture Fixture = new(); + public readonly User Alice = new("alice", "default", "default") { Id = Guid.NewGuid() }; + public readonly User Bob = new("bob", "default", "default") { Id = Guid.NewGuid() }; + public readonly Guid First = Guid.NewGuid(), Second = Guid.NewGuid(), Episode = Guid.NewGuid(), SeriesId = Guid.NewGuid(); + public Guid[] AliceAccess; + public readonly HashSet AlicePlayed = []; + public readonly Mock Library = new(); + public readonly TagCacheService Cache; + public readonly JellyfinEnhancedController Controller; + private readonly ServiceProvider _provider; + + public Endpoint() + { + Fixture.Plugin.Configuration.TagCacheServerMode = true; + Fixture.Plugin.Configuration.SpoilerBlurEnabled = false; + Fixture.Plugin.Configuration.ShowUserReviews = false; + AliceAccess = [First]; + var users = new Mock(); + users.Setup(x => x.GetUserById(Alice.Id)).Returns(Alice); + users.Setup(x => x.GetUserById(Bob.Id)).Returns(Bob); + Library.Setup(x => x.GetItemIds(It.IsAny())).Returns((InternalItemsQuery q) => + q.IsPlayed == true ? (q.User == Alice ? [.. q.ItemIds.Where(AlicePlayed.Contains)] : []) + : 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 }, + [Episode.ToString("N")] = new() { Type = "Episode", SeriesId = SeriesId.ToString("N"), Genres = ["Episode secret"], LastUpdated = 150 } + } + })); + 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; + }); + _provider = services.BuildServiceProvider(); + var resolver = new SpoilerUserResolver(Fixture.Core.Manager, Library.Object, Fixture.Core.Logger, null!); + var userData = Mock.Of(); + Controller = ApiAssetTests.Controller(Fixture.Core, users.Object, Cache, Library.Object, userData, Fixture.Core.Manager, resolver, + new SpoilerTagDataStripper(Library.Object, userData, resolver)); + } + + /// Alice guards the episode's series; genres are stripped from unwatched episodes. + public void GuardSeries() + { + Fixture.Plugin.Configuration.SpoilerBlurEnabled = true; + Fixture.Plugin.Configuration.SpoilerStripTags = true; + var state = new UserSpoilerBlur(); + state.Series[SeriesId.ToString("N")] = new(); + Fixture.Core.Manager.SaveUserConfiguration(Alice.Id.ToString("N"), SpoilerBlurImageFilter.SpoilerBlurFileName, state); + } + + public void SignIn(User caller, bool admin = false) => Controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = _provider, User = global::JellyfinEnhanced.Tests.PrivacyPolicyTests.Principal(caller.Id, admin) } + }; + + public async Task<(int Status, string ETag, string Body)> Read(User caller, Guid? route = null, long? since = null, string? validator = null, bool admin = false) + { + SignIn(caller, admin); + var http = Controller.HttpContext; + 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; + var result = Controller.GetTagCache(route ?? caller.Id, since); + 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())); + } + + public void Dispose() + { + Cache.Dispose(); + _provider.Dispose(); + Fixture.Dispose(); + } + } + + private static string FilterRevision(string body) + { + using var json = JsonDocument.Parse(body); + return json.RootElement.GetProperty("filterRevision").GetString()!; + } + + // A delta with nothing changed since the client's copy: no entries at all. + private static void AssertEmptyDelta(string body) + { + using var json = JsonDocument.Parse(body); + Assert.Equal(0, json.RootElement.GetProperty("count").GetInt32()); + Assert.Empty(json.RootElement.GetProperty("items").EnumerateObject()); + } + + [Fact] + public async Task ExecutedResponsesAndValidatorsRemainUserIsolatedAfterAccessChanges() + { + using var endpoint = new Endpoint(); + var (alice, bob, first, second) = (endpoint.Alice, endpoint.Bob, endpoint.First, endpoint.Second); + + var a = await endpoint.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 endpoint.Read(alice, validator: a.ETag); + Assert.Equal(304, aAgain.Status); Assert.Empty(aAgain.Body); + var b = await endpoint.Read(bob, validator: 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 endpoint.Read(bob, validator: b.ETag)).Status); + endpoint.AliceAccess = [second]; + endpoint.Cache.InvalidateUserAccess(); + var changed = await endpoint.Read(alice, validator: 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); + } + + [Theory] + [InlineData("tag-cache")] + [InlineData("tag-data")] + [InlineData("item-stats")] + [InlineData("file-size")] + [InlineData("watch-progress")] + public async Task NonAdminCannotReadAnotherUsersPerUserData(string route) + { + using var endpoint = new Endpoint(); + endpoint.SignIn(endpoint.Bob); + var alice = endpoint.Alice.Id; + var item = endpoint.First; + IActionResult result = route switch + { + "tag-cache" => endpoint.Controller.GetTagCache(alice), + "tag-data" => endpoint.Controller.GetTagData(alice, [item.ToString()]), + "item-stats" => endpoint.Controller.GetItemStatsByItemId(alice, item), + "file-size" => endpoint.Controller.GetFileSizeByItemId(alice, item), + _ => endpoint.Controller.GetWatchProgressByItemId(alice, item), + }; + Assert.IsType(result); + if (route != "tag-cache") return; + // Controls: an administrator may read Alice's cache, and Bob still reads his own. + var admin = await endpoint.Read(endpoint.Bob, route: alice, admin: true); + Assert.Equal(200, admin.Status); Assert.Contains("First secret", admin.Body); Assert.DoesNotContain("Second secret", admin.Body); + Assert.Contains("Second secret", (await endpoint.Read(endpoint.Bob)).Body); + } + + [Fact] + public async Task DeltaRevalidationAfterAccessRevocationReturnsTheNewFilterRevision() + { + // A delta (?since=) holds only entries changed since the client's copy, so it + // can't say an entry was revoked: only the filter revision tells the client to + // replace its stored copy. Here both deltas are empty, so the revision is all + // that differs, and the old validator must not get a 304. + using var endpoint = new Endpoint(); + endpoint.AliceAccess = [endpoint.First, endpoint.Second]; + var full = await endpoint.Read(endpoint.Alice); + Assert.Contains("Second secret", full.Body); + var delta = await endpoint.Read(endpoint.Alice, since: 200); + Assert.Equal(200, delta.Status); + AssertEmptyDelta(delta.Body); + Assert.Equal(304, (await endpoint.Read(endpoint.Alice, since: 200, validator: delta.ETag)).Status); + endpoint.AliceAccess = [endpoint.First]; + endpoint.Cache.InvalidateUserAccess(); + var revoked = await endpoint.Read(endpoint.Alice, since: 200, validator: delta.ETag); + Assert.Equal(200, revoked.Status); + Assert.NotEqual(delta.ETag, revoked.ETag); + Assert.NotEqual(FilterRevision(delta.Body), FilterRevision(revoked.Body)); + AssertEmptyDelta(revoked.Body); + } + + [Theory] + [InlineData(null)] + [InlineData(300L)] + public async Task RevalidationSeesAGuardedEpisodeBecomingWatched(long? since) + { + // Watching a guarded episode changes neither the cache version, the + // guarded set (filter revision) nor the entry count — only the entry + // itself, now unstripped. A full load and a delta (which always carries + // guarded entries) must both answer the old validator with the new body. + using var endpoint = new Endpoint(); + endpoint.AliceAccess = [endpoint.First, endpoint.Episode]; + endpoint.GuardSeries(); + var guarded = await endpoint.Read(endpoint.Alice, since: since); + Assert.Equal(200, guarded.Status); + Assert.Contains(endpoint.Episode.ToString("N"), guarded.Body); Assert.DoesNotContain("Episode secret", guarded.Body); + Assert.Equal(304, (await endpoint.Read(endpoint.Alice, since: since, validator: guarded.ETag)).Status); + endpoint.AlicePlayed.Add(endpoint.Episode); + var watched = await endpoint.Read(endpoint.Alice, since: since, validator: guarded.ETag); + Assert.Equal(200, watched.Status); + Assert.Contains("Episode secret", watched.Body); + Assert.Equal(FilterRevision(guarded.Body), FilterRevision(watched.Body)); + } + + [Fact] + public async Task EmptyRouteUserStillAppliesTheCallersOwnSpoilerGuard() + { + // An empty route id resolves to the caller; their Spoiler Guard state must + // be the one applied, not the (absent) state of the empty id. + using var endpoint = new Endpoint(); + endpoint.AliceAccess = [endpoint.First, endpoint.Episode]; + endpoint.GuardSeries(); + var own = await endpoint.Read(endpoint.Alice, route: Guid.Empty); + Assert.Equal(200, own.Status); + Assert.Contains(endpoint.Episode.ToString("N"), own.Body); Assert.DoesNotContain("Episode secret", own.Body); + + var series = new Series { Id = endpoint.SeriesId, Name = "Show", Genres = ["Series secret"] }; + endpoint.Library.Setup(x => x.GetItemById(series.Id, It.IsAny())).Returns(series); + endpoint.Library.Setup(x => x.GetItemList(It.IsAny())).Returns([]); // no episodes: the unguarded projection still completes + endpoint.SignIn(endpoint.Alice); + var tagData = Assert.IsType(endpoint.Controller.GetTagData(Guid.Empty, [series.Id.ToString()])); + var json = JsonSerializer.Serialize(tagData.Value); + Assert.Contains(series.Id.ToString(), json); Assert.DoesNotContain("Series secret", json); + } +} diff --git a/tests/backend/PosterTags/PerfRound2TagCacheTests.cs b/tests/backend/PosterTags/PerfRound2TagCacheTests.cs new file mode 100644 index 000000000..73529a378 --- /dev/null +++ b/tests/backend/PosterTags/PerfRound2TagCacheTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.JellyfinEnhanced.Model; +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); + } + + [Fact] + public void QueuedRebuildOfAnUnchangedItemDoesNotTouchTheCache() + { + // Library scans raise ItemUpdated for items whose tag data did not change; the + // rebuild must keep the stored entry (and its LastUpdated, which deltas key on) + // instead of reporting a change that re-saves the whole cache. + using var f = new ApiPluginFixture(); + f.Plugin.Configuration.TagCacheServerMode = true; + var episode = new Episode + { + Id = Guid.NewGuid(), Name = "Episode", IndexNumber = 1, ParentIndexNumber = 1, SeriesId = Guid.NewGuid(), Genres = ["Drama"], CommunityRating = 8, + Sources = [new() { MediaStreams = [new() { Type = MediaStreamType.Audio, Language = "eng" }, new() { Type = MediaStreamType.Video, Height = 1080 }] }] + }; + var library = new Mock(); + library.Setup(l => l.GetItemById(episode.Id)).Returns(episode); + var snapshot = Path.Combine(f.Core.ConfigRoot, "tag-cache.json"); + Directory.CreateDirectory(f.Core.ConfigRoot); + File.WriteAllText(snapshot, JsonSerializer.Serialize(new { SchemaVersion = 7, Version = 7, LastModified = 200, LastReconciledUtcTicks = new DateTime(2026, 1, 1).Ticks, Items = new Dictionary() })); + + // Shutdown applies the queued update synchronously: the first rebuild adds the entry... + using (var cache = new TagCacheService(library.Object, f.Core.Paths.Object, Mock.Of(), f.Core.Logger)) + { + cache.LoadFromDisk(); + cache.EnqueueUpdate(episode.Id); + } + var afterFirst = File.ReadAllText(snapshot); + using (var restarted = new TagCacheService(library.Object, f.Core.Paths.Object, Mock.Of(), f.Core.Logger)) + { + restarted.LoadFromDisk(); + Assert.True(restarted.TryGetEntry(episode.Id, out var added)); + Assert.Equal(["Drama"], added.Genres!); + Assert.True(restarted.LastModified > 200); + // ...and the second, with nothing changed, mutates nothing. + var (version, modified) = (restarted.Version, restarted.LastModified); + restarted.EnqueueUpdate(episode.Id); + restarted.Dispose(); + Assert.Equal((version, modified), (restarted.Version, restarted.LastModified)); + Assert.True(restarted.TryGetEntry(episode.Id, out var kept)); + Assert.Same(added, kept); + } + Assert.Equal(afterFirst, File.ReadAllText(snapshot)); + } +} diff --git a/tests/backend/PosterTags/PosterBlurFilterTests.cs b/tests/backend/PosterTags/PosterBlurFilterTests.cs new file mode 100644 index 000000000..9622ea350 --- /dev/null +++ b/tests/backend/PosterTags/PosterBlurFilterTests.cs @@ -0,0 +1,205 @@ +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]); + } + private static double Contrast(byte[] bytes) { using var image=SKBitmap.Decode(bytes); return PosterBlurTests.Contrast(image); } + // A re-encoded but unblurred checkerboard keeps nearly all of its contrast; the default intensity removes most of it. + private static void Blurred(byte[] source,byte[] output) + { + var (before,after)=(Contrast(source),Contrast(output)); Assert.True(after(executed.Result);Assert.Equal("image/jpeg",result.ContentType);Assert.NotEqual(bytes,result.FileContents);Blurred(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); + } + [Theory] + [InlineData("blur")][InlineData("hide")] + public async Task Head_preserves_body_result_but_scrubs_cache(string mode) + { + using var f=new Fixture();f.Plugin.Plugin.Configuration.SpoilerBlurMode=mode;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); + // Same parameter, different value: the value is part of the key too. + var(third,_)=await f.Run(new FileContentResult(PosterBlurTests.Source(32,16),"image/png"),query:"?maxWidth=32"); + using var c=SKBitmap.Decode(Assert.IsType(third.Result).FileContents);Assert.Equal(32,c.Width);Assert.Equal(16,c.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","blur")][InlineData("physical","blur")][InlineData("virtual","blur")] + [InlineData("stream","hide")][InlineData("physical","hide")][InlineData("virtual","hide")] + public async Task All_supported_file_result_shapes_are_protected(string shape,string mode) + { + using var f=new Fixture();f.Plugin.Plugin.Configuration.SpoilerBlurMode=mode;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);Blurred(bytes,result.FileContents);Private(http); + } + [Theory] + [InlineData("blur")][InlineData("hide")] + public async Task Higher_blur_intensity_removes_more_detail_in_both_modes(string mode) + { + async Task At(int intensity) + { + using var f=new Fixture();f.Plugin.Plugin.Configuration.SpoilerBlurMode=mode;f.Plugin.Plugin.Configuration.SpoilerBlurIntensity=intensity; + var(executed,_)=await f.Run(new FileContentResult(PosterBlurTests.Source(),"image/png"));return Contrast(Assert.IsType(executed.Result).FileContents); + } + var(light,heavy)=(await At(10),await At(60));Assert.True(heavy(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..f2d0c5aff --- /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..388833aa8 --- /dev/null +++ b/tests/backend/PosterTags/PosterRegressionTests.cs @@ -0,0 +1,268 @@ +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() + { + // Distinct chip texts give distinct tiles. Measure each tile, then use a budget that holds + // three of the largest but not all eight, so the cache both keeps tiles and has to evict. + var texts = new[] { "4K", "1080p", "720p", "480p", "HDR", "SDR", "DV", "8K" }; + static PosterTagLayout Chip(string text) => new([new(PosterTagGroup.Quality, PosterTagCorner.TopLeft, [new QualityTag(text, "resolution")])], false, false); + using var source = Poster(); + var tileBytes = new List(); + using (var probe = new PosterTagRenderer(1L << 40)) + { + foreach (var text in texts) + { + var before = probe.TileCacheBytes; + using var _ = probe.Render(source, Chip(text)); + tileBytes.Add(probe.TileCacheBytes - before); + } + Assert.Equal(texts.Length, probe.TileCacheCount); + } + Assert.All(tileBytes, size => Assert.True(size > 0)); + var budget = 3 * tileBytes.Max(); + Assert.True(tileBytes.Sum() > budget); + + using var cached = new PosterTagRenderer(budget); + using var uncached = new PosterTagRenderer(0); + foreach (var text in texts) + { + using var first = cached.Render(source, Chip(text)); + using var second = uncached.Render(source, Chip(text)); + Assert.Equal(first!.Pixels, second!.Pixels); + Assert.InRange(cached.TileCacheCount, 1, texts.Length); + Assert.InRange(cached.TileCacheBytes, 1, budget); + Assert.Equal(0, uncached.TileCacheCount); + } + Assert.InRange(cached.TileCacheCount, 3, texts.Length - 1); + + // A cached tile still draws the same pixels. + using (var again = cached.Render(source, Chip(texts[^1]))) + using (var reference = uncached.Render(source, Chip(texts[^1]))) + { + Assert.Equal(reference!.Pixels, again!.Pixels); + } + + 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)")] + // A script variant (baku1926, luna1918, ...) becomes the script only when no script or region is + // present after aliasing and it sorts first among the remaining variants (alalc97 from heploc counts). + [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)")] + [InlineData("az-baku1926-1994", "Azerbaijani (1994_BAKU1926)")] + [InlineData("az-abcde-baku1926", "Azerbaijani (ABCDE_BAKU1926)")] + [InlineData("tk-1994-baku1926", "Turkmen (1994_BAKU1926)")] + [InlineData("en-baku1926-abcde", "English (ABCDE_BAKU1926)")] + [InlineData("az-baku1926-1994-zzzzz", "Azerbaijani (1994_BAKU1926_ZZZZZ)")] + [InlineData("az-abcde-baku1926-fonipa", "Azerbaijani (ABCDE_BAKU1926_FONIPA)")] + [InlineData("ru-mmmmm-luna1918", "Russian (Luna)")] + [InlineData("ru-petr1708-luna1918", "Russian (Luna)")] + [InlineData("en-1994-colb1945", "English (1994_COLB1945)")] + [InlineData("ru-RU-petr1708", "Russian (Russia, PETR1708)")] + // getCanonicalLocales' language+variant aliases (sh-petr1708 -> sr-Petr) never apply to display names. + [InlineData("sh-petr1708", "Serbian (Latin, PETR1708)")] + 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..b02da80f2 --- /dev/null +++ b/tests/backend/PosterTags/PosterTokenTests.cs @@ -0,0 +1,101 @@ +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 void Concurrent_first_use_shares_one_persisted_secret() + { + // 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.*")); + } + + 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..8452e2276 --- /dev/null +++ b/tests/backend/Privacy/PrivacyAutoEnableTests.cs @@ -0,0 +1,164 @@ +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); + } + + 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() + { + // Bounded so a stuck sweep cannot hang the run. StopAsync swallows the host giving up, + // so a sweep still running after it is caught by the zero-wait check instead. + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + await Service.StopAsync(timeout.Token); + await Service.WaitForSweepsAsync().WaitAsync(TimeSpan.Zero); + } + 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() + { + 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 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); + try + { + await Task.Delay(100); + Assert.False(stop.IsCompleted); + } + finally + { + // Always unblock the sweep, even when the assertion fails. + 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); + } +} 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..348380cae --- /dev/null +++ b/tests/backend/Privacy/PrivacyFieldFilterTests.cs @@ -0,0 +1,166 @@ +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); + } + // The production save path drops the resolver's cached state itself (see the test below). + public void Save(UserSpoilerBlur state) => Plugin.Core.Manager.SaveUserConfiguration(User.ToString("N"), "spoilerblur.json", state); + 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, CriticRating = 80, SortName = "Secret sort", + }; + 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.Null(hidden.OriginalTitle); Assert.Null(hidden.SortName); + Assert.Null(hidden.CommunityRating); Assert.Null(hidden.CriticRating); + Assert.Equal("Secret ending", watched.Name); Assert.Equal("Secret plot", watched.Overview); + Assert.Equal("Secret original", watched.OriginalTitle); Assert.Equal("Secret sort", watched.SortName); + Assert.Equal(9, watched.CommunityRating); Assert.Equal(80, watched.CriticRating); + 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(); + static MediaStream[] Streams() => [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" }, + new MediaStream { Title = "Secret" }]; + dto.MediaStreams = Streams(); + // Each media source nests its own copy of the streams, scrubbed by the same rules. + dto.MediaSources = [new MediaSourceInfo { Name = "Secret", Path = "/Secret.mkv", MediaStreams = Streams() }]; + await f.Invoke(dto, "UserLibrary", "GetItem"); + foreach (var streams in new[] { dto.MediaStreams, dto.MediaSources[0].MediaStreams }) + { + Assert.Null(streams[0].Path); Assert.Null(streams[0].Title); + Assert.Equal("/Videos/1/Subtitles/0/Stream.srt", streams[0].DeliveryUrl); + Assert.Null(streams[1].Path); Assert.Null(streams[1].DeliveryUrl); + Assert.Null(streams[2].Title); + } + Assert.Null(dto.MediaSources[0].Path); Assert.Null(dto.MediaSources[0].Name); + } + + [Fact] + public async Task SavingSpoilerStateReplacesTheCachedStateForTheNextRequest() + { + using var f = new Fixture(); + var id = Guid.NewGuid(); + BaseItemDto Movie() => new() { Id = id, Type = BaseItemKind.Movie, Name = "Movie name", Overview = "Secret plot", UserData = new UserItemDataDto { Key = "test" } }; + // This request caches the user's state, in which the movie is not protected. + var before = Movie(); await f.Invoke(before); + Assert.Equal("Secret plot", before.Overview); + Assert.True(SpoilerUserResolver.IsUserStateCachedForTest(f.User.ToString("N"))); + var state = new UserSpoilerBlur(); state.Series[f.Series.ToString("N")] = new(); state.Movies[id.ToString("N")] = new(); + f.Save(state); + var after = Movie(); await f.Invoke(after); + Assert.NotEqual("Secret plot", after.Overview); + } + + [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..f4cd07f83 --- /dev/null +++ b/tests/backend/Privacy/PrivacyHiddenContentTests.cs @@ -0,0 +1,160 @@ +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.EventHandlers; +using Jellyfin.Plugin.JellyfinEnhanced.Services; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +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 Microsoft.Extensions.Logging.Abstractions; +using Moq; +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("homesections", "GetItems", false)] + [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); + // The Continue Watching switch alone never reaches library listings, even for a global hide. + Save(f, user, Hide(hidden)); + Assert.Single(Assert.IsType>(await Run(f, user, "Items", "GetItems", 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); + } + + [Theory] + [InlineData("continuewatching", null)] + [InlineData("homesections", "nextup")] + public async Task ResumingAHiddenItemShowsItInContinueWatchingOnTheNextRequest(string scope, string? remainingScope) + { + using var f = new JE.Tests.ApiPluginFixture(); f.Plugin.Configuration.HiddenContentEnabled = true; + var user = Guid.NewGuid(); var movie = new Movie { Id = Guid.NewGuid() }; Save(f, user, Hide(movie.Id, scope)); + // The first request caches the user's hidden content (30-second TTL) with the entry present. + Assert.Empty(Assert.IsType>(await Run(f, user, "Items", "GetResumeItems", Rows(new BaseItemDto { Id = movie.Id }))).Items); + using var usage = new UsageEventCounterService(f.Core.Paths.Object, f.Core.Logger); + await new ContinueWatchingPlaybackConsumer(f.Core.Manager, usage, f.Core.Logger).OnEvent(new PlaybackStartEventArgs + { Item = movie, Session = new SessionInfo(Mock.Of(), NullLogger.Instance) { UserId = user } }); + // A Continue Watching hide is dropped and a home-sections hide demoted to Next Up... + var saved = f.Core.Manager.GetUserConfiguration(user.ToString("N"), "hidden-content.json"); + Assert.Equal(remainingScope, saved.Items.Values.SingleOrDefault()?.HideScope); + // ...and the very next request sees it, without waiting for the cache to expire. + Assert.Single(Assert.IsType>(await Run(f, user, "Items", "GetResumeItems", Rows(new BaseItemDto { Id = movie.Id }))).Items); + } +} diff --git a/tests/backend/Privacy/PrivacyIdentityTests.cs b/tests/backend/Privacy/PrivacyIdentityTests.cs new file mode 100644 index 000000000..0f1411bb0 --- /dev/null +++ b/tests/backend/Privacy/PrivacyIdentityTests.cs @@ -0,0 +1,196 @@ +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 ARowThatThrowsIsSkippedWithoutHidingLaterMatches() + { + // A null row throws on its first member access, like a corrupt SessionInfo would. + _sessions.SetupGet(x => x.Sessions).Returns(new[] { Session(_bob, "203.0.113.9:8096"), null!, Session(_alice) }); + 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")] + [InlineData("0123456789abcdef0123456789abcdef")] + public void MalformedMarkersCannotResolveAsValidSuffix(string? tag) + => Assert.False(SpoilerIdentityService.TryParseMarker(tag, out _, out _)); + + [Fact] + public void APlainHexImageTagIsStampedRatherThanMistakenForAMarker() + { + // Jellyfin image tags are 32 hex characters; only the "-jeu" sentinel marks a stamped tag. + const string tag = "0123456789abcdef0123456789abcdef"; + var marker = _markers.MintMarker(_alice); + var stamped = SpoilerIdentityService.AppendMarker(tag, marker); + Assert.Equal(tag + "-jeu" + marker, stamped); + Assert.Equal(stamped, SpoilerIdentityService.AppendMarker(stamped, marker)); + } + + 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..058102572 --- /dev/null +++ b/tests/backend/Privacy/ReviewActivityVisibilityTests.cs @@ -0,0 +1,157 @@ +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 items are only visible to a query scoped to a user: on Jellyfin 12 by + // ConfigureUserAccess(query, user), on 10.11 by the user-bound recursive query. + var scopedTo = new Dictionary(ReferenceEqualityComparer.Instance); +#if NET9_0 + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(query => query.User == null ? [] : allowedIds); +#else + // Like Jellyfin 12's AddUserToQuery, the library scope only applies while ItemIds is + // still empty; configuring access after narrowing to the page leaves the query unscoped. + library.Setup(x => x.ConfigureUserAccess(It.IsAny(), It.IsAny())).Callback((query, user) => + { + if (query.ItemIds.Length == 0) scopedTo[query] = user; + }); + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(query => scopedTo.ContainsKey(query) ? allowedIds : []); +#endif + 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); +#if !NET9_0 + Assert.Contains(viewer, scopedTo.Values); +#endif + allowedIds = []; + Assert.Empty(builder.Build(entries, [], User(), false, config, 1)); + // A later request sees the author's new state from a fresh lookup (a new User object), + // not an author resolved for an earlier request. + allowedIds = [allowed]; + var hiddenAuthor = User(author.Id); hiddenAuthor.SetPermission(PermissionKind.IsHidden,true); + users.Setup(x => x.GetUserById(author.Id)).Returns(hiddenAuthor); + Assert.Empty(builder.Build(entries, [], viewer, false, config, 1)); + Assert.Single(builder.Build(entries, [], hiddenAuthor, false, config, 1)); + Assert.Single(builder.Build(entries, [], viewer, true, config, 1)); + } + + [Theory] + [InlineData("Watched", false)] [InlineData("Watched", true)] + [InlineData("Favorited", false)] [InlineData("Favorited", true)] + [InlineData("Reviewed", false)] [InlineData("Reviewed", true)] + public void DisabledFeedCategoriesNeverQueryLibraryWhileEnabledOnesReachIt(string category, bool enabled) + { + using var env = new CoreFixture(); + // The viewer is the author, so the author-visibility check cannot drop the entry: + // only the category switch decides whether the library is asked. + var viewer = User(); var itemId = Guid.NewGuid(); + var library = new Mock(); var repository = new Mock(); + if (enabled) + { + library.Setup(x => x.GetItemIds(It.IsAny())).Returns(new List { itemId }); + library.Setup(x => x.GetItemById(itemId)).Returns(new Movie { Id = itemId, Name = "Visible movie" }); + repository.Setup(x => x.GetItemIdsList(It.IsAny())).Returns(new List { itemId }); + } + var builder = new ActivityFeedBuilder(library.Object, Mock.Of(), repository.Object, env.Logger); + var config = new PluginConfiguration + { + ActivityFeedShowWatched = enabled && category == "Watched", ActivityFeedShowFavorited = enabled && category == "Favorited", + ActivityFeedShowReviewed = enabled && category == "Reviewed", HideReviewsFromHiddenUsers = true, HideReviewsFromDisabledUsers = true + }; + var activity = category == "Reviewed" ? [] : new[] { new ActivityEntry { UserId = viewer.Id.ToString("N"), ItemId = itemId.ToString("N"), ActivityType = category, OccurredAt = "2025-01-01T00:00:00Z" } }; + var reviews = category == "Reviewed" ? new[] { new UserReview { UserId = viewer.Id.ToString("N"), TmdbId = "42", CreatedAt = "2025-01-01T00:00:00Z", Rating = 4 } } : []; + var rows = builder.Build(activity, reviews, viewer, false, config, 5); + if (!enabled) + { + // Not even a lookup: the feed builder swallows per-candidate lookup failures, + // so a throwing mock could not prove this. + Assert.Empty(rows); + library.VerifyNoOtherCalls(); + repository.VerifyNoOtherCalls(); + return; + } + Assert.Equal(category, Assert.Single(rows).ActivityType); + library.Verify(x => x.GetItemIds(It.IsAny()), Times.Once); + } + + [Fact] + public void CancellationStopsTheFeedBeforeTheLibrary() + { + 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" }; + 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..fb2afd581 --- /dev/null +++ b/tests/backend/Privacy/UrlPolicyTests.cs @@ -0,0 +1,74 @@ +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://169.254.169.254/latest", false)] + [InlineData("http://169.254.1.2/", false)] + [InlineData("http://0.0.0.0", false)] + [InlineData("http://[::]", false)] + [InlineData("ftp://127.0.0.1/", false)] + [InlineData("gopher://127.0.0.1/", false)] + [InlineData("file://server/share", false)] + [InlineData("http://127.0.0.1/", true)] + [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)); + } + + // The metadata host names must be refused by name, before any DNS lookup: offline, or with a + // resolver that doesn't map them to a blocked address, a DNS fallback would let them through. + [Theory] + [InlineData("http://metadata.goog/", false)] + [InlineData("http://METADATA.GOOGLE.INTERNAL./latest", false)] + [InlineData("http://ordinary.internal/", null)] + public void MetadataHostNamesAreRefusedWithoutDns(string url, bool? expected) + { + var syncChecks = typeof(ArrUrlGuard).GetMethod("TrySyncChecks", BindingFlags.Static | BindingFlags.NonPublic)!; + Assert.Equal(expected, (bool?)syncChecks.Invoke(null, [url, null])); + } +} 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..eb9a92051 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskAutoRequestTests.cs @@ -0,0 +1,241 @@ +using System.Globalization; +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("fa-IR", true)][InlineData("fa-IR", false)] + [InlineData("th-TH", true)][InlineData("th-TH", false)] + public async Task MovieCollectionReleaseDateIsReadAsGregorianUnderNonGregorianCulture(string culture, bool released) + { + // A year either side of today: read as a Persian year a past date lands centuries ahead, + // and read as a Thai Buddhist year a future date lands centuries ago. + var releaseDate = DateTime.UtcNow.AddYears(released ? -1 : 1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + Assert.Equal(released ? 1 : 0, await NextCollectionMovieRequests(releaseDate, CultureInfo.GetCultureInfo(culture))); + } + + [Fact] + public async Task MovieCollectionReleasedTodayInServerTimeIsRequested() + { + // A date-only release day starts at the server's local midnight. A UTC cutoff would treat this + // title as unreleased when the test runs ahead of UTC in the hours after local midnight. + var today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + Assert.Equal(1, await NextCollectionMovieRequests(today, CultureInfo.CurrentCulture)); + } + + // Runs the collection check for an available first movie whose sequel has the given release date, + // under the given culture, and returns how many requests reached Seerr. + private static async Task NextCollectionMovieRequests(string releaseDate, CultureInfo culture) + { + 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 = 0; + using var transport = new IntegrationTransport((request, _) => + { + switch (request.RequestUri!.AbsolutePath) + { + case "/3/movie/123": return Task.FromResult(IntegrationTransport.Response("{\"belongs_to_collection\":{\"id\":9,\"name\":\"Saga\"}}")); + case "/api/v1/collection/9": return Task.FromResult(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, mediaInfo = new { status = 1 } } } }))); + case "/api/v1/user": return Task.FromResult(IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 22, jellyfinUserId = user.Id.ToString("N") } } }))); + case "/api/v1/request": + Interlocked.Increment(ref posts); + return Task.FromResult(IntegrationTransport.Response("{}", 201)); + default: throw new InvalidOperationException("Unexpected URL " + request.RequestUri); + } + }); + var old = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + await new AutoMovieRequestService(transport, f.Core.Logger, users.Object, null!).CheckMovieForCollectionRequestAsync(movie, user.Id); + } + finally { CultureInfo.CurrentCulture = old; } + return posts; + } + + [Theory] + [InlineData(false, true)][InlineData(true, false)][InlineData(true, true)] + public async Task DisabledAutomaticRequestsDoNotConsultUsersOrNetwork(bool feature, bool seerr) + { + using var f = new ApiPluginFixture(); + 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)] + [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..9fa63c4c1 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskMetadataTests.cs @@ -0,0 +1,194 @@ +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; + // One movie has only a community rating, the other only a critic rating: each existing + // value is preserved without overwrite while the missing one is filled. + var movie = new Movie { Id = Guid.NewGuid(), CommunityRating = 4, ProviderIds = new() { ["Tmdb"] = "123" } }; + var critic = new Movie { Id = Guid.NewGuid(), CriticRating = 50, ProviderIds = new() { ["Tmdb"] = "124" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie, critic }); + var writes = 0; + library.Setup(l => l.UpdateItemAsync(It.IsAny(), 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); + MdblistCacheEntry Ratings() => new() { Found = true, Confirmed = true, FetchedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Ratings = [new() { Source = "tmdb", Score = 82 }, new() { Source = "tomatoes", Score = 91 }] }; + service.MergeMediaBatchIntoCache("movie", ["123", "124"], new Dictionary { ["123"] = Ratings(), ["124"] = Ratings() }); + 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(8.2f, critic.CommunityRating); + Assert.Equal(overwrite ? 91f : 50f, critic.CriticRating); + Assert.Equal(2, writes); + await task.ExecuteAsync(new Progress(), default); + Assert.Equal(2, 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..323af2e17 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskRegressionTests.cs @@ -0,0 +1,259 @@ +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; + // Without consent the report is overdue and the version changed, so only consent stops it; + // with consent nothing is due yet. + config.AnalyticsLastReportedAt = DateTimeOffset.UtcNow.AddDays(consent ? 0 : -60).ToUnixTimeMilliseconds(); + config.AnalyticsLastReportedPluginVersion = consent ? f.Plugin.Version.ToString() : "older"; + 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.SyncJellyseerrWatchlist = 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)); + } + } + + [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)); + } + } + } +} diff --git a/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs b/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs new file mode 100644 index 000000000..3d721e429 --- /dev/null +++ b/tests/backend/ScheduledTasks/TaskSeerrSyncTests.cs @@ -0,0 +1,329 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Plugin.JellyfinEnhanced.Configuration; +using Jellyfin.Plugin.JellyfinEnhanced.ScheduledTasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +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 }; + // Loose, so a regression that processes the other user's request would reach these + // calls instead of throwing inside the task's per-item catch. + var data = new Mock(); + data.Setup(d => d.GetUserData(allowed, movie)).Returns(userData); + data.Setup(d => d.GetUserData(allowed, otherUsersMovie)).Returns(new UserItemData { Key = "other", Likes = false }); + 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); + // The blocked user's request for 456 never touches the allowed user's data. + data.Verify(d => d.GetUserData(It.IsAny(), otherUsersMovie), Times.Never); + data.Verify(d => d.SaveUserData(It.IsAny(), otherUsersMovie, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.DoesNotContain(f.Core.Manager.GetProcessedWatchlistItems(allowed.Id).Items, item => item.TmdbId == 456); + Assert.Equal(100, progress.Values.Last()); + } + + [Fact] + public async Task ManualWatchlistSyncHonoursBlockedUsersReadditionPreventionAndTheRequestSetting() + { + 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 = false; + var admin = new User("admin", "default", "default") { Id = Guid.NewGuid() }; + var blocked = new User("blocked", "default", "default") { Id = Guid.NewGuid() }; + config.JellyseerrImportBlockedUsers = blocked.Id.ToString(); + Movie Movie(string tmdb) => new() { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = tmdb } }; + var fresh = Movie("123"); var removed = Movie("789"); var blockedPick = Movie("456"); var requested = Movie("555"); + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { fresh, removed, blockedPick, requested }); + // An earlier sync added 789 for the admin, who has since removed it. + f.Core.Manager.SaveProcessedWatchlistItems(admin.Id, new ProcessedWatchlistItems + { Items = { new ProcessedWatchlistItem { TmdbId = 789, MediaType = "movie", ProcessedAt = DateTime.UtcNow, Source = "sync" } } }); + var data = new Mock(); + data.Setup(d => d.GetUserData(It.IsAny(), It.IsAny())).Returns(() => new UserItemData { Key = "fixture", Likes = false }); + var saved = new List<(Guid User, Guid Item)>(); + data.Setup(d => d.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((User user, BaseItem item, UserItemData _, MediaBrowser.Model.Entities.UserDataSaveReason _, CancellationToken _) => saved.Add((user.Id, item.Id))); + using var transport = new IntegrationTransport((request, _) => + { + var path = request.RequestUri!.AbsolutePath; + return Task.FromResult(IntegrationTransport.Response( + path == "/api/v1/user" ? JsonSerializer.Serialize(new { results = new[] { new { id = 27, jellyfinUserId = admin.Id.ToString("N") }, new { id = 28, jellyfinUserId = blocked.Id.ToString("N") } } }) + : path == "/api/v1/user/27/watchlist" ? "{\"results\":[{\"tmdbId\":123,\"mediaType\":\"movie\"},{\"tmdbId\":789,\"mediaType\":\"movie\"}]}" + : path == "/api/v1/user/28/watchlist" ? "{\"results\":[{\"tmdbId\":456,\"mediaType\":\"movie\"}]}" + : "{\"results\":[{\"requestedBy\":{\"id\":27},\"media\":{\"tmdbId\":555,\"mediaType\":\"movie\"}}]}")); + }); + var controller = ApiAssetTests.Controller(f.Core, Users(admin, blocked).Object, library.Object, data.Object, transport, f.Core.Manager); + controller.ControllerContext.HttpContext.User = global::JellyfinEnhanced.Tests.PrivacyPolicyTests.Principal(admin.Id, admin: true); + + var result = Assert.IsType(await controller.SyncJellyseerrWatchlist()); + // Only the admin's new watchlist item: not the blocked user's 456, not the + // removed 789, and not the 555 request while requested media is off. + Assert.Equal(new[] { (admin.Id, fresh.Id) }, saved); + // The admin's two watchlist entries (123 and the removed 789) were processed; + // the blocked user's list and the requests were never read. + Assert.Equal("{\"success\":true,\"itemsProcessed\":2,\"itemsAdded\":1,\"errors\":null}", JsonSerializer.Serialize(result.Value)); + Assert.Contains(f.Core.Manager.GetProcessedWatchlistItems(admin.Id).Items, item => item.TmdbId == 123); + Assert.Empty(f.Core.Manager.GetProcessedWatchlistItems(blocked.Id).Items); + } + + [Theory] + [InlineData("")] + [InlineData("\n \n")] + public async Task ManualWatchlistSyncWithoutAUsableUrlIsABadRequest(string urls) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.SyncJellyseerrWatchlist = true; + config.JellyseerrUrls = urls; + config.JellyseerrApiKey = "test"; + var admin = new User("admin", "default", "default") { Id = Guid.NewGuid() }; + using var transport = new IntegrationTransport((_, _) => throw new InvalidOperationException("Must not call Seerr")); + var controller = ApiAssetTests.Controller(f.Core, Users(admin).Object, new Mock().Object, new Mock().Object, transport, f.Core.Manager); + controller.ControllerContext.HttpContext.User = global::JellyfinEnhanced.Tests.PrivacyPolicyTests.Principal(admin.Id, admin: true); + + var result = Assert.IsType(await controller.SyncJellyseerrWatchlist()); + Assert.Equal("{\"error\":\"Jellyseerr URL or API key is not configured\"}", JsonSerializer.Serialize(result.Value)); + Assert.Equal(0, transport.Calls); + } + + [Theory] + [InlineData(false)][InlineData(true)] + public async Task ManualWatchlistSyncUsesTheFirstUrlThatAnswersAndFailsWhenNoneDoes(bool allFail) + { + using var f = new ApiPluginFixture(); + var config = f.Plugin.Configuration; + config.JellyseerrEnabled = true; + config.SyncJellyseerrWatchlist = true; + config.JellyseerrUrls = "http://first.test\n http://second.test \nhttp://third.test"; + config.JellyseerrApiKey = "test"; + var admin = new User("admin", "default", "default") { Id = Guid.NewGuid() }; + var movie = new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = "123" } }; + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(new BaseItem[] { movie }); + var data = new Mock(); + data.Setup(d => d.GetUserData(It.IsAny(), It.IsAny())).Returns(() => new UserItemData { Key = "fixture", Likes = false }); + var saved = 0; + data.Setup(d => d.SaveUserData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => saved++); + var calls = new List(); + using var transport = new IntegrationTransport((request, _) => + { + var uri = request.RequestUri!; + lock (calls) calls.Add(uri.Host + uri.AbsolutePath); + // The first URL refuses the connection; the second answers unless every URL is down. + if (uri.Host == "first.test") throw new HttpRequestException("connection refused"); + if (allFail) return Task.FromResult(IntegrationTransport.Response("bad gateway", 502, "text/html")); + return Task.FromResult(IntegrationTransport.Response(uri.AbsolutePath == "/api/v1/user" + ? JsonSerializer.Serialize(new { results = new[] { new { id = 27, jellyfinUserId = admin.Id.ToString("N") } } }) + : "{\"results\":[{\"tmdbId\":123,\"mediaType\":\"movie\"}]}")); + }); + var controller = ApiAssetTests.Controller(f.Core, Users(admin).Object, library.Object, data.Object, transport, f.Core.Manager); + controller.ControllerContext.HttpContext.User = global::JellyfinEnhanced.Tests.PrivacyPolicyTests.Principal(admin.Id, admin: true); + + var result = await controller.SyncJellyseerrWatchlist(); + + if (allFail) + { + // No URL answered: a failure, not a 200 with nothing processed. + var failure = Assert.IsType(result); + Assert.Equal(502, failure.StatusCode); + Assert.Equal("{\"error\":\"Could not load the user list from any configured Jellyseerr URL.\"}", JsonSerializer.Serialize(failure.Value)); + Assert.Equal(new[] { "first.test/api/v1/user", "second.test/api/v1/user", "third.test/api/v1/user" }, calls); + Assert.Equal(0, saved); + return; + } + + var ok = Assert.IsType(result); + Assert.Equal("{\"success\":true,\"itemsProcessed\":1,\"itemsAdded\":1,\"errors\":null}", JsonSerializer.Serialize(ok.Value)); + Assert.Equal(1, saved); + // The rest of the pass stays on the URL whose user list loaded; the third is never tried. + Assert.Equal(new[] { "first.test/api/v1/user", "second.test/api/v1/user", "second.test/api/v1/user/27/watchlist" }, calls); + } + + [Fact] + public async Task ConcurrentSyncPassesKeepEveryProcessedEntry() + { + 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; + var user = new User("viewer", "default", "default") { Id = Guid.NewGuid() }; + const int perPass = 60; + var movies = Enumerable.Range(1, perPass * 2).Select(i => new Movie { Id = Guid.NewGuid(), ProviderIds = new() { ["Tmdb"] = i.ToString() } }).ToArray(); + var library = new Mock(); + library.Setup(l => l.GetItemList(It.IsAny())).Returns(movies); + // Even TMDB ids are already on the watchlist, so both the "added" and the + // "already there" records are written concurrently. + var data = new Mock(); + data.Setup(d => d.GetUserData(It.IsAny(), It.IsAny())) + .Returns((User _, BaseItem item) => new UserItemData { Key = "fixture", Likes = int.Parse(item.ProviderIds["Tmdb"]) % 2 == 0 }); + // Both passes fetch the user list before either processes an item, so their + // processed-items writes for the same user overlap. + using var bothStarted = new Barrier(2); + IntegrationTransport Seerr(int first) => new((request, _) => + { + if (request.RequestUri!.AbsolutePath == "/api/v1/user") + { + bothStarted.SignalAndWait(TimeSpan.FromSeconds(30)); + return Task.FromResult(IntegrationTransport.Response(JsonSerializer.Serialize(new { results = new[] { new { id = 27, jellyfinUserId = user.Id.ToString("N") } } }))); + } + var items = Enumerable.Range(first, perPass).Select(i => new { tmdbId = i, mediaType = "movie" }); + return Task.FromResult(IntegrationTransport.Response(JsonSerializer.Serialize(new { results = items }))); + }); + // The scheduled run and a manual sync, each seeing a different half of the watchlist. + using var scheduledSeerr = Seerr(1); + using var manualSeerr = Seerr(perPass + 1); + var scheduled = new JellyseerrWatchlistSyncTask(library.Object, Users(user).Object, data.Object, scheduledSeerr, f.Core.Manager, f.Core.Logger); + var manual = new JellyseerrWatchlistSyncTask(library.Object, Users(user).Object, data.Object, manualSeerr, f.Core.Manager, f.Core.Logger); + + var passes = await Task.WhenAll(Task.Run(() => scheduled.RunAsync(null, default)), Task.Run(() => manual.RunAsync(null, default))); + + Assert.All(passes, pass => Assert.Equal(perPass / 2, pass!.ItemsAdded)); + var recorded = f.Core.Manager.GetProcessedWatchlistItems(user.Id).Items.Select(item => item.TmdbId).OrderBy(id => id); + Assert.Equal(Enumerable.Range(1, perPass * 2), recorded); + } +} 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..734eee740 --- /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 (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: + +- 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..97385e990 --- /dev/null +++ b/tests/browser/flows.spec.cjs @@ -0,0 +1,172 @@ +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([]); + } +}); +// Freezes the page's timers so only clock.runFor advances them and a slow runner cannot fire +// one early. The pause point is far past install so a stall between the calls cannot put it +// in the past. +async function pauseClock(page) { + await page.clock.install({ time: 0 }); + await page.clock.pauseAt(60_000); +} +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); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByLabel('Season', { exact: true })).toBeFocused(); + await expect(dialog.getByRole('button', { name: 'Request', exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Cancel', exact: true })).toBeVisible(); + 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 pauseClock(page); + 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); + // It slides out at 1000 ms and is removed 300 ms later. + await page.clock.runFor(1270); + await expect(page.locator('.jellyfin-enhanced-toast')).toHaveCount(1); + await page.clock.runFor(20); + 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 pauseClock(page); // the panel's page reload must not fire mid-test + 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 pauseClock(page); // the panel's page reloads must not fire mid-test + 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 000000000..7c0a78f05 Binary files /dev/null and b/tests/browser/flows.spec.cjs-snapshots/request-modal-chromium-linux.png differ diff --git a/tests/browser/flows.spec.cjs-snapshots/request-modal-firefox-linux.png b/tests/browser/flows.spec.cjs-snapshots/request-modal-firefox-linux.png new file mode 100644 index 000000000..4cd0be77c Binary files /dev/null and b/tests/browser/flows.spec.cjs-snapshots/request-modal-firefox-linux.png differ 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 000000000..c6bd12576 Binary files /dev/null and b/tests/browser/flows.spec.cjs-snapshots/request-modal-mobile-chromium-linux.png differ 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 000000000..c6f2ce3a6 Binary files /dev/null and b/tests/browser/fonts/LiberationSans-Bold.ttf differ diff --git a/tests/browser/fonts/LiberationSans-Regular.ttf b/tests/browser/fonts/LiberationSans-Regular.ttf new file mode 100644 index 000000000..adb89475f Binary files /dev/null and b/tests/browser/fonts/LiberationSans-Regular.ttf differ diff --git a/tests/browser/playwright.config.cjs b/tests/browser/playwright.config.cjs new file mode 100644 index 000000000..847956828 --- /dev/null +++ b/tests/browser/playwright.config.cjs @@ -0,0 +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: 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: origin, env: { ...process.env, JE_BROWSER_PORT: String(port) }, 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..d5cf7e89a --- /dev/null +++ b/tests/docs/README.md @@ -0,0 +1,77 @@ +# 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 the newest official Jellyfin image of each compatibility line (`jellyfin/jellyfin:10.11` and `jellyfin/jellyfin:12`, pulled fresh on every run). Each target's `report.json` records the exact image digest and Jellyfin server version that were tested. 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 | + +The results of the latest complete run are recorded in [validation](validation.md). + +`--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 47% lines and 35% branches and frontend global floors are 28% statements, 20% branches, 29% functions and 30% lines, each below the measured values recorded in [validation](validation.md). 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..a7f9939ec --- /dev/null +++ b/tests/docs/coverage-matrix.md @@ -0,0 +1,142 @@ +# 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 compile reference versions, not a claim that every patch release was executed. The real-host suite tests the newest release of each line instead (`jellyfin/jellyfin:10.11` and `jellyfin/jellyfin:12`, pulled on every run) and records the exact image digest and server version in each target's `report.json`. 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. + +The current inventory lists 348 source/configuration/style/locale files, 170 HTTP verb/route declarations, 510 configuration and persisted-model properties, 13 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 its source file 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` runs 45 installed-plugin scenarios against the newest Jellyfin 10.11.x and 12.x releases, recording the tested digest and server version per target; see [validation.md](validation.md) for the latest recorded run. 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. `PerfRound2TagCacheResponseTests.cs` checks that a non-admin asking for another user's tag cache, tag data, item stats, file size or watch progress gets Forbid while an administrator may read it. 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; an empty route user id on tag cache/tag data resolves to the caller, whose own Spoiler Guard state then applies; 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; a shortcut rebound or cleared in the editor overrides the administrator's default after a reload. Real host verifies persistence/restart. Remaining: control-by-control administrator configuration. | +| 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, strict reads racing large saves never seeing a half-written file (the saves start after a first read and wait for a read that began after they started), review/activity integrity, and activity retention dropping the oldest timestamp even when its key sorts last and was inserted last. Remaining: each store-specific recovery policy, filesystem permission/interrupted-write injection. Core suite passed 144 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, the exact rewritten UTF-8 body and its `text/html;charset=utf-8` content type, assets, development mode serving the bundle with its own `no-store` header and rebuilding it on every request (`ApiConfigurationTests.cs`), 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` fills every secret-like setting (`*ApiKey`, `*API_KEY`, `*Secret`, `*Token`, `*Password`) and each Sonarr/Radarr instance key by reflection and verifies none reaches the public or admin payload, alongside topology 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, a plain 32-hex image tag never parsed as a marker, and a throwing session row skipped without hiding later matches. 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; tag-cache revalidation returns the unstripped entry once a guarded episode is watched. `PrivacyFieldFilterTests.cs` adds actual field filter cases (OriginalTitle/SortName/community and critic ratings nulled, external subtitle paths scrubbed at the top level and inside each media source), including a spoilerblur.json save replacing the resolver's cached state through the production save path; 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 45 dual-target cases: actual Skia contrast reduction, decoded filter output that must lose most of the source's contrast in blur mode and the hide-mode fallback, a higher `SpoilerBlurIntensity` removing more, sigma/dimension bounds, corrupt input, stock/resize/cache/concurrency; actual MVC byte/file/stream/physical/virtual results and movie image types in both the default hide mode and blur mode, chapter resume boundary, watched/keep-art bypass, HEAD/no-store/cache scrubbing, viewer separation, output dimensions (the size value is part of the cache key) 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, with stop waiting for a running sweep and every test checking no sweep outlives it. 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 (including cached preferences), 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 with ID normalization and the calendar surface setting, session cleanup of hidden IDs/TMDB IDs/items, stale refresh/parent lookup. `PrivacyHiddenContentTests.cs` adds actual MVC filtering across eight route shapes, pagination, series cascade/cache invalidation, typed hints/latest lists, surface scopes that never reach library listings (including a homesections hide and Remove Continue Watching with the master switch off) 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. `PrivacyHiddenContentTests.cs` plays a hidden item through the actual playback consumer: a Continue Watching hide is dropped, a home-sections hide demoted to Next Up, and the very next resume request shows the item (the filter cache is refreshed). Remaining: hide/unhide endpoint, multiselect UI and the orphan prune on item removal. | +| 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, overlapping add/add and delete/delete saves with a failed first save, replacement migration that keeps the old group when its save fails, orphan cleanup counting only saved deletes, player-modal failure toasts, episode backfill and delete-all running in the per-owner queue with rollback, an already-deleted bookmark reported as nothing to delete (no failure toast) and delete buttons disabled while in flight, Unicode persistence. Expanded account-switch/logout success/failure cases now pass, including stale lookup, owner-scoped metadata cache, and a queued update, delete-all or episode backfill that does not run once another user has signed in. 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, edit/delete controls only on a regular viewer's own review, 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 user and item names in every section including active streams, refresh coalescing and polling teardown. `ReviewActivityVisibilityTests.cs` covers actual feed filtering before limits, user-access scoping (`ConfigureUserAccess` on Jellyfin 12, applied before the page's ItemIds) and fresh author lookups per request, hidden/self/admin behavior, and each disabled category (watched, favorited, reviewed) making no library or repository call while its enabled control reaches the library, plus cancellation. Remaining: playback/favorite event-to-feed integration and review-rating mirror side effects. | +| Catch Up, `Controllers/CatchUpController.cs`, `Services/CatchUpLogService.cs`, `PluginPages/CatchUpPage.html`, `js/enhanced/catchup/*`, its page registration in `JellyfinEnhanced.cs` | Swipe actions (watched with the season picker, dismiss, watchlist, open) and undo, the session user and item names taken from the server rather than the client, admin-only log reads, summary totals where an undo cancels the earlier mark, log rotation and size bound, corrupt log lines skipped, feature or log disabled, native tab/Plugin Pages/Custom Tabs placement | Unverified — Catch Up arrived from main after this suite was written and has no behavioral tests. Only generic checks reach it: the manifest smoke tests (`bootstrap-manifest.test.mjs`) load each `catchup-*` module in bundle order without load-time errors and check it is one top-level IIFE, and the inventory lists its three routes. Remaining: everything in the risk column. | +| 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 and same-day window boundaries (08:00-10:00 is not active at 07:00 or after 10:00), skipped occurrence restart, expiry/restoration, failed update/restore retry and reconciliation intent, per-user enable journaling, fail-open disable with an unwritable journal, corrupt/unreadable state handling, carrying persistently failing restores into later windows, and backed-off retries (next tick, then 30 s doubling to one hour) with one Error per failure streak; a restore or enable journal that could only be kept in memory is rewritten by the tick, with the same backoff, once the disk recovers; a restore on the enable path whose checkpoint fails stays restored in memory, and the tick saves it instead of restoring that user again; that failed checkpoint stops the pass, so a second pending user is neither restored nor dropped from the list. 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 advanced-option gating on the plain and 4K split request buttons (component) and in the browser. Principal identity wins, secret-bearing service settings require Jellyfin admin, issue-only and auto-approve permissions cannot read services, 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, disconnected reads versus completing writes, and late status results or failures from a previous user that are neither cached nor shown. The request button's success, quota, advanced-options and typed-403 outcomes run on both the plain and the 4K split button: the standard half sends exactly `{mediaType, mediaId}` (no 4K flag) and opens the standard options, and a quota rejection restores the exact idle label without a spinner; a search-results refresh updates only buttons inside the refreshed section and matches media type as well as the exact TMDB id (a second movie whose id starts with the same digits keeps its own status and requests its own id); and pending/requested/partial/available/blocked media cannot be requested (deleted media can). The actual season-selection modal sends stored or manual TVDB ids (a missing match blocks the request, #653), leaves Specials out of whole-show requests, escapes season names, disables requested seasons but re-offers one Seerr still calls available after its deletion, and escapes a failure toast while re-enabling the button; the movie request modal blocks the request when any one advanced option is empty and sends server, quality and folder to the right fields; the collection modal escapes titles (a quoted title stays inside the poster's `alt`), skip owned movies and stop at a quota rejection. 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, the admin manual watchlist sync route honouring blocked users, re-addition prevention and the requested-media setting (it runs the scheduled task's pass) with its full response body, a `BadRequest` without a usable URL, the first Seerr URL that answers serving the pass and a `502` when none does, two concurrent passes for one user keeping every processed-item record, movie collection and next-season decision thresholds/availability/existing requests, collection release dates read as Gregorian under fa-IR and th-TH, a title released today in the server's time zone being requested, 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 (successful or failed refreshes, with a control that a failed refresh of the open modal still marks it requested), 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 30 cases for literal/cloud/mapped-IP destinations, mixed resolved addresses and Docker/Podman link-local exceptions through the actual address-policy delegate, literal link-local URLs, metadata host names refused before DNS and the http/https scheme allowlist; the Radarr/Sonarr status check alone stops a 503 with a valid body. 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 (events with no Jellyfin item survive the library-access check), error escaping, stale account responses (calendar events, instance-error toasts, user data and their failures from before a switch never reach the next account, and the switch empties events and user data), request state reset on a user switch (a previous user's pending load no longer blocks the next), 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; 16 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 whole-grid-row paging (page size from the live column count, History's 48-card cap, reset to page 1 on a column change), stale-user permission/data rejection (late queue/request/issue/history responses and failures, with issues enabled, the approve permission revoked by the switch, and a late "history visible" answer that cannot override the new account's hidden history), sticky issue denials, a scoped metadata cache that a late Seerr detail success or failure cannot refill, disabled sources with no request on either transport (and an enabled control), 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, and person birth/death dates from TMDB and from Jellyfin staying Gregorian (with the right age at death) under fa-IR and th-TH. `IntegrationWatchProvidersTests.cs` covers strict region/logo validation, malformed IDs/dedup, the 100-ID limit, the 2048-character limit on an otherwise valid list, 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 (an unsaved test key bypasses the saved key's cached status) plus `TaskMetadataTests.cs` exercise actual enabled rating sync fill/preserve (community and critic ratings each)/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. `PerfRound2TagCacheTests.cs` applies a queued rebuild of an unchanged item that leaves the entry, version and snapshot untouched; `PerfRound2TagCacheResponseTests.cs` checks the tag-cache ETag against an access revocation seen by a delta read (both deltas carry no entries) and a guarded episode becoming watched. Browser tag-pipeline/renderer tests cover stale targets, dedup, owner/TTL/corrupt cache and review ratings dropped on an identity reset. 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 — `PosterRegressionTests.cs` (60 cases) passed both targets, including actual native resolution/rendering. Strict parity: 39 setting profiles × 101 metadata fixtures plus one empty-profile check, 115,960 language-name comparisons and 31,676 casing strings pass. Previously nonfatal parity mismatches now fail. Script-variant aliasing (baku1926, colb1945, luna1918, petr1708) follows ICU in 26 theory cases, and a 572,040-tag comparison against Node 26.2 `Intl.DisplayNames` shows no regression versus main. `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 between them; 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. `PosterTokenTests.cs` (18) and `HistoryNativeClientTests.cs` (13) 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 a tile cache that keeps tiles, evicts within its byte budget and empties when cleared. 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 (including a current key, timestamp and owner key that share a legacy stem, with an already-applied server clear)/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. `bootstrap-manifest.test.mjs` loads every `js/component-scripts.json` module in bundle order with the bundle's per-file wrapper, waits for deferred startup errors, and checks each file is a single top-level IIFE as the dev and fallback loaders run it unwrapped. A request made after a user switch never joins the previous user's in-flight request, an aborted queued request releases its slot, and a bootstrap answered for another user during a switch is rejected; its per-endpoint fallback also drops a late private config or settings document from the previous identity. 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, and stored bindings whose modifiers are listed in any order, in both the key listener and the editor's conflict check; saved rebinds and cleared bindings override the administrator's defaults after a reload. 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 (the next user's tray holds only the launcher) 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 preference (the configured region, then US, ahead of list order), errors/escaping, local-day air dates west of UTC, 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; a cached locale is reused a minute short of 24 hours and fetched again a minute past it. 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 (with the page clock pinned, so it cannot straddle UTC midnight). `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 proves consent alone stops an overdue scheduled report and a forced send (zero transport calls; consent on sends), and 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 13 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`: no consent suppresses an overdue report, and consent with nothing due sends nothing; enabled-due/report failures remain at the task level. | +| 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 — `TaskSeerrSyncTests.cs` runs enabled inbound sync: user-scoped requests (another user's request is never read or written), idempotence, no re-add after removal, upstream failure, invalid JSON and cancellation; plus disabled/missing credentials. The pass falls back to the next configured URL when one fails and reports failure when none answers; concurrent passes keep every processed-item record. The admin manual sync route runs the same pass. Partial per-account failures remain. | +| 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 (community and critic)/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. | +| StartupService (`Services/StartupService.cs`) | startup trigger, monitor initialization, repeated run, cancellation | Partial — exercised only indirectly: each real-host run starts the plugin, whose startup trigger runs it. No test isolates its initialization, a repeated run or cancellation. | + +## Persistence checklist + +User-scoped files include `settings.json`, `shortcuts.json`, `elsewhere.json`, `bookmark.json`, `hidden-content.json`, `spoilerblur.json`, and `processed-watchlist-items.json`. Shared stores/caches include `reviews.json`, `activity.json`, `maintenance-state.json`, `tag-cache.json`, `tmdb-cache.json`, `mdblist-ratings.json`, `awards.json`, `usage-counters.json`, `whats-new.json`, `config-schema-snapshot.json`, and the Catch Up log `catchup-log.jsonl` (rotated to `catchup-log.1.jsonl`). 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 | Tested — `HistoryNativeClientTests.ReportedDesktopShellMustNotReceiveNativeTags` checks fixed client names (including Jellium Desktop's casing and padding) independently of production's exclusion list, and the `issue-905-jellium-exclusion` history replay removes the exclusion and sees that test fail | +| `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 commands and results of the latest complete run are in [validation](validation.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, persisted cache recovery and a queued rebuild of an unchanged item. +- 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. +- Known untested guards (no test fails if they are removed): Spoiler Guard late-response epoch checks in `js/enhanced/spoilerguard/state.js`; the Seerr override-rules epoch check and its reset on a user switch in `js/jellyseerr/api.js`; the quota-dialog late-response guards in `js/jellyseerr/ui/ui-buttons.js`; the pause-screen cache and credential reset on a user switch in `js/enhanced/player/pausescreen.js`; the media-info watch-progress and audio-language cache clears on a user switch in `js/enhanced/itemdetails/features-details-media-info.js`; `TightenPermissions` on the native poster token secret in `Services/PosterTags/PosterTagVariantToken.cs`; the section container the search refresh passes to `updateJellyseerrResults` in `js/jellyseerr/jellyseerr.js` (no unit test loads that module; the scoping itself is tested through `ui-results.js`); and the `errors` list in the manual watchlist sync response, which no test can fill because every per-user step catches its own failures. The watchlist monitor's processed-item writes and the retention cleanup use the same locked update as the sync pass, but only the sync pass is run concurrently in a test. +- Catch Up has no behavioral tests: nothing exercises its swipe event route, admin-only log routes, log store or page behavior; the manifest smoke tests only load its modules (see its row above). +- 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..fa1027453 --- /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). Each ledger links the source records it assesses. 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; the [recent audit](history-recent.md) records which original tests survived each one. + +**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. The original pipeline's baseline, fault and restored runs all passed, 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. + +## 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 the latest execution results. diff --git a/tests/docs/history-early.md b/tests/docs/history-early.md new file mode 100644 index 000000000..4ca7c8489 --- /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. diff --git a/tests/docs/history-middle.md b/tests/docs/history-middle.md new file mode 100644 index 000000000..bbc3243ae --- /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. Each row links its source record. 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. + +## 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..797c62a16 --- /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. 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..6b18f11e0 --- /dev/null +++ b/tests/docs/maintenance-recovery.md @@ -0,0 +1,11 @@ +# 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. 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. While no window is open, the schedule tick keeps retrying a failing restore with in-memory backoff (next tick, then 30 s doubling to a one-hour cap); each failure streak logs one Error, later attempts a Warning, and an administrator's disable still retries at once. + +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/production-inventory.json b/tests/docs/production-inventory.json new file mode 100644 index 000000000..9f5d6b450 --- /dev/null +++ b/tests/docs/production-inventory.json @@ -0,0 +1,7491 @@ +{ + "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": [ + { + "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" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/icons.json", + "category": "Assets" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/manifest.json", + "category": "Assets" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/quality-colours.json", + "category": "Assets" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Assets/PosterTags/vector-icons.json", + "category": "Assets" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.css", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/configPage.html", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/whats-new-seed.json", + "category": "Configuration" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/CatchUpController.cs", + "category": "Controllers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "category": "Controllers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ActivityPlaybackConsumer.cs", + "category": "EventHandlers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs", + "category": "EventHandlers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/MaintenancePlaybackReminder.cs", + "category": "EventHandlers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/SpoilerAutoEnableEvents.cs", + "category": "EventHandlers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/UserTopologyEvents.cs", + "category": "EventHandlers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/ItemRepositoryExtensions.cs", + "category": "Extensions" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/JellyfinDbContextExtensions.cs", + "category": "Extensions" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Extensions/UserManagerExtensions.cs", + "category": "Extensions" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ArrUrlGuard.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/AudioLanguageTagHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/BuildAttestation.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrPermissionHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/JellyseerrUserImportHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalRatingDecision.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/ParentalTagDecision.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrCertificationExtractor.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrHttpHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/Jellyseerr/SeerrTagSignatureExtractor.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MatroskaLanguageReader.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/MediaStreamLanguageResolver.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ProviderHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/ReviewVisibility.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/TagEpisodeSelector.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Helpers/UserHelper.cs", + "category": "Helpers" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs", + "category": "JellyfinEnhanced.cs" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Logger.cs", + "category": "Logger.cs" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AnalyticsData.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrInstance.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrItem.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Arr/ArrType.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/AwardsData.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrPermission.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/Jellyseerr/JellyseerrUser.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/MdblistRatings.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/TagCacheEntry.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Model/WhatsNewData.cs", + "category": "Model" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/ActivityPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/BookmarksPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/CalendarPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/CatchUpPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/DownloadsPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/HiddenContentPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginPages/RecommendationsPage.html", + "category": "PluginPages" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/PluginServiceRegistrator.cs", + "category": "PluginServiceRegistrator.cs" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AnalyticsReportTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ArrTagsSyncTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/AudioLanguageTagsSyncTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/BuildTagCacheTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/ClearTranslationCacheTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyfinToSeerrWatchlistSyncTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrUserImportTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/JellyseerrWatchlistSyncTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchRunner.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsFetchTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/MdblistRatingsSyncTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/RefreshCdnAssetsTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/ScheduledTasks/SpoilerApplyExistingTitlesTask.cs", + "category": "ScheduledTasks" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFavoriteMonitor.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityFeedBuilder.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ActivityService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestMonitor.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoMovieRequestService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestMonitor.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/AutoSeasonRequestService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/BrandingAssetStartupFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/CatchUpLogService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HiddenContentResponseFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/HostCompatibilityService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/Identity/RequestIdentityService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ItemStatsService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceScheduleService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/CompositeImageCache.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativeClientPolicy.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/NativePosterTagStamper.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagComposer.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagDataProvider.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagImageFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagModel.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagResolver.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagReviewRatings.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettings.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagUserCache.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagVariantToken.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagCss.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagImageScaler.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagPlan.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagRenderer.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagText.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagTileCache.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/AgeRatingRules.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/GenreIcons.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/JsText.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageFlagRules.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/LanguageNames.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/QualityTagRules.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/RatingRules.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Resolution/ResolutionData.g.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/RadarrService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ReviewAuthorCache.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/ScriptInjectionStartupFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrParentalFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SeerrScanTriggerService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SonarrService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageBlurService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/ImageTagDecoration.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableArmer.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerAutoEnableFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerExistingTitlesApplier.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerFieldStripFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerIdentityTagFilter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerLibraryAddAutoEnabler.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerNextUnwatchedService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerSeerrPendingPromoter.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerTagDataStripper.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerUserResolver.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/StartupService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheMonitor.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCachePendingChanges.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbCompanyTvDiscover.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchProvidersBatch.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WatchlistMonitor.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs", + "category": "Services" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-links.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/arr-tag-links.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-data.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-events.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-render-views.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-actions.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-data.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-cards.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render-helpers.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-render.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/requests/requests-page-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/awards/awards.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/component-scripts.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/api-client.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/dom-observer.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/lifecycle.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/media-language.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/navigation.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/session.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/tag-renderer-base.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/core/ui-kit.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-items.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-modals.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-page.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-render.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-replacements.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks-library-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-activity.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-core.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-deck.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-page.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/catchup/catchup-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/events.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/features-random-button.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/header-actions.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/helpers.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-buttons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-data.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-dialogs.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-filter.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-admin.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-cards.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-nav.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-render.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-state.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-page-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-panel.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-save.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/hiddencontent/hidden-content-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-home.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/homeremoval/features-remove-multiselect.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/icons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-media-info.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-details-page.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/itemdetails/features-release-dates.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/native-tabs.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/auto-skip.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/osd-rating.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/pausescreen.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback-rating-badge.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/playback.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/player/subtitles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-entry-points.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-hidden-content.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-language.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-settings.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-shortcut-editor.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel-template.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-panel.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/settingspanel/ui-release-notes.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/detail-button.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/dialog.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/identity.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/ids.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/image-refresh.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/index.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/seerr-toggle.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/settings-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/snooze.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/state.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/spoilerguard/watched-refresh.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/themer.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/ui-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/active-streams.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/activity-page.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-activity-icons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/colored-ratings.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/login-image.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-icons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/plugin-revisions.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/extras/theme-selector.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/api.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/collection-discovery.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-base.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/discovery-filter-utils.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/genre-discovery.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/network-discovery.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/person-discovery.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/discovery/tag-discovery.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/hss-discovery-handler.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/issue-reporter.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/item-details.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/jellyseerr.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/modal.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions-tv.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-actions.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-badges.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-data.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-render.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-seasons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/moreinfo/more-info-modal-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-catalog.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-category.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-custom-tab.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-data.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-init.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-page.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-render.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/recommendations/recommendations-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/request-manager.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seamless-scroll.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-detail-link.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/seerr-status.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-badges.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-buttons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-cards.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-icons.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-popover.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-quota.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-request-modals.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-results.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-season-modal.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/jellyseerr/ui/ui-styles.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/letterboxd-links.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/mdblist-ratings.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/others/splashscreen.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ageratingtags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/genretags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/languagetags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/peopletags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/qualitytags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/ratingtags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-cache-store.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/tag-pipeline.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/js/tags/userreviewtags.js", + "category": "js" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf10.lock.json", + "category": "packages.jf10.lock.json" + }, + { + "path": "Jellyfin.Plugin.JellyfinEnhanced/packages.jf12.lock.json", + "category": "packages.jf12.lock.json" + } + ], + "routes": [ + { + "verb": "POST", + "path": "/JellyfinEnhanced/catchup/event", + "method": "PostEvent", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/CatchUpController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/catchup/events", + "method": "GetEvents", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/CatchUpController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/catchup/summary", + "method": "GetSummary", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/CatchUpController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/status", + "method": "GetJellyseerrStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/validate", + "method": "ValidateJellyseerr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/trigger-recently-added-scan", + "method": "TriggerJellyseerrRecentlyAddedScan", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/user-status", + "method": "GetJellyseerrUserStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/search", + "method": "JellyseerrSearch", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/sonarr", + "method": "GetSonarrInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/radarr", + "method": "GetRadarrInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/sonarr/lookup/{tmdbId}", + "method": "GetSonarrLookup", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/settings/{type}", + "method": "GetJellyseerrArrSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/host-compat", + "method": "GetHostCompatibility", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyfin-urls", + "method": "GetJellyfinNetworkUrls", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/{type}/{serverId}", + "method": "GetServiceDetails", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/request", + "method": "JellyseerrRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/request", + "method": "GetJellyseerrRequests", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/quota", + "method": "GetJellyseerrQuota", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}", + "method": "GetTvShow", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/season/{seasonNumber}", + "method": "GetTvSeason", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}", + "method": "GetMovie", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/similar", + "method": "GetSimilarMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/recommendations", + "method": "GetRecommendedMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/movie/{tmdbId}/ratingscombined", + "method": "GetMovieRatingsCombined", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/similar", + "method": "GetSimilarTvShows", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/recommendations", + "method": "GetRecommendedTvShows", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/tv/{tmdbId}/ratings", + "method": "GetTvRatingsCombined", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/network/{networkId}", + "method": "DiscoverTvByNetwork", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/studio/{studioId}", + "method": "DiscoverMoviesByStudio", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/studio/{studioId}", + "method": "DiscoverTvByStudio", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/trending", + "method": "DiscoverTrending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies", + "method": "DiscoverMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv", + "method": "DiscoverTv", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/upcoming", + "method": "DiscoverUpcomingMovies", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/upcoming", + "method": "DiscoverUpcomingTv", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/studio/{studioId}", + "method": "GetStudioInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/boxset/{boxsetId}", + "method": "GetBoxSetInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/person/{personId}", + "method": "GetPersonInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/people/info", + "method": "GetPeopleInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/genre/{genreId}", + "method": "GetGenreInfo", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/person/{personId}", + "method": "GetJellyseerrPerson", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/person/{personId}/combined_credits", + "method": "GetJellyseerrPersonCredits", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/genre/{genreId}", + "method": "DiscoverTvByGenre", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/genre/{genreId}", + "method": "DiscoverMoviesByGenre", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/tv/keyword/{keywordId}", + "method": "DiscoverTvByKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/movies/keyword/{keywordId}", + "method": "DiscoverMoviesByKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/search/person", + "method": "SearchTmdbPerson", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/search/keyword", + "method": "SearchTmdbKeyword", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/genres/movie", + "method": "GetTmdbMovieGenres", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/genres/tv", + "method": "GetTmdbTvGenres", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/movie", + "method": "GetMovieGenreSlider", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/discover/genreslider/tv", + "method": "GetTvGenreSlider", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/overrideRule", + "method": "GetOverrideRules", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/collection/{collectionId}", + "method": "GetCollection", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/user", + "method": "GetJellyseerrUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/request/tv/{tmdbId}/seasons", + "method": "RequestTvSeasons", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/watchlist", + "method": "GetJellyseerrWatchlist", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/sync-watchlist", + "method": "SyncJellyseerrWatchlist", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/import-users", + "method": "ImportJellyseerrUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/settings/partial-requests", + "method": "GetJellyseerrPartialRequestsSetting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/validate", + "method": "ValidateTmdb", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/script", + "method": "GetMainScript", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/js/{**path}", + "method": "GetScript", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/Configuration/configPage.css", + "method": "GetConfigPageStylesheet", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/version", + "method": "GetVersion", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/private-config", + "method": "GetPrivateConfig", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)", + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/public-config", + "method": "GetPublicConfig", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bootstrap", + "method": "GetBootstrap", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bundle.js", + "method": "GetScriptBundle", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/bundle.js.map", + "method": "GetScriptBundleMap", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tmdb/{**apiPath}", + "method": "ProxyTmdbRequest", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/watch-providers", + "method": "GetWatchProvidersBatch", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/usage/track", + "method": "TrackUsage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/usage/preview", + "method": "PreviewUsageReport", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/audio-language-tags", + "method": "GetAudioLanguageTags", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/awards/{mediaType}/{tmdbId}", + "method": "GetAwards", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/mdblist-ratings/{mediaType}/{tmdbId}", + "method": "GetMdblistRatings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/mdblist-ratings/account-status", + "method": "GetMdblistAccountStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/locales", + "method": "GetAvailableLocales", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "ResponseCache(Duration = 86400)" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/cdn/{source}/{**path}", + "method": "GetCdnAsset", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/fonts/{name}", + "method": "GetBundledFont", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/locales/{lang}.json", + "method": "GetLocale", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/settings.json", + "method": "GetUserSettingsSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/shortcuts.json", + "method": "SaveUserSettingsShortcuts", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/bookmark.json", + "method": "GetUserBookmark", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/user-settings/{userId}/elsewhere.json", + "method": "SaveUserSettingsElsewhere", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/user-settings/{userId}/spoilerblur.json", + "method": "GetUserSpoilerBlur", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}", + "method": "GetUserHiddenContentForAdmin", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/unhide", + "method": "AdminUnhideForUser", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/admin/hidden-content/{userId}/hide", + "method": "AdminHideForUser", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/health", + "method": "GetSpoilerBlurHealth", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/health/{targetUserId}", + "method": "AckSpoilerBlurCorruption", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/preview", + "method": "PreviewSpoilerApplyExisting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing", + "method": "StartSpoilerApplyExisting", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/spoiler-blur/apply-existing/status", + "method": "GetSpoilerApplyExistingStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/user-prefs", + "method": "SetSpoilerBlurUserPrefs", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/series/{seriesId}", + "method": "DisableSpoilerBlurForSeries", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/movies/{movieId}", + "method": "EnableSpoilerBlurForMovie", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/collections/{collectionId}", + "method": "EnableSpoilerBlurForCollection", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", + "method": "EnableSpoilerBlurPending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/spoiler-blur/pending/{mediaType}/{tmdbId}", + "method": "DisableSpoilerBlurPending", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", + "method": "HideFromContinueWatching", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/next-up/hide/{itemId}", + "method": "HideFromNextUp", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/continue-watching/hide/{itemId}", + "method": "UnhideFromContinueWatching", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/next-up/hide/{itemId}", + "method": "UnhideFromNextUp", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "GetItemReviews", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/ratings", + "method": "GetReviewRatings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "UpsertReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", + "method": "AdminUpsertReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/reviews/{mediaType}/{tmdbId}", + "method": "DeleteReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/reviews/admin/all", + "method": "AdminListReviews", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "DELETE", + "path": "/JellyfinEnhanced/reviews/admin/{userIdN}/{mediaType}/{tmdbId}", + "method": "AdminDeleteReview", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/reset-all-users-settings", + "method": "ResetAllUsersSettings", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/tag-cache/rebuild", + "method": "RebuildTagCache", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/tag-cache/{userId}", + "method": "GetTagCache", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/activity", + "method": "GetActivityFeed", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/whats-new/dismiss", + "method": "DismissWhatsNew", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/tag-data/{userId}", + "method": "GetTagData", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/item-stats/{userId}/{itemId}", + "method": "GetItemStatsByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/native-poster-tags/preview/{itemId}", + "method": "PreviewNativePosterTags", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/file-size/{userId}/{itemId}", + "method": "GetFileSizeByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/watch-progress/{userId}/{itemId}", + "method": "GetWatchProgressByItemId", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize", + "Produces(\"application/json\")" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/issue", + "method": "GetJellyseerrIssues", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/jellyseerr/issue/{id}", + "method": "GetJellyseerrIssueById", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/jellyseerr/issue", + "method": "ReportJellyseerrIssue", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/UploadBrandingImage", + "method": "UploadBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/BrandingStatus", + "method": "GetBrandingStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/BrandingImage", + "method": "GetBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/DeleteBrandingImage", + "method": "DeleteBrandingImage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/validate/sonarr", + "method": "ValidateSonarr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/validate/radarr", + "method": "ValidateRadarr", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/identify-url", + "method": "IdentifyUrl", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/series-slug", + "method": "GetSeriesSlug", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/series-slugs", + "method": "GetSeriesSlugs", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/links", + "method": "GetArrLinks", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/movie-instances", + "method": "GetMovieInstances", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/active-streams/sessions", + "method": "GetActiveSessions", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/active-streams/broadcast", + "method": "BroadcastMessage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/queue", + "method": "GetDownloadQueue", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/history", + "method": "GetDownloadHistory", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/requests", + "method": "GetRequests", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "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", + "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", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/arr/calendar", + "method": "GetCalendarEvents", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/arr/calendar/user-data", + "method": "GetCalendarUserDataForEvents", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/proxy/avatar", + "method": "ProxyAvatar", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [ + "Authorize" + ] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/items/by-providers", + "method": "GetItemIdByProviders", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/MaintenanceMode/Status", + "method": "GetMaintenanceStatus", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Enable", + "method": "EnableMaintenanceMode", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Disable", + "method": "DisableMaintenanceMode", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/MaintenanceMode/Users", + "method": "GetMaintenanceModeUsers", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "POST", + "path": "/JellyfinEnhanced/MaintenanceMode/Broadcast", + "method": "BroadcastMaintenanceMessage", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + }, + { + "verb": "GET", + "path": "/JellyfinEnhanced/{viewName}", + "method": "GetView", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs", + "following_attributes": [] + } + ], + "configuration_properties": [ + { + "class": "Shortcut", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "Shortcut", + "name": "Key", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "Shortcut", + "name": "Label", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "Shortcut", + "name": "Category", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "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" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeNotificationMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"Server undergoing maintenance.\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeAction", + "type": "string", + "initializer": "\"disable_accounts\"", + "constructor_assignment": "\"disable_accounts\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeAffectedUsers", + "type": "string", + "initializer": "\"all\"", + "constructor_assignment": "\"all\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeDurationMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceModeRemindOnPlayback", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleStart", + "type": "string", + "initializer": "\"00:00\"", + "constructor_assignment": "\"00:00\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleEnd", + "type": "string", + "initializer": "\"08:00\"", + "constructor_assignment": "\"08:00\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"The library is updating", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleNotificationMessage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"The library is updating", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MaintenanceScheduleAction", + "type": "string", + "initializer": "\"none\"", + "constructor_assignment": "\"none\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ToastDuration", + "type": "int", + "initializer": null, + "constructor_assignment": "1500", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HelpPanelAutocloseDelay", + "type": "int", + "initializer": null, + "constructor_assignment": "15000", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "EnableCustomSplashScreen", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SplashScreenImageUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"/web/assets/img/banner-light.png\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DevMode", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DisableScriptInjectionMiddleware", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DisableBrandingMiddleware", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TMDB_API_KEY", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DEFAULT_REGION", + "type": "string", + "initializer": null, + "constructor_assignment": "\"US\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DEFAULT_PROVIDERS", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "IGNORE_PROVIDERS", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereCustomBrandingText", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ElsewhereCustomBrandingImageUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ClearLocalStorageTimestamp", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ClearTranslationCacheTimestamp", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoPauseEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoResumeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoPipEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoSkipIntro", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoSkipOutro", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LongPress2xEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RandomButtonEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RandomIncludeMovies", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RandomIncludeShows", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RandomUnwatchedOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RandomScopeCurrentContainer", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowWatchProgress", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "WatchProgressDefaultMode", + "type": "string", + "initializer": null, + "constructor_assignment": "\"percentage\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "WatchProgressTimeFormat", + "type": "string", + "initializer": null, + "constructor_assignment": "\"hours\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowFileSizes", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RemoveContinueWatchingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowAudioLanguages", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowReviews", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowUserReviews", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ReviewsExpandedByDefault", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HideReviewsFromHiddenUsers", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HideReviewsFromDisabledUsers", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MirrorReviewRatingsToJellyfin", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowAwards", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistApiKey", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsShowOnItemDetails", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsFetchEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsAutoSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsOverwriteExisting", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistFetchReserve", + "type": "int", + "initializer": "400", + "constructor_assignment": "400", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsSources", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MdblistRatingsShowPercentSymbol", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowReleaseDates", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowUserRatingOnPosters", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowUserRatingDash", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "Shortcuts", + "type": "List", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "PauseScreenEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowPlaybackRatingBadge", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "PauseScreenDelaySeconds", + "type": "int", + "initializer": "5", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowResolutionTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowSourceTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowDynamicRangeTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowSpecialFormatTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowVideoCodecTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowAudioInfoTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ResolutionTagOrder", + "type": "int", + "initializer": "1", + "constructor_assignment": "1", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SourceTagOrder", + "type": "int", + "initializer": "2", + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DynamicRangeTagOrder", + "type": "int", + "initializer": "3", + "constructor_assignment": "3", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpecialFormatTagOrder", + "type": "int", + "initializer": "4", + "constructor_assignment": "4", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "VideoCodecTagOrder", + "type": "int", + "initializer": "5", + "constructor_assignment": "5", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AudioInfoTagOrder", + "type": "int", + "initializer": "6", + "constructor_assignment": "6", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AgeRatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "PeopleTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "NativePosterTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "NativePosterTagsWebClientNames", + "type": "string", + "initializer": null, + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TagsCacheTtlDays", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DisableTagsOnSearchPage", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TagsHideOnHover", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TagCacheServerMode", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TagCacheExcludeLibraries", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TagCacheExcludedLibraryIds", + "type": "string", + "initializer": "\"\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "EnableTagsLocalStorageFallback", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DisableAllShortcuts", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleStyle", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleSize", + "type": "int", + "initializer": null, + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleFont", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DefaultSubtitleTextEffect", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DisableCustomSubtitleStyles", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsPosition", + "type": "string", + "initializer": "\"top-left\"", + "constructor_assignment": "\"top-left\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "GenreTagsPosition", + "type": "string", + "initializer": "\"top-right\"", + "constructor_assignment": "\"top-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPosition", + "type": "string", + "initializer": "\"bottom-left\"", + "constructor_assignment": "\"bottom-left\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPriority", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LanguageTagsPriorityStrict", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsPreferredAudioLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "QualityTagsAudioLanguageFromUser", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": "\"bottom-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnSeasons", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnEpisodes", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RatingTagsOnNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AgeRatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": "\"bottom-right\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowRatingInPlayer", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "GenreTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DefaultLanguage", + "type": "string", + "initializer": null, + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowSearchResults", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrSearchExcludeLibraryItems", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowReportButton", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowIssueIndicator", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnable4KRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrEnable4KTvRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowAdvanced", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowQuotaInfo", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowSimilar", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowRecommended", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowRequestMoreOnSeries", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowNetworkDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowGenreDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowTagDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowPersonDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowCollectionDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowDetailPageLink", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrShowDetailPageLinkAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrExcludeLibraryItems", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrExcludeBlocklistedItems", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowElsewhereOnJellyseerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUseMoreInfoModal", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrAvailablePosterLinksToJellyfin", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUrls", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "TriggerSeerrScanOnItemAdded", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SeerrScanDebounceSeconds", + "type": "int", + "initializer": "60", + "constructor_assignment": "60", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowCollectionsInSearch", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrDisableCache", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrResponseCacheTtlMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "10", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrUserIdCacheTtlMinutes", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrLinksEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SonarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RadarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BazarrUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowArrLinksAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrLinksShowStatusSingle", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SonarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RadarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BazarrUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SonarrInstances", + "type": "string", + "initializer": "\"[]\"", + "constructor_assignment": "\"[]\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RadarrInstances", + "type": "string", + "initializer": "\"[]\"", + "constructor_assignment": "\"[]\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoUrl", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoUrlMappings", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowEpisodes", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowSpecials", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowCredits", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowTrailers", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowParodies", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShokoShowOther", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SonarrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RadarrApiKey", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsPrefix", + "type": "string", + "initializer": null, + "constructor_assignment": "\"JE Arr Tag: \"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsClearOldTags", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsShowAsLinks", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsLinksFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsLinksHideFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ArrTagsSyncFilter", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagSyncEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagPrefix", + "type": "string", + "initializer": null, + "constructor_assignment": "\"JE Language: \"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AudioLanguageTagShowAsLinks", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "LetterboxdEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowLetterboxdLinkAsText", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "MetadataIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestThresholdValue", + "type": "int", + "initializer": null, + "constructor_assignment": "2", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoSeasonRequestRequireAllWatched", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestTriggerType", + "type": "string", + "initializer": null, + "constructor_assignment": "\"OnMinutesWatched\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestMinutesWatched", + "type": "int", + "initializer": null, + "constructor_assignment": "20", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCheckReleaseDate", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestQualityMode", + "type": "string", + "initializer": null, + "constructor_assignment": "\"default\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomServerId", + "type": "int", + "initializer": null, + "constructor_assignment": "-1", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomProfileId", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestCustomRootFolder", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AutoMovieRequestFallbackOn4k", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AddRequestedMediaToWatchlist", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SyncJellyseerrWatchlist", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SyncJellyfinWatchlistToSeerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "PreventWatchlistReAddition", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "WatchlistMemoryRetentionDays", + "type": "int", + "initializer": null, + "constructor_assignment": "365", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrAutoImportUsers", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "JellyseerrImportBlockedUsers", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "BookmarksCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "UseIcons", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "IconStyle", + "type": "string", + "initializer": null, + "constructor_assignment": "\"emoji\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ColoredRatingsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ThemeSelectorEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ColoredActivityIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "PluginIconsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "EnableLoginImage", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CustomPluginLinks", + "type": "string", + "initializer": null, + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActiveStreamsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActiveStreamsAllUsers", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowWatched", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowFavorited", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowReviewed", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedShowActiveStreams", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ActivityFeedCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPagePollingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPollIntervalSeconds", + "type": "int", + "initializer": null, + "constructor_assignment": "30", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsPageShowIssues", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "ShowDownloadsInRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsFilterByUserRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsShowHistory", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "DownloadsHistoryAdminOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarFirstDayOfWeek", + "type": "string", + "initializer": null, + "constructor_assignment": "\"Monday\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarTimeFormat", + "type": "string", + "initializer": null, + "constructor_assignment": "\"5pm/5:30pm\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarHighlightFavorites", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarHighlightWatchedSeries", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarFilterByLibraryAccess", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarShowOnlyRequested", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CalendarForceOnlyRequested", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsPageEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "RecommendationsCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentAdmin", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpUsePluginPages", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpUseNativeTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpUseCustomTabs", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpAutoCreateCustomTab", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpCustomTabJeOwned", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "CatchUpLogEnabled", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowHideButtons", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowHideConfirmation", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonJellyseerr", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonLibrary", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonDetails", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultShowButtonCast", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterLibrary", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterDiscovery", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterSearch", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterCalendar", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterUpcoming", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterRecommendations", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterRequests", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterNextUp", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultFilterContinueWatching", + "type": "bool", + "initializer": null, + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "HiddenContentDefaultExperimentalHideCollections", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurEnabled", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurIntensity", + "type": "int", + "initializer": "40", + "constructor_assignment": "40", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripSeriesOverview", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripTags", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripChapters", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripTaglines", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripPremiereDate", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerReplaceTitle", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripCast", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripCastMode", + "type": "string", + "initializer": "\"GuestStars\"", + "constructor_assignment": "\"GuestStars\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerStripReviews", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurMode", + "type": "string", + "initializer": "\"hide\"", + "constructor_assignment": "\"hide\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurArtwork", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnFirstPlay", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnSeerrRequest", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableOnLibraryAdd", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAutoEnableLibraryIds", + "type": "string", + "initializer": "\"\"", + "constructor_assignment": "\"\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerBlurStrictRefresh", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerKeepMoviePosters", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerAdvancedMode", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripTitle", + "type": "bool", + "initializer": "false", + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerNextEpisodeStripImage", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripTitle", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripOverview", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerCurrentSeasonStripRatings", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "SpoilerOverviewPlaceholder", + "type": "string", + "initializer": "\"Spoiler Guard activated\"", + "constructor_assignment": "\"Spoiler Guard activated\"", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareFeatureFlags", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareUsageCounts", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsShareDataSizes", + "type": "bool", + "initializer": "true", + "constructor_assignment": "true", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsHasBeenConfigured", + "type": "bool", + "initializer": null, + "constructor_assignment": "false", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsInstallId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsInstallSecret", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsReportIntervalDays", + "type": "int", + "initializer": "15", + "constructor_assignment": "15", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedAt", + "type": "long", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastPayloadJson", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedPluginVersion", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedJellyfinTarget", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsLastReportedJellyfinVersion", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": "string.Empty", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "PluginConfiguration", + "name": "AnalyticsForbiddenSinceLastSuccess", + "type": "int", + "initializer": null, + "constructor_assignment": "0", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/PluginConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AutoPauseEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AutoResumeEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AutoPipEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "LongPress2xEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "PauseScreenEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "PauseScreenDelaySeconds", + "type": "int", + "initializer": "5", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AutoSkipIntro", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AutoSkipOutro", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "DisableCustomSubtitleStyles", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SelectedStylePresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SelectedFontSizePresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SelectedFontFamilyPresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SelectedTextEffectPresetIndex", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "CustomSubtitleTextColor", + "type": "string", + "initializer": "\"#FFFFFFFF\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "CustomSubtitleBgColor", + "type": "string", + "initializer": "\"#00000000\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "UsingCustomColors", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SubtitleVerticalPosition", + "type": "int", + "initializer": "85", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SubtitleHorizontalPosition", + "type": "int", + "initializer": "50", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomButtonEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomUnwatchedOnly", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomIncludeMovies", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomIncludeShows", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomScopeCurrentContainer", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RandomSourceId", + "type": "string", + "initializer": "\"\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowWatchProgress", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "WatchProgressMode", + "type": "string", + "initializer": "\"percentage\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "WatchProgressTimeFormat", + "type": "string", + "initializer": "\"hours\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowFileSizes", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowAudioLanguages", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "UseNativePosterTags", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "QualityTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowResolutionTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowSourceTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowDynamicRangeTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowSpecialFormatTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowVideoCodecTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowAudioInfoTag", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ResolutionTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SourceTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "DynamicRangeTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "SpecialFormatTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "VideoCodecTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AudioInfoTagOrder", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "GenreTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "LanguageTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AgeRatingTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "PeopleTagsEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "TagsHideOnHover", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "QualityTagsPosition", + "type": "string", + "initializer": "\"top-left\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "QualityTagsPreferredAudioLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "GenreTagsPosition", + "type": "string", + "initializer": "\"top-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "LanguageTagsPosition", + "type": "string", + "initializer": "\"bottom-left\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnMovies", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnSeries", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnSeasons", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnEpisodes", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RatingTagsOnNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "AgeRatingTagsPosition", + "type": "string", + "initializer": "\"bottom-right\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ShowRatingInPlayer", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "RemoveContinueWatchingEnabled", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "LastOpenedTab", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "ReviewsExpandedByDefault", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "DisplayLanguage", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "CalendarDisplayMode", + "type": "string", + "initializer": "\"list\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSettings", + "name": "CalendarDefaultViewMode", + "type": "string", + "initializer": "\"agenda\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "SeriesId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "SeriesName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurSeriesEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "MovieId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "MovieName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurMovieEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "CollectionId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "CollectionName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurCollectionEntry", + "name": "EnabledAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "DisplayName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurPendingEntry", + "name": "RequestedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideSeriesDescriptions", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideEpisodeDescriptions", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideTags", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideChapterNames", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideTaglines", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideRatings", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideAirDate", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "ReplaceEpisodeTitles", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideCast", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "HideReviews", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "UseAdvancedCategories", + "type": "bool?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "SpoilerBlurUserPrefs", + "name": "SkipDisableConfirm", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserSpoilerBlur", + "name": "Prefs", + "type": "SpoilerBlurUserPrefs", + "initializer": "new SpoilerBlurUserPrefs()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserShortcuts", + "name": "Shortcuts", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "TvdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "Timestamp", + "type": "double", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "Label", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "CreatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "UpdatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "SyncedFrom", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "SeasonNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "BookmarkItem", + "name": "EpisodeNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserBookmark", + "name": "Bookmarks", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ElsewhereSettings", + "name": "Region", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ElsewhereSettings", + "name": "Regions", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ElsewhereSettings", + "name": "Services", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "PendingWatchlistItem", + "name": "TmdbId", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "PendingWatchlistItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "PendingWatchlistItem", + "name": "RequestedAt", + "type": "System.DateTime", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "PendingWatchlistItems", + "name": "Items", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ProcessedWatchlistItem", + "name": "TmdbId", + "type": "int", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ProcessedWatchlistItem", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ProcessedWatchlistItem", + "name": "ProcessedAt", + "type": "System.DateTime", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ProcessedWatchlistItem", + "name": "Source", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ProcessedWatchlistItems", + "name": "Items", + "type": "List", + "initializer": "new List()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "Name", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "Type", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "HiddenAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "PosterPath", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "SeriesId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "SeriesName", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "SeasonNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "EpisodeNumber", + "type": "int?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentItem", + "name": "HideScope", + "type": "string", + "initializer": "\"global\"", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "Enabled", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterLibrary", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterDiscovery", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterUpcoming", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterCalendar", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterSearch", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterRecommendations", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterRequests", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterNextUp", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "FilterContinueWatching", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowHideButtons", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowHideConfirmation", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonJellyseerr", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonLibrary", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonDetails", + "type": "bool", + "initializer": "true", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ShowButtonCast", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "HiddenContentSettings", + "name": "ExperimentalHideCollections", + "type": "bool", + "initializer": "false", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserHiddenContent", + "name": "Items", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserHiddenContent", + "name": "Settings", + "type": "HiddenContentSettings", + "initializer": "new HiddenContentSettings()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "UserId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "TmdbId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "MediaType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "Content", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "Rating", + "type": "double?", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "CreatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "UserReview", + "name": "UpdatedAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "AllReviewsStore", + "name": "Reviews", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "UserId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "ItemId", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "ActivityType", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "OccurredAt", + "type": "string", + "initializer": "string.Empty", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "Completed", + "type": "bool", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "ActivityEntry", + "name": "Progress", + "type": "double", + "initializer": null, + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + }, + { + "class": "AllActivityStore", + "name": "Entries", + "type": "Dictionary", + "initializer": "new Dictionary()", + "constructor_assignment": null, + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfiguration.cs" + } + ], + "storage_references": [ + { + "literal": "activity.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs" + }, + { + "literal": "processed-watchlist-items.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Configuration/UserConfigurationManager.cs" + }, + { + "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" + }, + { + "literal": "Jellyfin.Plugin.JellyfinEnhanced.js.locales.{sanitizedLang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "Saved enhanced bookmarks for {ResolveUserDisplay(authorizedUserId)} to bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "Saved hidden content for {ResolveUserDisplay(authorizedUserId)} to hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "Saved user elsewhere settings for {ResolveUserDisplay(authorizedUserId)} to elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "Saved user shortcuts for {ResolveUserDisplay(authorizedUserId)} to shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "locales/{lang}.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "user-settings/{userId}/bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "user-settings/{userId}/elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "user-settings/{userId}/hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "literal": "user-settings/{userId}/settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Controllers/JellyfinEnhancedController.cs" + }, + { + "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" + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/EventHandlers/ContinueWatchingPlaybackEvents.cs" + }, + { + "literal": "config.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.cs" + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs" + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/AnalyticsReportingService.cs" + }, + { + "literal": ".meta.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs" + }, + { + "literal": ".meta.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/CdnAssetService.cs" + }, + { + "literal": "component-scripts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/ClientScriptBundle.cs" + }, + { + "literal": "hidden-content.json", + "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" + }, + { + "literal": "maintenance-state.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MaintenanceModeService.cs" + }, + { + "literal": "mdblist-ratings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/MdblistService.cs" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/PosterTagSettingsProvider.cs" + }, + { + "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" + }, + { + "literal": "quality-colours.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" + }, + { + "literal": "vector-icons.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/PosterTags/Rendering/PosterTagAssets.cs" + }, + { + "literal": "spoilerblur.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/SpoilerGuard/SpoilerBlurImageFilter.cs" + }, + { + "literal": "tag-cache.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TagCacheService.cs" + }, + { + "literal": "tmdb-cache.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/TmdbResponseCache.cs" + }, + { + "literal": "usage-counters.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/UsageEventCounterService.cs" + }, + { + "literal": "Jellyfin.Plugin.JellyfinEnhanced.Configuration.whats-new-seed.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" + }, + { + "literal": "config-schema-snapshot.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" + }, + { + "literal": "whats-new.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WhatsNewService.cs" + }, + { + "literal": "awards.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/WikidataAwardsService.cs" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/arr/calendar/calendar-page-actions.js" + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/elsewhere.js" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/elsewhere/reviews.js" + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/bookmarks/bookmarks.js" + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/config.js" + }, + { + "literal": "settings.json", + "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" + }, + { + "literal": "settings.json", + "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" + }, + { + "literal": "shortcuts.json", + "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" + }, + { + "literal": "/JellyfinEnhanced/locales/en.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/enhanced/translations.js" + }, + { + "literal": "bookmark.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" + }, + { + "literal": "elsewhere.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" + }, + { + "literal": "hidden-content.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" + }, + { + "literal": "settings.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" + }, + { + "literal": "shortcuts.json", + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/plugin.js" + } + ], + "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" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/Services/StartupService.cs", + "class": "StartupService", + "key": "JellyfinEnhancedStartup" + } + ], + "locales": [ + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ar.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/bg.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ca.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/cs.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/da.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/de.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-GB.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en-US.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/en.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/es.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/et.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fa.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fi.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/fr.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/gsw.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/he.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/hu.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/it.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ko.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ml.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/nl.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/no.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pl.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pr.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt-BR.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/pt.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/ru.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sk.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/sv.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/tr.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/uk.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-CN.json" + }, + { + "file": "Jellyfin.Plugin.JellyfinEnhanced/js/locales/zh-HK.json" + } + ], + "counts": { + "build_targets": 2, + "files": 348, + "routes": 170, + "configuration_properties": 510, + "storage_references": 63, + "scheduled_tasks": 13, + "locales": 33 + } +} diff --git a/tests/docs/validation.md b/tests/docs/validation.md new file mode 100644 index 000000000..0e5bf0d7b --- /dev/null +++ b/tests/docs/validation.md @@ -0,0 +1,50 @@ +# Validation + +What the regression suite runs and what the latest complete local run produced. Install the prerequisites in the [setup guide](README.md#setup) first; every command runs from the repository root. + +## Commands + +```sh +dotnet build Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj -c Release -p:JellyfinTarget=jf12 +dotnet build Jellyfin.Plugin.JellyfinEnhanced/JellyfinEnhanced.csproj -c Release -p:JellyfinTarget=jf10 +python3 tests/run.py fast +python3 tests/run.py browser +python3 tests/runner/poster.py +python3 tests/run.py mutation --target jf12 +python3 tests/run.py history +python3 tests/run.py host --target jf10 +python3 tests/run.py host --target jf12 +``` + +`python3 tests/run.py all` runs the same stages in one go. Each stage writes its command, exit status, duration and log path to `artifacts/regression/summary.json`. + +The culture and time-zone row reruns `python3 tests/run.py backend` once with `LANG=fa_IR.UTF-8 LC_ALL=fa_IR.UTF-8` and once with `TZ=America/Los_Angeles` set. The Node 22.14.0 results come from `python3 tests/run.py frontend` with that Node first on `PATH`. + +## Latest results + +Every stage below ran once at commit `b8179d9e` on 2026-10-08, except the real-host row, which was rerun at `8e64c47c` after the host suite switched to the newest release of each line. All passed with no failed or skipped tests. + +| Stage | Result | +| --- | --- | +| Plugin build, jf12 (.NET 10) and jf10 (.NET 9) | 0 warnings, 0 errors | +| Inventory freshness | Current | +| Tooling checks (false-green guards and the inventory generator) | 10 passed | +| Backend, Jellyfin 10.11 / .NET 9 | 804 passed | +| Backend, Jellyfin 12 / .NET 10 | 804 passed | +| Backend coverage | jf10 50.28% lines / 38.36% branches; jf12 50.29% / 38.39% (floors 47% / 35%) | +| Backend under the fa-IR culture and under the America/Los_Angeles time zone | 804 passed on each target in each run | +| Frontend, instrumented production modules | 413 passed (Node 26.2.0 and the CI's Node 22.14.0) | +| Frontend coverage | 31.84% statements, 23.78% branches, 31.94% functions, 33.70% lines on Node 26.2.0; 31.86% / 23.79% / 31.99% / 33.71% on Node 22.14.0 (floors 28% / 20% / 29% / 30%) | +| Browser, Chromium desktop/mobile and Firefox | 39 passed, including 3 unchanged visual baselines | +| Native poster pipeline | 140 checks passed | +| JavaScript/C# poster parity | 39 setting profiles × 101 fixtures matched, plus one empty-profile check; 115,960 language names and 31,676 casing strings matched (Node 26.2.0, ICU 78.3) | +| Native assets | 253 flags decoded and HarfBuzz shaping passed on Skia 3.116.1 and 3.119.4 | +| Mutation checks, jf12 | 4 of 4 faults detected, with passing baseline and restored runs | +| Historical fault replay | 13 historical faults (9 JavaScript, 4 backend on each target): all 17 experiments detected the fault and passed again once restored | +| Real Jellyfin hosts | Jellyfin 10.11.11 and 12.2.0 (the newest `10.11` and `12` images) each passed all 46 report entries: 45 HTTP checks plus one real-Chromium journey (login, panel save and reload) covering a regular and an admin user | + +Coverage counts every production module, including ones no test loads. It guards against regressions; it is not a measure of behavioral completeness. The [coverage matrix](coverage-matrix.md) lists what is tested and what is not. + +## Not covered by these runs + +Native clients, complete playback on a media-playing host, every page's layout and accessibility, and the feature combinations listed as gaps in the coverage matrix. Maintenance recovery's cross-store limits are described in [maintenance recovery](maintenance-recovery.md). diff --git a/tests/frontend/README.md b/tests/frontend/README.md new file mode 100644 index 000000000..4a6b449c6 --- /dev/null +++ b/tests/frontend/README.md @@ -0,0 +1,49 @@ +# 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 28% statements, 30% lines, 20% branches, and 29% +functions across the whole production inventory (just under the measured +values recorded in [validation](../docs/validation.md)); 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. + +Unexpected console errors fail teardown; negative tests must explicitly allow +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 pass `--test-timeout=120000`, which +Node 22 (used in CI) applies to each test file and newer Node to each test. + +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..425026bb9 --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-header.test.mjs @@ -0,0 +1,90 @@ +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(); }, login() { signedIn = true; } }; +} + +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')); + // The next user reuses the tray element: it must come back holding only the launcher. + h.login(); + const next = h.JE.headerActions.getTray(); + assert.equal(next, h.tray); + for (const id of ['randomItemButton', 'calendar', 'requests', 'bookmarks']) assert.equal(next.querySelector(`#${id}`), null, id); + assert.deepEqual([...next.children].map(child => child.id), ['je-header-launcher']); +}); + +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-manifest.test.mjs b/tests/frontend/bootstrap/bootstrap-manifest.test.mjs new file mode 100644 index 000000000..9ce4a5cba --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-manifest.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { createHarness, jsRoot } from '../helpers/harness.mjs'; + +// @babel/parser ships with the istanbul-lib-instrument devDependency; resolve it through that package. +const { parse } = createRequire(createRequire(import.meta.url).resolve('istanbul-lib-instrument'))('@babel/parser'); +const manifestFiles = () => JSON.parse(readFileSync(resolve(jsRoot, 'component-scripts.json'), 'utf8')).filter(entry => !entry.startsWith('//')); + +/** + * Dev mode and the fallback loader run each module as its own classic script, unwrapped, so its + * top level must be exactly one IIFE: no directive, declaration or other statement there. + * @param {string} source The module's source. + * @param {string} filename Its path, for error messages. + * @returns {string|null} What breaks the rule, or null. + */ +function unwrappedProblem(source, filename) { + try { new vm.Script(source, { filename }); } catch (error) { return `does not compile as a classic script: ${error.message}`; } + const { program } = parse(source, { sourceType: 'script' }); + if (program.directives.length) return `has a top-level ${program.directives[0].value.value} directive`; + const [statement, ...rest] = program.body; + if (rest.length || statement?.type !== 'ExpressionStatement') return `top level is ${program.body.map(node => node.type).join(', ') || 'empty'}, not one IIFE`; + const call = statement.expression; + if (call.type !== 'CallExpression' || !['FunctionExpression', 'ArrowFunctionExpression'].includes(call.callee.type)) return `top-level expression is ${call.type}, not an IIFE`; + return null; +} + +// 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 = manifestFiles(); + 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}`); } + } + // Let deferred startup work (timers, promise chains) run inside the window, so an error it + // throws is reported by close() instead of being lost when the realm is torn down. + await new Promise(done => setTimeout(done, 300)); + assert.deepEqual(failures, []); +}); + +test('every manifest module is a single top-level IIFE when loaded unwrapped', () => { + const problems = manifestFiles().map(path => [path, unwrappedProblem(readFileSync(resolve(jsRoot, path), 'utf8'), path)]).filter(([, problem]) => problem); + assert.deepEqual(problems, []); +}); 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..914252ac2 --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap-shortcuts.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, loadKeyCombo, plain } 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()); + loadKeyCombo(h); 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); +}); + +// A page reload rebuilds the bindings from the plugin defaults and the user's saved shortcuts.json. +for (const [label, press, expected] of [['rebinding', 'q', 'Q'], ['disabling', 'Delete', '']]) { + test(`shortcut ${label} saved by the editor overrides the plugin default after a reload`, t => { + const h = setup(t); h.key.focus(); h.press(h.key, press); + const [file, saved] = h.saves.at(-1); assert.equal(file, 'shortcuts.json'); + const reloaded = createHarness({ JE: { pluginConfig: { Shortcuts: [{ Name: 'CycleAudioTracks', Key: 'A' }, { Name: 'GoToHome', Key: 'H' }] }, userConfig: { shortcuts: plain(saved) } } }); + t.after(() => reloaded.close()); + reloaded.load('enhanced/config.js'); reloaded.JE.initializeShortcuts(); + assert.equal(reloaded.JE.state.activeShortcuts.CycleAudioTracks, expected); + assert.equal(reloaded.JE.state.activeShortcuts.GoToHome, 'H', 'bindings the user never changed keep the plugin default'); + }); +} + +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']); +}); + +// 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']); + }); +} + +// The conflict check must see a stored binding the key listener would fire for the same press. +for (const stored of ['Shift+Ctrl+S', 'Ctrl+Shift+s']) { + test(`shortcut editor rejects a combo that collides with stored ${stored}`, t => { + const h = setup(t); h.JE.state.activeShortcuts.CycleSubtitleTracks = stored; + h.key.focus(); h.press(h.key, 'S', { ctrlKey: true, shiftKey: true }); + assert.equal(h.JE.state.activeShortcuts.CycleAudioTracks, 'A'); assert.equal(h.saves.length, 0); + assert.ok(h.key.classList.contains('shake-error')); + }); +} + +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..c4583496b --- /dev/null +++ b/tests/frontend/bootstrap/bootstrap.test.mjs @@ -0,0 +1,238 @@ +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('after a switch, a bootstrap answered for another user is replaced by the new user\'s own data', async t => { + // A misrouted or cached bootstrap still names the previous user: reload per endpoint instead. + let current = 'user-a'; + const h = setup(t, { ajax: async path => { + if (path.endsWith('/bootstrap')) return payload({ UserId: 'user-a', PrivateConfig: { SonarrUrl: 'secret' }, UserSettings: { Bookmark: { Bookmarks: { [current === 'user-a' ? 'user-a' : 'stolen']: {} } } } }); + if (path === '/JellyfinEnhanced/user-settings/user-b/bookmark.json') return { Bookmarks: { 'user-b': { Name: 'user-b' } } }; + if (path.startsWith('/JellyfinEnhanced/user-settings/user-b/') || path.endsWith('/private-config')) return {}; + throw new Error(`Unexpected endpoint ${path}`); + } }); + 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 })); + current = 'user-b'; h.switchUser('user-b'); + await loaded; + assert.equal(h.plugin.pluginConfig.SonarrUrl, undefined); + assert.deepEqual(plain(h.plugin.userConfig.bookmark.bookmarks), { 'user-b': { name: 'user-b' } }); + assert.ok(h.calls.includes('/JellyfinEnhanced/user-settings/user-b/bookmark.json')); +}); + +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' } }); +}); + +// The per-endpoint fallback (a bootstrap naming another user) has its own late-response guards. +for (const late of ['private-config', 'settings']) test(`per-endpoint fallback drops a late ${late} response from the previous identity`, async t => { + const stale = deferred(); + let bWaiting = false; + const h = setup(t, { ajax: async path => { + if (path.endsWith('/bootstrap')) return payload({ UserId: 'user-a' }); + if (path.endsWith('/private-config')) { + if (late === 'private-config' && h.plugin.session.getUserId() === 'user-b') { bWaiting = true; return stale.promise; } + return {}; + } + if (path === '/JellyfinEnhanced/user-settings/user-b/bookmark.json' && late === 'settings') { bWaiting = true; return stale.promise; } + if (path === '/JellyfinEnhanced/user-settings/user-c/bookmark.json') return { Bookmarks: { 'user-c': { Name: 'user-c' } } }; + if (/^\/JellyfinEnhanced\/user-settings\/user-[bc]\//.test(path)) return {}; + throw new Error(`Unexpected endpoint ${path}`); + } }); + await until(() => h.plugin.initialized); + h.switchUser('user-b'); + await until(() => bWaiting); + const loaded = new Promise(resolve => h.document.addEventListener('je:user-data-loaded', resolve, { once: true })); + h.switchUser('user-c'); + await loaded; + stale.resolve(late === 'private-config' ? { SonarrUrl: 'secret' } : { Bookmarks: { stolen: {} } }); + for (let i = 0; i < 5; i++) 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..03b2be333 --- /dev/null +++ b/tests/frontend/core/core-tag-pipeline.test.mjs @@ -0,0 +1,35 @@ +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); +}); +test('identity reset drops the previous user\'s review ratings',async t=>{ + const changes=new Map(); + const h=createHarness({globals:{requestIdleCallback:()=>{}},apiClient:{ajax:async()=>({reviewRatings:{'movie:42':{average:4.5}},items:{},count:0})},JE:{pluginConfig:{TagCacheServerMode:true},currentSettings:{},core:{tagRenderer:{applyCornerStacking(){},scheduleCornerStacking(){}}}, + session:{getUserId:()=>'user-a',getServerId:()=>'server-a',getEpoch:()=>0,isCurrent:()=>true,onUserChange:(id,fn)=>changes.set(id,fn)}}});t.after(()=>h.close()); + h.load('tags/tag-pipeline.js');await h.JE.tagPipeline.invalidateServerCache(); + assert.equal(h.JE.tagPipeline.peekReviewRatings()?.get('movie:42'),4.5); + changes.get('tag-pipeline')({userId:'b'});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..097b9a1dd --- /dev/null +++ b/tests/frontend/core/core-tag-renderer.test.mjs @@ -0,0 +1,68 @@ +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:'oldtest',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('oldtest-v1','{}');store.setItem('oldtest','{}');store.setItem('oldtestTimestamp','1');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')); + for(const key of ['oldtest-v1','oldtest','oldtestTimestamp'])assert.equal(store.getItem(key),null,key); + h.register();assert.ok(ctx.getPersistent('fresh'),'a plain re-register (page reload) keeps the cache'); + h.JE.pluginConfig.ClearLocalStorageTimestamp=Date.now();h.register();assert.equal(ctx.getPersistent('fresh'),undefined);assert.equal(ctx.hot.size,0); + assert.equal(store.getItem('test-v2'),null);assert.equal(store.getItem('test-v2Timestamp'),String(h.JE.pluginConfig.ClearLocalStorageTimestamp)); +}); +test('legacy sweep keeps the current cache, timestamp and owner keys when they share the legacy stem',t=>{ + // Language tags: current "…languageTagsCache-v2" starts with the legacy "…languageTagsCache-" stem. + // A past server clear (timestamp 1000) is already applied; sweeping its "…-v2Timestamp" key would re-apply it on every reload. + const h=setup(t,{TagCacheServerMode:false,ClearLocalStorageTimestamp:1000});h.spec.cache={key:'JellyfinEnhanced-languageTagsCache-v2',legacyPrefix:['JellyfinEnhanced-languageTagsCache','languageTagsCache'],hotBucket:'language'}; + const store=h.window.localStorage;store.setItem('JellyfinEnhanced-languageTagsCache','{}');store.setItem('languageTagsCache-x','{}'); + const ctx=h.register();ctx.setPersistent('fresh',{timestamp:Date.now()});h.saved[0](); + assert.equal(store.getItem('JellyfinEnhanced-languageTagsCache'),null);assert.equal(store.getItem('languageTagsCache-x'),null); + assert.equal(store.getItem('JellyfinEnhanced-languageTagsCache-v2:identity-owner'),'s:a'); + assert.equal(store.getItem('JellyfinEnhanced-languageTagsCache-v2Timestamp'),'1000'); + h.register();assert.ok(ctx.getPersistent('fresh'),'reload must not drop the language cache'); + assert.ok(store.getItem('JellyfinEnhanced-languageTagsCache-v2').includes('fresh')); + assert.equal(store.getItem('JellyfinEnhanced-languageTagsCache-v2:identity-owner'),'s:a'); + assert.equal(store.getItem('JellyfinEnhanced-languageTagsCache-v2Timestamp'),'1000'); +}); +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..022406fad --- /dev/null +++ b/tests/frontend/core/core-theme.test.mjs @@ -0,0 +1,22 @@ +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=>{ + // Pin the page's clock one millisecond before UTC midnight, so the stored day and the day the page computes cannot differ. + const h=setup(t);const RealDate=h.window.Date,now=RealDate.UTC(2026,0,1,23,59,59,999); + h.window.Date=class extends RealDate{constructor(...args){super(...(args.length?args:[now]));}static now(){return now;}}; + h.window.localStorage.setItem('user-a-lastRandomThemeDate',new h.window.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..f6dea3fd1 --- /dev/null +++ b/tests/frontend/core/core-translations.test.mjs @@ -0,0 +1,39 @@ +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'); +}); +for(const [label,age,expired] of [['a minute short of',24*60*60*1000-60*1000,false],['a minute past',24*60*60*1000+60*1000,true]])test(`translations cached ${label} 24 hours ${expired?'are fetched again':'are reused'}`,async t=>{ + let calls=0;const h=setup(t,async()=>{calls++;return jsonResponse({ok:'fresh'});});const store=h.window.localStorage; + store.setItem('JE_translation_en_test-v1',JSON.stringify({ok:'cached'}));store.setItem('JE_translation_ts_en_test-v1',String(Date.now()-age)); + assert.equal((await h.JE.loadTranslations()).ok,expired?'fresh':'cached');assert.equal(calls,expired?1:0); +}); +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..d1adb8e96 --- /dev/null +++ b/tests/frontend/core/core.test.mjs @@ -0,0 +1,242 @@ +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('a request made after a user switch never joins the previous user\'s in-flight request', async t => { + let user = 'a'; const responses = []; + const { JE } = setup(t, { apiClient: { getCurrentUserId: () => user }, fetch: () => { const response = deferred(); responses.push(response); return response.promise; } }); + const old = JE.core.api.fetch('/private').catch(() => null); + await new Promise(done => setImmediate(done)); + user = 'b'; JE.session.checkNow('test'); + const fresh = JE.core.api.fetch('/private'); + await new Promise(done => setImmediate(done)); + assert.equal(responses.length, 2); + responses[0].resolve(jsonResponse({ owner: 'a' })); responses[1].resolve(jsonResponse({ owner: 'b' })); + assert.equal((await fresh).owner, 'b'); await old; +}); + +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 calls===1?pending.promise:Promise.resolve(jsonResponse({ok:true}));}}); + 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); + // The aborted entry must have left the queue: otherwise it takes the freed slot and never returns it. + const third=JE.core.api.fetch('/three'); + const outcome=await Promise.race([third,new Promise(done=>setTimeout(()=>done('hung'),1000))]); + assert.notEqual(outcome,'hung','a later request must still get a slot'); + assert.equal(calls,2); +}); + +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..7b0591aed --- /dev/null +++ b/tests/frontend/features/features-autoskip.test.mjs @@ -0,0 +1,50 @@ +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(),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; + 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..7c9d22fb7 --- /dev/null +++ b/tests/frontend/features/features-bookmarks.test.mjs @@ -0,0 +1,360 @@ +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=[],toasts=[]; + 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:message=>toasts.push(message),escapeHtml:value=>String(value)},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,toasts,routes,api:h.JE.bookmarks}; +} +/** Answers each user-settings save with a promise the test settles, in call order. */ +function controlledSaves(){ + const calls=[]; + return {calls,save:(file,data)=>new Promise((resolve,reject)=>calls.push({data,resolve,reject})), + async next(count){for(let i=0;calls.lengthsetImmediate(done));assert.equal(calls.length,count);return calls[count-1];}}; +} +const settle=async(until,limit=200)=>{for(let i=0;!until()&&isetImmediate(done));assert.ok(until(),'condition never became true');}; +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'),null); + 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('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');}); + 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'}); + await new Promise(done=>setImmediate(done)); + 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.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'}}]}); + 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.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={}; + 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.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.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); +}); + +// Overlapping mutations: the first save fails and the second succeeds. Memory must end +// equal to what the server holds, or the next save resurrects or drops a bookmark. +test('overlapping bookmark deletes cannot resurrect a record the server no longer has',async t=>{ + const saves=controlledSaves();const h=setup(t,{a:{itemId:'one'},b:{itemId:'two'}},saves.save); + h.expectConsoleError(/Failed to delete bookmark/); + const first=h.api.delete('a'),second=h.api.delete('b'); + (await saves.next(1)).reject(new Error('offline')); + (await saves.next(2)).resolve(); + assert.deepEqual([await first,await second],[false,true]); + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks),['a']); + assert.deepEqual(Object.keys(saves.calls[1].data.bookmarks),['a']); + assert.deepEqual(h.events,['delete']); +}); +test('overlapping bookmark adds leave memory equal to the saved set',async t=>{ + const saves=controlledSaves();const h=setup(t,{},saves.save); + h.document.body.innerHTML='
'; + h.routes.items=async()=>({Items:[{Id:'item',Name:'Movie',Type:'Movie'}]}); + h.expectConsoleError(/Failed to save bookmark/); + const first=h.api.add(1),second=h.api.add(2);const firstFailure=assert.rejects(first,/offline/); + (await saves.next(1)).reject(new Error('offline')); + (await saves.next(2)).resolve(); + await firstFailure;const added=await second; + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks),[added.id]); + assert.deepEqual(Object.keys(saves.calls[1].data.bookmarks),[added.id]); + assert.equal(h.JE.userConfig.bookmark.bookmarks[added.id].timestamp,2); +}); +// Each mutation is queued behind a pending delete; another user signs in before it runs. Its +// save would go to the new user's bookmark.json, so it must not run at all. +for(const operation of ['update','deleteAll','backfill']){ + test(`a queued bookmark ${operation} does not run for a user who signed in meanwhile`,async t=>{ + const saves=controlledSaves();const h=setup(t,{a:{itemId:'one'},b:{itemId:'two',mediaType:'tv'}},saves.save); + h.routes.items=async()=>({Items:[{Id:'two',Type:'Episode',ParentIndexNumber:1,IndexNumber:2,ProviderIds:{}}]}); + const first=h.api.delete('a'); + const second=operation==='update'?h.api.update('b',{label:'later'}):operation==='deleteAll'?h.api.deleteAll():h.api.backfillEpisodeMetadata(); + const saveA=await saves.next(1);await ticks(); // the backfill's episode lookup finishes and it queues + saveA.resolve(); + const next={bookmark:{bookmarks:{mine:{itemId:'x'}}}};h.JE.userConfig=next; + // Settle any save a regression issues for the new user, so it fails rather than hangs. + const results=Promise.all([first,second]);await ticks();saves.calls.slice(1).forEach(call=>call.resolve()); + assert.deepEqual(await results,[true,operation==='backfill'?undefined:false]); + assert.equal(saves.calls.length,1); + assert.equal(h.JE.userConfig,next);assert.deepEqual(plain(next.bookmark.bookmarks),{mine:{itemId:'x'}}); + }); +} +/** Loads the replacement modal and migrates `group` to the single offered item. */ +async function migrate(h,group){ + const replacement={Id:'new-item-0000000000000000',Name:'New cut',Type:'Movie',ProviderIds:{Tmdb:'42'}}; + h.JE.core={api:{fetch:async()=>({Items:[replacement]})}};h.JE.helpers={getItemCached:async()=>replacement};h.window.ApiClient.getImageUrl=()=>''; + h.load('enhanced/bookmarks/bookmarks-library-replacements.js'); + await h.JE.internals.bookmarksLibrary.findAndOfferReplacement(group,h.document.createElement('button')); + h.document.querySelector('.replacement-option').click(); + h.document.querySelector('.je-bookmark-btn-submit').click(); +} +const orphanGroup=bookmarks=>({details:{itemId:'old-item-0000000000000000',tmdbId:'42',mediaType:'movie',name:'Old cut'},bookmarks:Object.entries(bookmarks).map(([id,bm])=>({id,...bm}))}); +test('failed bookmark migration keeps the old group, and the next save still persists it',async t=>{ + let fail=true;const saved=[]; + const h=setup(t,{a:{itemId:'old-item-0000000000000000',timestamp:5,label:'scene'},b:{itemId:'old-item-0000000000000000',timestamp:9},keep:{itemId:'other'}},async(file,data)=>{if(fail)throw new Error('offline');saved.push(plain(data));}); + h.expectConsoleError(/Failed to sync bookmarks/);h.expectConsoleError(/Migration failed/); + const before=plain(h.JE.userConfig.bookmark.bookmarks); + await migrate(h,orphanGroup({a:before.a,b:before.b})); + await settle(()=>h.toasts.includes('bookmark_migration_failed')); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),before); + fail=false;assert.equal(await h.api.update('keep',{label:'edited'}),true); + assert.deepEqual(Object.keys(saved[0].bookmarks).sort(),['a','b','keep']); +}); +test('successful bookmark migration replaces the old group in one save',async t=>{ + const saved=[];const h=setup(t,{a:{itemId:'old-item-0000000000000000',timestamp:5}},async(file,data)=>saved.push(plain(data))); + await migrate(h,orphanGroup(h.JE.userConfig.bookmark.bookmarks)); + await settle(()=>h.toasts.some(message=>message.startsWith('bookmark_migrated'))); + const records=Object.values(h.JE.userConfig.bookmark.bookmarks); + assert.equal(saved.length,1);assert.deepEqual(Object.keys(saved[0].bookmarks),Object.keys(h.JE.userConfig.bookmark.bookmarks)); + assert.deepEqual(records.map(bm=>[bm.itemId,bm.timestamp,bm.syncedFrom]),[['new-item-0000000000000000',5,'old-item-0000000000000000']]); +}); +test('orphan cleanup counts only deletes that were saved',async t=>{ + let fail=true;const h=setup(t,{a:{itemId:'gone'},b:{itemId:'gone'},c:{itemId:'present'}},async()=>{if(fail)throw new Error('offline');}); + h.window.ApiClient.getItem=async(user,id)=>{if(id==='gone')throw new Error('404');return {Id:id};}; + h.expectConsoleError(/Failed to delete bookmark/); + assert.deepEqual(plain(await h.api.cleanupOrphaned()),{cleaned:0,errors:2}); + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks).sort(),['a','b','c']); + fail=false;assert.deepEqual(plain(await h.api.cleanupOrphaned()),{cleaned:2,errors:0}); + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks),['c']); +}); +test('player bookmark modal reports a failed edit or delete instead of success',async t=>{ + const h=setup(t,{one:{itemId:'item',timestamp:5,label:'old'}},async()=>{throw new Error('offline');}); + h.document.body.innerHTML='
'; + h.routes.items=async()=>({Items:[{Id:'item',Name:'Movie',Type:'Movie'}]}); + h.expectConsoleError(/Failed to update bookmark/);h.expectConsoleError(/Failed to delete bookmark/); + await h.api.showModal('edit',{id:'one',...h.JE.userConfig.bookmark.bookmarks.one}); + h.document.querySelector('.je-bookmark-btn-submit').click(); + await settle(()=>h.toasts.length===1); + h.document.querySelector('.je-bookmark-btn-delete[data-bookmark-id="one"]').click(); + await settle(()=>h.toasts.length===2); + assert.deepEqual(h.toasts,['toast_bookmark_save_failed','toast_bookmark_delete_failed']); + assert.equal(h.JE.userConfig.bookmark.bookmarks.one.label,'old'); +}); + +// Episode backfill, delete-all and repeated deletes share the per-owner queue, so +// memory always ends equal to what the server last accepted. +const episodeLookup=(season,episode,tvdb)=>({Items:[{Id:'episode',Type:'Episode',ParentIndexNumber:season,IndexNumber:episode,ProviderIds:tvdb?{Tvdb:tvdb}:{}}]}); +const ticks=async(count=10)=>{for(let i=0;isetImmediate(done));}; +test('episode backfill queued behind a failing delete also saves the restored record',async t=>{ + const saves=controlledSaves(); + const h=setup(t,{ep:{itemId:'ep-item',mediaType:'tv',tvdbId:'series'},other:{itemId:'movie',mediaType:'movie'}},saves.save); + let lookups=0;h.routes.items=async()=>{lookups++;return episodeLookup(2,5,'episode');}; + h.expectConsoleError(/Failed to delete bookmark/); + const deleting=h.api.delete('other'); + await saves.next(1); + const backfill=h.api.backfillEpisodeMetadata(); + await settle(()=>lookups===1);await ticks(); + saves.calls[0].reject(new Error('offline')); + assert.equal(await deleting,false); + (await saves.next(2)).resolve();await backfill; + assert.deepEqual(Object.keys(saves.calls[1].data.bookmarks).sort(),['ep','other']); + assert.deepEqual(plain(saves.calls[1].data.bookmarks.ep),{itemId:'ep-item',mediaType:'tv',tvdbId:'episode',seasonNumber:2,episodeNumber:5}); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),saves.calls[1].data.bookmarks); +}); +test('episode backfill skips a record deleted meanwhile and rolls back a failed save',async t=>{ + let fail=false,release;const saved=[];const gate=new Promise(done=>{release=done;}); + const h=setup(t,{gone:{itemId:'ep-gone',mediaType:'tv'},kept:{itemId:'ep-kept',mediaType:'tv'}},async(file,data)=>{if(fail)throw new Error('offline');saved.push(plain(data));}); + let lookups=0;h.routes.items=async()=>{if(++lookups===1)await gate;return episodeLookup(1,lookups);}; + const backfill=h.api.backfillEpisodeMetadata(); + await settle(()=>lookups===1); + assert.equal(await h.api.delete('gone'),true); + release();await backfill; + assert.deepEqual(saved.map(data=>Object.keys(data.bookmarks)),[['kept'],['kept']]); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),{kept:{itemId:'ep-kept',mediaType:'tv',seasonNumber:1,episodeNumber:2}}); + + const failing=setup(t,{ep:{itemId:'ep-item',mediaType:'tv',tvdbId:'series'}},async()=>{throw new Error('offline');}); + failing.routes.items=async()=>episodeLookup(3,4,'episode'); + await failing.api.backfillEpisodeMetadata(); + assert.deepEqual(plain(failing.JE.userConfig.bookmark.bookmarks),{ep:{itemId:'ep-item',mediaType:'tv',tvdbId:'series'}}); +}); +test('delete-all runs after queued mutations and restores them when its save fails',async t=>{ + const saves=controlledSaves();const h=setup(t,{a:{itemId:'one'},b:{itemId:'two',label:'old'}},saves.save); + h.expectConsoleError(/Failed to delete all bookmarks/); + const deleting=h.api.delete('a'),updating=h.api.update('b',{label:'new'}),clearing=h.api.deleteAll(); + const clearFailure=assert.rejects(clearing,/offline/); + (await saves.next(1)).resolve(); + (await saves.next(2)).resolve(); + const clear=await saves.next(3);assert.deepEqual(clear.data.bookmarks,{}); + clear.reject(new Error('offline'));await clearFailure; + assert.deepEqual([await deleting,await updating],[true,true]); + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks),['b']); + assert.equal(h.JE.userConfig.bookmark.bookmarks.b.label,'new'); + assert.deepEqual(plain(h.JE.userConfig.bookmark.bookmarks),plain(saves.calls[1].data.bookmarks)); +}); +/** Loads the bookmarks library and renders it into a fresh `.sections.bookmarks` container. */ +async function renderLibrary(h){ + h.JE.helpers={getItemCached:async id=>({Id:id,Name:'Movie',Type:'Movie'}),isActiveTabContainer:()=>false}; + h.window.ApiClient.getImageUrl=()=>'';h.window.confirm=()=>true; + h.load('enhanced/bookmarks/bookmarks-library-render.js');h.load('enhanced/bookmarks/bookmarks-library-items.js'); + const container=h.document.createElement('div');container.className='sections bookmarks';h.document.body.append(container); + await h.JE.internals.bookmarksLibrary.renderBookmarksLibrary(container); + return container; +} +test('library delete-all reports a failed save and keeps every bookmark',async t=>{ + const h=setup(t,{a:{itemId:'one',mediaType:'movie',timestamp:1},b:{itemId:'two',mediaType:'movie',timestamp:2}},async()=>{throw new Error('offline');}); + h.expectConsoleError(/Failed to delete all bookmarks/);h.expectConsoleError(/Delete failed/); + const container=await renderLibrary(h);const button=container.querySelector('.btnDeleteAllBookmarks'); + button.click();assert.equal(button.disabled,true); + await settle(()=>h.toasts.length===1); + assert.deepEqual(h.toasts,['bookmark_delete_failed']); + assert.deepEqual(Object.keys(h.JE.userConfig.bookmark.bookmarks).sort(),['a','b']); + assert.equal(button.disabled,false); +}); +test('a repeated delete of the same bookmark reports nothing to delete rather than a failure',async t=>{ + const saves=controlledSaves();const h=setup(t,{a:{itemId:'one'},b:{itemId:'gone-1'},c:{itemId:'gone-2'}},saves.save); + const first=h.api.delete('a'),second=h.api.delete('a'); + (await saves.next(1)).resolve(); + assert.deepEqual([await first,await second],[true,null]); + assert.equal(saves.calls.length,1); + // Cleanup counts a record that vanished before its delete (b, removed while gone-2 was + // checked) as neither removed nor an error. + h.window.ApiClient.getItem=async(user,id)=>{if(id==='gone-2')delete h.JE.userConfig.bookmark.bookmarks.b;throw new Error('404');}; + const cleanup=h.api.cleanupOrphaned(); + (await saves.next(2)).resolve(); + assert.deepEqual(plain(await cleanup),{cleaned:1,errors:0}); + assert.deepEqual(h.JE.userConfig.bookmark.bookmarks,{}); +}); +test('a delete button for a bookmark already removed elsewhere shows no toast',async t=>{ + const h=setup(t,{a:{itemId:'one',mediaType:'movie',timestamp:1},b:{itemId:'one',mediaType:'movie',timestamp:2}}); + const container=await renderLibrary(h); + h.document.body.insertAdjacentHTML('beforeend','
'); + h.routes.items=async()=>({Items:[{Id:'one',Name:'Movie',Type:'Movie'}]}); + await h.api.showModal('edit',{id:'b',...h.JE.userConfig.bookmark.bookmarks.b}); + assert.equal(await h.api.delete('a'),true);assert.equal(await h.api.delete('b'),true); + const card=container.querySelector('.btnDeleteBookmark[data-bookmark-id="a"]'); + const player=h.document.querySelector('.je-bookmark-btn-delete[data-bookmark-id="b"]'); + card.click();player.click();await ticks(); + assert.equal(card.disabled,false);assert.equal(player.disabled,false); + assert.deepEqual(h.toasts,[]); +}); +test('library and player delete buttons are disabled while their delete is in flight',async t=>{ + const saves=controlledSaves(); + const h=setup(t,{a:{itemId:'one',mediaType:'movie',timestamp:1,label:'x'},b:{itemId:'one',mediaType:'movie',timestamp:2}},saves.save); + h.expectConsoleError(/Failed to delete bookmark/); + const container=await renderLibrary(h); + const card=container.querySelector('.btnDeleteBookmark[data-bookmark-id="a"]'); + card.click();card.click();assert.equal(card.disabled,true); + (await saves.next(1)).resolve(); + await settle(()=>h.toasts.length===1);await ticks(); + assert.deepEqual(h.toasts,['toast_bookmark_deleted']);assert.equal(saves.calls.length,1); + + h.document.body.insertAdjacentHTML('beforeend','
'); + h.routes.items=async()=>({Items:[{Id:'one',Name:'Movie',Type:'Movie'}]}); + await h.api.showModal('edit',{id:'b',...h.JE.userConfig.bookmark.bookmarks.b}); + const player=h.document.querySelector('.je-bookmark-btn-delete[data-bookmark-id="b"]'); + player.click();player.click();assert.equal(player.disabled,true); + (await saves.next(2)).reject(new Error('offline')); + await settle(()=>h.toasts.length===2);await ticks(); + assert.deepEqual(h.toasts,['toast_bookmark_deleted','toast_bookmark_delete_failed']); + assert.equal(player.disabled,false);assert.equal(saves.calls.length,2); +}); diff --git a/tests/frontend/features/features-hidden.test.mjs b/tests/frontend/features/features-hidden.test.mjs new file mode 100644 index 000000000..d99799361 --- /dev/null +++ b/tests/frontend/features/features-hidden.test.mjs @@ -0,0 +1,66 @@ +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:'AA-BB'},{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); + h.api.getHiddenData().settings.filterCalendar=false; + assert.equal(h.api.filterCalendarEvents([{title:'Show'},{tmdbId:42},{itemId:'AA-BB'}]).length,3); +}); +test('switching users clears previous hidden DOM marks and cached policy',t=>{ + const h=setup(t,{items:{a:{itemId:'a',tmdbId:42}},html:'
'}); + h.api.filterNativeCards();h.flush();assert.equal(h.api.getHiddenCount(),1);assert.equal(h.api.filterRequestItems([{tmdbId:42}]).length,0); + 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); + // The incoming user sees none of the previous user's hidden IDs, TMDB IDs or items. + assert.equal(h.api.filterRequestItems([{tmdbId:42}]).length,1);assert.equal(h.api.getHiddenCount(),0);assert.equal(h.api.isHiddenOnSurface('a','library'),false); +}); +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..604bc3677 --- /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 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-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..cebbc2561 --- /dev/null +++ b/tests/frontend/features/features-release-dates.test.mjs @@ -0,0 +1,66 @@ +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('a release type listed in several countries prefers the configured region, then US, over list order',async t=>{ + // GB is listed first and has the earliest date, US the next earliest; the configured AU still wins. + const dates={GB:'2025-01-05',US:'2025-02-05',AU:'2025-03-05'}; + const data=countries=>({results:countries.map(iso=>({iso_3166_1:iso,release_dates:[{type:4,release_date:dates[iso]}]}))}); + const configured=setup(t,{data:data(['GB','US','AU'])});configured.api.displayReleaseDate('movie',configured.container);await configured.settle(); + assert.match(configured.container.querySelector('.je-release-date-digital').textContent,/Mar/); + const fallback=setup(t,{data:data(['GB','US'])});fallback.api.displayReleaseDate('movie',fallback.container);await fallback.settle(); + assert.match(fallback.container.querySelector('.je-release-date-digital').textContent,/Feb/); +}); +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(); + 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. + 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('season chip picks the episode airing today by the local date west of UTC',async t=>{ + 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;}); + const episodes=[{air_date:'2025-03-03'},{air_date:'2025-03-10'},{air_date:'2025-03-17'}]; + const h=setup(t,{item:{Type:'Season',IndexNumber:1,SeriesProviderIds:{Tmdb:'42'}},data:{episodes}}); + // 20:00 on 10 March in Los Angeles is already 11 March in UTC. + const RealDate=h.window.Date,now=new RealDate(2025,2,10,20).getTime();assert.equal(new RealDate(now).toISOString().slice(0,10),'2025-03-11'); + h.window.Date=class extends RealDate{constructor(...args){super(...(args.length?args:[now]));}static now(){return now;}}; + h.api.displayReleaseDate('season',h.container);await h.settle(); + const format=(year,month,day)=>new RealDate(year,month-1,day).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); + assert.equal(h.container.textContent.slice('tv_guide'.length),format(2025,3,10)); +}); +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..172067a64 --- /dev/null +++ b/tests/frontend/features/features-spoiler.test.mjs @@ -0,0 +1,115 @@ +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); + assert.deepEqual(Object.keys(h.state.getUserPrefs()),[]); + 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..36e0d1db7 --- /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, 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', ...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) { + 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: 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']) { + 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..7742f1988 --- /dev/null +++ b/tests/frontend/helpers/discovery.mjs @@ -0,0 +1,31 @@ +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; +} + +/** + * A hung test fails on its own instead of stalling the whole run. Node 22 (CI) + * applies --test-timeout to each test file as a whole, newer Node to each test, + * so the limit leaves room for the heaviest file on a slow runner. + */ +export const testArguments = ['--test-timeout=120000']; + +/** 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 new file mode 100644 index 000000000..64aa30045 --- /dev/null +++ b/tests/frontend/helpers/harness.mjs @@ -0,0 +1,89 @@ +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 = []; + // 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(' '); + 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}`, { + 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, + /** 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); + // 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'); + } + }; +} + +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)); + +/** Install the real JE.keyCombo from config.js without running the rest of that module (it needs a full JE). */ +export function loadKeyCombo(harness) { + const source = readFileSync(resolve(jsRoot, 'enhanced/config.js'), 'utf8'); + const match = /JE\.keyCombo = function[\s\S]*?\n };/.exec(source); + if (!match) throw new Error('JE.keyCombo not found in enhanced/config.js'); + harness.window.eval(`(function(JE){${match[0]}})(window.JellyfinEnhanced)`); +} diff --git a/tests/frontend/pages/pages-activity.test.mjs b/tests/frontend/pages/pages-activity.test.mjs new file mode 100644 index 000000000..cb07e3cf5 --- /dev/null +++ b/tests/frontend/pages/pages-activity.test.mjs @@ -0,0 +1,65 @@ +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 escapes user and item names in every section, active streams included',async t=>{ + const hostile=label=>``; + const row=(type,label,extra={})=>({ActivityType:type,UserId:'u',UserName:hostile(`${label}-user`),Timestamp:0,Item:{Id:label,Name:hostile(`${label}-item`)},...extra}); + const session={UserId:'u',UserName:hostile('stream-user'),PlayState:{PositionTicks:50},NowPlayingItem:{Id:'stream',Name:hostile('stream-item'),RunTimeTicks:100}}; + const h=setup(t,{config:{ActiveStreamsEnabled:true,ActivityFeedShowActiveStreams:true},api:async path=>path==='/active-streams/sessions'?[session] + :{items:[row('Watched','partial',{Progress:0.5}),row('Watched','complete',{Completed:true}),row('Reviewed','review',{Rating:4,Content:'fine'}),row('Favorited','favorite')]}}); + await h.P.renderForCustomTab(h.host); + assert.equal(h.document.querySelectorAll('.je-activity-section').length,3); + assert.equal(h.document.querySelector('img[data-from]'),null);assert.equal(h.document.querySelector('script'),null); + const labels=['stream','partial','complete','review','favorite']; + assert.deepEqual([...h.document.querySelectorAll('.je-activity-user')].map(n=>n.textContent),labels.map(label=>hostile(`${label}-user`))); + assert.deepEqual([...h.document.querySelectorAll('.je-activity-item-name')].map(n=>n.textContent),labels.map(label=>hostile(`${label}-item`))); + h.P.stopPolling(); +}); +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..97b51bbb7 --- /dev/null +++ b/tests/frontend/pages/pages-downloads.test.mjs @@ -0,0 +1,91 @@ +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={},html='',globals={}}={}){ + let epoch=0,changed;const notices=[],calls=[]; + const h=createHarness({html,globals,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();}}; +} +// Pages hold whole grid rows: with three live columns that is four rows of 12 cards. +const grids='
'; +test('downloads page loads queue, whole-row requests/issues/history pages while exposing loading lifecycle',async t=>{ + let columns='1fr 1fr 1fr';const ajaxUrls=[]; + const h=setup(t,{html:grids,globals:{getComputedStyle:()=>({gridTemplateColumns:columns})},ajax:async options=>{ajaxUrls.push(new URL(options.url));return {results:[]};}, + 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}}); + const page=(prefix,from=0)=>new URL(h.calls.slice(from).find(([p])=>p.startsWith(prefix))[0],'http://test').searchParams; + Object.assign(h.P.state,{requestsPageSize:12,issuesPageSize:12,historyPageSize:12,requestsPage:2,requestsFilter:'pending',issuesPage:3,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); + assert.deepEqual([page('/arr/requests').get('skip'),page('/arr/requests').get('take'),page('/arr/requests').get('filter')],['12','12','pending']); + assert.deepEqual([ajaxUrls[0].searchParams.get('skip'),ajaxUrls[0].searchParams.get('take')],['24','12']); + assert.deepEqual([page('/arr/history').get('skip'),page('/arr/history').get('take')],['24','12']); + // A column-count change re-sizes the page and returns to page 1; at 13 columns History drops to three rows under its 48-card cap. + columns=Array(13).fill('1fr').join(' ');const before=h.calls.length;await h.P.loadAllData(); + assert.deepEqual([page('/arr/requests',before).get('skip'),page('/arr/requests',before).get('take'),h.P.state.requestsPage],['0','52',1]); + assert.deepEqual([ajaxUrls[1].searchParams.get('skip'),ajaxUrls[1].searchParams.get('take'),h.P.state.issuesPage],['0','52',1]); + assert.deepEqual([page('/arr/history',before).get('skip'),page('/arr/history',before).get('take'),h.P.state.historyPage],['0','39',1]); +}); +test('downloads pending queue, request, issue and history responses cannot restore previous account data or permissions',async t=>{ + const pending=deferred(),issues=deferred();const h=setup(t,{api:()=>pending.promise,ajax:()=>issues.promise}); + // The previous account could approve: the switch itself must revoke that. + h.P.state.canApproveRequests=true; + const run=h.P.loadAllData();h.switchUser();assert.equal(h.P.state.canApproveRequests,false); + // The new account's history is hidden; the previous account's late response says visible. + h.P.state.historyVisible=false; + pending.resolve({items:[{id:'old'}],requests:[{id:'old'}],canApproveRequests:true,visible:true});issues.resolve({results:[{id:'old'}],pageInfo:{pages:9}});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); + assert.equal(h.P.state.historyVisible,false); + assert.equal(h.P.state.issues.length,0);assert.equal(h.P.state.issuesTotalPages,1); +}); +test('downloads queue, request, issue and history failures from before an account switch keep the new account lists',async t=>{ + const pending=deferred(),issues=deferred();const h=setup(t,{api:()=>pending.promise,ajax:()=>issues.promise}); + for(const source of ['downloads','requests','issues','history'])h.expectConsoleError(`Requests Page: Failed to fetch ${source}:`); + const run=h.P.loadAllData();h.switchUser(); + Object.assign(h.P.state,{downloads:[{id:'new'}],requests:[{id:'new'}],issues:[{id:'new'}],history:[{id:'new'}]}); + pending.reject(new Error('offline'));issues.reject(new Error('offline'));await run; + for(const list of ['downloads','requests','issues','history'])assert.deepEqual(plain(h.P.state[list]),[{id:'new'}],list); + assert.equal(h.P.state.issuesError,false); +}); +test('downloads issue media details resolving after an account switch do not fill the new account cache',async t=>{ + const late={42:deferred(),43:deferred()};let lateLookups=0;const lookups=[]; + const h=setup(t,{ajax:options=>{ + if(options.url.includes('/issue'))return Promise.resolve({results:[{id:1,media:{mediaType:'tv',tmdbId:42}},{id:2,media:{mediaType:'movie',tmdbId:43}}]}); + const id=options.url.match(/(\d+)$/)[1];lookups.push(id); + return lateLookups++<2?late[id].promise:Promise.resolve({id:Number(id),name:`New ${id}`,title:`New ${id}`}); + }}); + const run=h.P.fetchIssues();for(let i=0;i<5&&lateLookups<2;i++)await new Promise(resolve=>setImmediate(resolve)); + h.switchUser();late[42].resolve({id:42,name:'Previous account view'});late[43].reject(new Error('offline'));await run; + // Neither the late success nor the late failure is cached for the new account: both are looked up again. + await h.P.fetchIssues();assert.deepEqual(lookups.sort(),['42','42','43','43']); + assert.deepEqual(plain(h.P.state.issues).map(issue=>issue.media.title),['New 42','New 43']); +}); +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); +}); +// History goes through JE.core.api.plugin (h.calls); issues through ApiClient.ajax, counted separately. +for(const enabled of [false,true]){ + test(`downloads ${enabled?'enabled':'disabled'} optional sources ${enabled?'are fetched':'clear stale state without network'}`,async t=>{ + let ajaxCalls=0;const h=setup(t,{config:{DownloadsShowHistory:enabled,DownloadsPageShowIssues:enabled},ajax:async()=>{ajaxCalls++;return {results:[]};}}); + 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.issues.length,0); + if(enabled){assert.ok(ajaxCalls>=1);assert.ok(h.calls.some(([path])=>path.startsWith('/arr/history')));} + else{assert.equal(h.P.state.historyVisible,false);assert.equal(ajaxCalls,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..c5af127e3 --- /dev/null +++ b/tests/frontend/pages/pages-moreinfo.test.mjs @@ -0,0 +1,85 @@ +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=>{ + 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(); + 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); +}); +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]); +}); +for(const outcome of ['completion','failure'])test(`more-info stale TV request ${outcome} 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'); + if(outcome==='completion')pending.resolve({id:1,mediaInfo:{status:2}});else pending.reject(Error('offline')); + await nextTurn();assert.deepEqual(h.actions,[1,2]); +}); +test('more-info failed TV request refresh still marks the open modal requested',async t=>{ + let calls=0;const rendered=[];const h=setup(t,{details:async id=>{if(++calls>1)throw Error('offline');return {id};}}); + h.internal.renderActions=data=>rendered.push(data.mediaInfo?.status); + await h.P.open(1,'tv');h.document.dispatchEvent(new h.window.CustomEvent('jellyseerr-tv-requested',{detail:{tmdbId:1}}));await nextTurn(); + assert.deepEqual(rendered,[undefined,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..4ba23e100 --- /dev/null +++ b/tests/frontend/pages/pages-reviews.test.mjs @@ -0,0 +1,138 @@ +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']) { + // 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,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}}); + 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.equal(h.document.querySelector('.je-review-edit-btn'),null);assert.equal(h.document.querySelector('.je-review-delete-btn'),null); + assert.match(h.document.querySelector('.je-avg-user-rating-chip').textContent,/8/); +}); +test('regular viewers get edit and delete controls on their own review only',async t=>{ + const h=await setup(t,{reviews:[{userId:'other',userName:'Other',content:'Theirs',rating:3},{userId:'user-a',userName:'Me',content:'Mine',rating:4}]}); + const card=text=>[...h.document.querySelectorAll('.tmdb-review-card')].find(c=>c.textContent.includes(text)); + assert.equal(card('Theirs').querySelector('.je-review-btn'),null); + assert.ok(card('Mine').querySelector('.je-review-edit-btn'));assert.ok(card('Mine').querySelector('.je-review-delete-btn')); + assert.equal(card('Mine').querySelector('.je-review-admin-delete-btn'),null); +}); +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..85ca93440 --- /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, loadKeyCombo} 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());loadKeyCombo(h);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..bf6365f88 --- /dev/null +++ b/tests/frontend/requests/request-submission.test.mjs @@ -0,0 +1,366 @@ +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('late status failure from previous user is neither cached nor shown to next user', async t => { + const pending = deferred(); let reads = 0; + const h = setup(t, { get: () => ++reads === 1 ? pending.promise : Promise.resolve({ active: true, userFound: true }) }); + const first = h.api.checkUserStatus(); h.switchUser(); + pending.reject({ responseJSON: { code: 'blocked' } }); + assert.equal((await first).reason, 'blocked'); + assert.equal(h.toasts.length, 0); + assert.equal((await h.api.checkUserStatus()).active, true); assert.equal(reads, 2); +}); + +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); +}); + +// split: the 4K-enabled split button's main half has its own copy of the request handler. +const mainButton = (h, split) => split ? h.document.querySelector('.jellyseerr-split-main') : h.button; +for (const split of [false, true]) test(`rapid repeated clicks submit one movie request and update status on success (split=${split})`, async t => { + const pending = deferred(); const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: split }, post: () => pending.promise }); h.configure(); + const button = mainButton(h, split); + button.click(); button.click(); assert.equal(button.disabled, true); await flush(); assert.equal(h.calls.length, 1); + // The standard half requests this movie in standard quality: no is4k flag. + assert.deepEqual(h.calls[0][1].body, { mediaType: 'movie', mediaId: 42 }); + pending.resolve({ id: 7 }); await flush(); + assert.equal(h.item.mediaInfo.status, 3); assert.equal(button.disabled, true); + assert.ok(button.classList.contains('jellyseerr-button-pending')); assert.ok(button.textContent.includes('jellyseerr_btn_requested')); +}); + +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, { config: { JellyseerrEnable4KRequests: split }, 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(); + const button = mainButton(h, split); button.click(); await flush(); + assert.equal(dialogs.length, 1); assert.ok(dialogs[0].message.includes('<img')); assert.equal(button.disabled, false); assert.equal(h.events.length, 0); + // Back to the idle label exactly (not the in-flight "requesting" one) with no spinner. + assert.equal(button.textContent.trim(), 'jellyseerr_btn_request'); assert.equal(button.querySelector('.jellyseerr-button-spinner'), null); + assert.equal(button.classList.contains('jellyseerr-button-error'), false); +}); + +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('a results refresh updates only its own section and matches the media type as well as the exact id', async t => { + const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: true } }); + h.JE.core.ui = { addTouchTapListener() {} }; h.load('jellyseerr/ui/ui-results.js'); + // A retained overview elsewhere on the page keeps its own 4K split button for movie 42. + h.button.dataset.tmdbId = '42'; h.button.dataset.mediaType = 'movie'; h.configure(); + const overviewMain = h.document.querySelector('.jellyseerr-split-main'); + // The refreshed section holds a show and a movie that share TMDB id 42, and a + // second movie whose id 420 starts with the same digits. + const section = h.document.createElement('div'); h.document.body.append(section); + const sectionButton = item => { + const button = h.document.createElement('button'); button.className = 'jellyseerr-request-button'; + Object.assign(button.dataset, { tmdbId: String(item.id), mediaType: item.mediaType, searchResultItem: JSON.stringify(item) }); + section.append(button); return button; + }; + const show = { id: 42, mediaType: 'tv', name: 'Show', mediaInfo: { status: 1 } }; + const showButton = sectionButton(show); + sectionButton({ id: 42, mediaType: 'movie', title: 'Film', mediaInfo: { status: 1 } }); + sectionButton({ id: 420, mediaType: 'movie', title: 'Other film', mediaInfo: { status: 2 } }); + // Movie 42 became available and movie 420 requestable again. Movie 42 comes first, so an + // id-only match would hand it to the show, and a type-only or prefix match to movie 420. + h.JE.jellyseerrUI.updateJellyseerrResults([{ id: 42, mediaType: 'movie', title: 'Film', mediaInfo: { status: 5 } }, show, + { id: 420, mediaType: 'movie', title: 'Other film', mediaInfo: { status: 1 } }], true, true, section); + assert.ok(showButton.isConnected); assert.equal(JSON.parse(showButton.dataset.searchResultItem).mediaType, 'tv'); + const sectionMain = section.querySelector('.jellyseerr-split-main[data-tmdb-id="42"]'); + assert.equal(sectionMain.disabled, true); assert.equal(JSON.parse(sectionMain.dataset.searchResultItem).mediaInfo.status, 5); + const otherMain = section.querySelector('.jellyseerr-split-main[data-tmdb-id="420"]'); + assert.equal(otherMain.disabled, false); assert.equal(JSON.parse(otherMain.dataset.searchResultItem).mediaInfo.status, 1); + // The overview outside the section keeps its split styling and its own request handler. + assert.ok(overviewMain.classList.contains('jellyseerr-split-main')); + overviewMain.click(); await flush(); otherMain.click(); await flush(); + assert.deepEqual(h.calls.map(call => call[1].body), [{ mediaType: 'movie', mediaId: 42 }, { mediaType: 'movie', mediaId: 420 }]); +}); + +for (const split of [false, true]) test(`advanced-permission UI opens options without prematurely submitting (split=${split})`, async t => { + const h = uiSetup(t, { config: { JellyseerrShowAdvanced: true, JellyseerrEnable4KRequests: split }, get: async () => ({ active: true, userFound: true, canRequestAdvanced: true }) }); + await h.api.checkUserStatus(); const opened = []; h.JE.jellyseerrUI.showMovieRequestModal = (...args) => opened.push(args); + h.configure(); mainButton(h, split).click(); await flush(); assert.equal(h.calls.length, 0); assert.equal(opened.length, 1); assert.equal(opened[0][0], 42); + // The standard half opens the standard-quality options, never the 4K ones. + assert.equal(opened[0][3], false); +}); + +for (const split of [false, true]) for (const code of ['no_request_permission', 'request_4k_forbidden']) test(`typed permission denial ${code} is shown without success state (split=${split})`, async t => { + const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: split }, post: async () => { throw { status: 403, responseJSON: { code } }; } }); + h.configure(); const button = mainButton(h, split); button.click(); await flush(); + assert.ok(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 => { + 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 => { + 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); +}); + +// Already requested, partial, available and blocked media show a disabled button that cannot submit; +// a deleted one (status 7) can be requested again. +for (const split of [false, true]) for (const [label, status, disabled] of [['pending', 2, true], ['requested', 3, true], ['partial', 4, true], ['available', 5, true], ['blocked', 6, true], ['deleted', 7, false]]) { + test(`movie ${label} status ${disabled ? 'renders a disabled button that cannot submit' : 'stays requestable'} (split=${split})`, async t => { + const h = uiSetup(t, { config: { JellyseerrEnable4KRequests: split } }); h.item.mediaInfo = { status }; h.configure(); + const button = mainButton(h, split); button.click(); await flush(); + assert.equal(button.disabled, true); assert.equal(h.calls.length, disabled ? 0 : 1); + if (disabled) assert.equal(button.classList.contains('jellyseerr-button-request'), false); + }); +} +for (const [label, status] of [['available', 5], ['blocked', 6]]) test(`tv ${label} status renders a disabled button`, t => { + const h = uiSetup(t); h.item = { id: 42, mediaType: 'tv', name: 'Show', mediaInfo: { status } }; h.configure(); + assert.equal(h.button.disabled, true); +}); + +/** + * Opens the real season-selection modal over a stubbed modal shell. `save` clicks Request. + * @returns {Promise} The harness plus the modal element, its request button and `save`. + */ +async function seasonModal(t, { tvDetails, partial = true, specials = true, post } = {}) { + const h = setup(t, { post }); + for (const file of ['seerr-status.js', 'ui/ui-icons.js', 'ui/ui-quota.js', 'ui/ui-season-modal.js']) h.load(`jellyseerr/${file}`); + h.JE.internals.jellyseerrUi.markCardRequested = () => {}; + Object.assign(h.api, { fetchRequestSettings: async () => ({ partialRequestsEnabled: partial, enableSpecialEpisodes: specials }), + fetchTvShowDetails: async () => tvDetails, fetchSonarrLookup: async () => [], fetchUserQuota: async () => null, fetchTvSeasonDetails: async () => ({}) }); + let modal; + h.JE.jellyseerrModal = { createAdvancedOptionsHTML: () => '', populateAdvancedOptions() {}, create(o) { + const el = h.document.createElement('div'); el.innerHTML = `
${o.bodyHtml}
`; + h.document.body.append(el); modal = { el, o }; return { modalElement: el, show() {} }; } }; + await h.JE.jellyseerrUI.showSeasonSelectionModal(42, 'tv', 'Show', null, false); + const requestBtn = h.document.createElement('button'); + return { ...h, el: modal.el, requestBtn, save: () => modal.o.onSave(modal.el, requestBtn, () => {}) }; +} +const seasonCheckbox = (h, number) => h.el.querySelector(`.jellyseerr-season-checkbox[data-season-number="${number}"]`); +const tvSeasons = [{ seasonNumber: 0, name: 'Specials', episodeCount: 2, airDate: '2020-01-01' }, { seasonNumber: 1, name: '', episodeCount: 3, airDate: '2020-01-01' }, { seasonNumber: 2, episodeCount: 3, airDate: '2021-01-01' }]; + +test('season modal sends a stored TVDB id with the selected seasons', async t => { + const h = await seasonModal(t, { tvDetails: { seasons: tvSeasons, externalIds: {}, mediaInfo: { tvdbId: 555, seasons: [] } } }); + assert.equal(h.el.querySelector('#jellyseerr-tvdb-id'), null); + seasonCheckbox(h, 1).checked = true; await h.save(); + assert.deepEqual(h.calls[0][1].body.seasons, [1]); assert.equal(h.calls[0][1].body.tvdbId, 555); +}); + +// #653: without a TVDB id Seerr accepts the request, then Sonarr drops it. +for (const partial of [true, false]) test(`season modal requires a TVDB match when TMDB has none and sends it (partial=${partial})`, async t => { + const h = await seasonModal(t, { partial, tvDetails: { seasons: tvSeasons, externalIds: {}, mediaInfo: { seasons: [] } } }); + if (partial) seasonCheckbox(h, 1).checked = true; + await h.save(); + assert.equal(h.calls.length, 0); assert.equal(h.toasts.at(-1)[0], 'jellyseerr_modal_toast_tvdb_required'); assert.equal(h.requestBtn.disabled, false); + // Seerr stores the id in a 32-bit column: a larger one is rejected like a missing one. + h.el.querySelector('#jellyseerr-tvdb-id').value = '2147483648'; await h.save(); assert.equal(h.calls.length, 0); + h.el.querySelector('#jellyseerr-tvdb-id').value = '777'; await h.save(); + assert.equal(h.calls.length, 1); assert.equal(h.calls[0][1].body.tvdbId, 777); + assert.deepEqual(h.calls[0][1].body.seasons, partial ? [1] : [1, 2]); +}); + +test('season modal escapes season names and leaves Specials out of a whole-show request', async t => { + const h = await seasonModal(t, { partial: false, tvDetails: { seasons: tvSeasons, externalIds: { tvdbId: 1 } } }); + assert.equal(h.el.querySelector('.jellyseerr-season-name img'), null); + assert.equal(h.el.querySelectorAll('.jellyseerr-season-name')[1].textContent, ''); + assert.ok(seasonCheckbox(h, 0), 'Specials are listed when Seerr enables them'); + await h.save(); assert.deepEqual(h.calls[0][1].body.seasons, [1, 2]); +}); + +test('season modal disables requested seasons but re-offers one Seerr still calls available after its deletion', async t => { + // Season 2 is "available" in Seerr, but nothing in Jellyfin backs it any more (no media id). + const h = await seasonModal(t, { tvDetails: { seasons: tvSeasons, externalIds: { tvdbId: 1 }, mediaInfo: { seasons: [{ seasonNumber: 1, status: 3 }, { seasonNumber: 2, status: 5 }] } } }); + assert.equal(seasonCheckbox(h, 1).disabled, true); assert.equal(seasonCheckbox(h, 2).disabled, false); + seasonCheckbox(h, 2).checked = true; await h.save(); assert.deepEqual(h.calls[0][1].body.seasons, [2]); +}); + +test('season modal failure toast escapes the upstream message and re-enables the request button', async t => { + const h = await seasonModal(t, { tvDetails: { seasons: tvSeasons, externalIds: { tvdbId: 1 } }, post: async () => { throw { status: 500, responseJSON: { message: '' } }; } }); + seasonCheckbox(h, 1).checked = true; await h.save(); + assert.equal(h.toasts.at(-1)[0], '<img src=x>'); assert.equal(h.requestBtn.disabled, false); + assert.equal(h.requestBtn.textContent, 'jellyseerr_modal_request_selected'); +}); + +/** + * Loads the real movie/collection request modals over a stubbed modal shell with + * server/quality/folder selects. `save` clicks Request on the last modal opened. + */ +function requestModals(t, options) { + const h = setup(t, options); + for (const file of ['seerr-status.js', 'ui/ui-icons.js', 'ui/ui-quota.js', 'ui/ui-request-modals.js']) h.load(`jellyseerr/${file}`); + h.JE.cdn = { url: () => 'https://cdn.test/poster.png' }; + // Distinct values, so a server/quality mix-up reaches the request body. + const values = { 'movie-server': 2, 'movie-quality': 5, 'movie-folder': '/movies' }; + const select = id => ``; + let current; + h.JE.jellyseerrModal = { createAdvancedOptionsHTML: () => ['movie-server', 'movie-quality', 'movie-folder'].map(select).join(''), populateAdvancedOptions() {}, + create(o) { const el = h.document.createElement('div'); el.innerHTML = `
${o.bodyHtml}
`; h.document.body.append(el); current = { el, o }; return { modalElement: el, show() {} }; } }; + h.requestBtn = h.document.createElement('button'); + h.modal = () => current.el; + h.save = () => current.o.onSave(current.el, h.requestBtn, () => {}); + return h; +} + +test('movie request modal needs every advanced option before it submits them', async t => { + const h = requestModals(t); h.api.fetchAdvancedRequestData = async () => ({}); + await h.JE.jellyseerrUI.showMovieRequestModal(42, 'Film', null); + const ids = ['movie-server', 'movie-quality', 'movie-folder']; + await h.save(); assert.equal(h.calls.length, 0); assert.equal(h.toasts.at(-1)[0], 'jellyseerr_modal_toast_options_missing'); + // Any one option left empty blocks the request on its own. + for (const empty of ids) { + for (const id of ids) h.modal().querySelector(`#${id}`).selectedIndex = id === empty ? 0 : 1; + const toasts = h.toasts.length; + await h.save(); + assert.equal(h.calls.length, 0, `${empty} empty`); + assert.equal(h.toasts.length, toasts + 1); assert.equal(h.toasts.at(-1)[0], 'jellyseerr_modal_toast_options_missing'); + } + for (const id of ids) h.modal().querySelector(`#${id}`).selectedIndex = 1; + await h.save(); assert.equal(h.calls.length, 1); + assert.deepEqual([h.calls[0][1].body.serverId, h.calls[0][1].body.profileId, h.calls[0][1].body.rootFolder], [2, 5, '/movies']); +}); + +test('collection request modal escapes titles, skips owned movies and stops at the quota', async t => { + let posts = 0; + const h = requestModals(t, { post: async () => { if (++posts === 1) throw { status: 403, responseJSON: { message: 'Movie Quota exceeded.' } }; return { id: 7 }; } }); + const dialogs = []; h.window.Dashboard = { alert: data => dialogs.push(data) }; + // The quote would end the poster's alt attribute and start an onerror one if it were not escaped. + const hostile = '" onerror="evil()'; + h.api.fetchCollectionDetails = async () => ({ parts: [{ id: 1, title: hostile, mediaInfo: { status: 5 } }, { id: 2, title: 'Two' }, { id: 3, title: 'Three' }] }); + await h.JE.jellyseerrUI.showCollectionRequestModal(9, 'Saga'); + assert.equal(h.modal().querySelector('.jellyseerr-collection-movie-details .title img'), null); + assert.equal(h.modal().querySelector('.jellyseerr-collection-movie-poster').getAttribute('alt'), hostile); + assert.equal(h.modal().querySelector('[onerror]'), null); + assert.equal(h.modal().querySelector('#movie-1').disabled, true); + await h.save(); + // The quota rejection on the first selected movie ends the batch: nothing is sent for the rest. + assert.deepEqual(h.calls.map(call => call[1].body.mediaId), [2]); + assert.equal(dialogs.length, 1); +}); diff --git a/tests/frontend/run.mjs b/tests/frontend/run.mjs new file mode 100644 index 000000000..e3954b190 --- /dev/null +++ b/tests/frontend/run.mjs @@ -0,0 +1,7 @@ +import { spawnSync } from 'node:child_process'; +import { discoverTests, repositoryRoot, testArguments, testEnvironment } from './helpers/discovery.mjs'; + +const result = spawnSync(process.execPath, + ['--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/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..beb98172f --- /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 the newest official Jellyfin image of each compatibility line: `jellyfin/jellyfin:10.11` (the newest 10.11.x) and `jellyfin/jellyfin:12` (the newest 12.x; `latest` is deliberately avoided because it would move to the next major line). Every run first pulls the tag, so a stale local image is never tested; a failed pull fails the run instead of falling back to a cached image. The container then runs the exact pulled digest. Because the tags move, each target's `report.json` records what actually ran: `image` (the tag), `resolved_image` (the repository digest) and `server_version` (from `/System/Info/Public`); the run log prints both too. A run covers only the release the tag pointed at that day, not every patch release. + +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. Pulling the Jellyfin images and first-time NuGet retrieval require internet access on the runner; cached NuGet packages allow the build to run offline. + +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` (tag, resolved digest, server version, passed checks and status) 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 + +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..2fbf5e1d2 --- /dev/null +++ b/tests/host/run.py @@ -0,0 +1,335 @@ +#!/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] +# Floating tags: each run pulls the newest release of its compatibility line and +# records the digest and server version it actually tested in report.json. +# Never use `latest`, which would move to the next major line. +IMAGES = { + "jf10": "jellyfin/jellyfin:10.11", + "jf12": "jellyfin/jellyfin:12", +} +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): + 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() + + +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.""" + allocated = [] + for network_id in command("docker", "network", "ls", "-q").splitlines(): + # One at a time: a network another process removes after the listing is simply gone, + # rather than failing a multi-ID inspect. + details = owned("docker", "network", "inspect", network_id) + if details is None: + continue + allocated.extend(ipaddress.ip_network(entry["Subnet"]) for network in json.loads(details) + 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 + 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 pull(image): + """Pull the tag and return its repo digest. A failed pull is an error, never a fallback to a cached image.""" + print(f"Pulling {image}", flush=True) + try: + command("docker", "pull", image) + except RuntimeError as error: + raise RuntimeError(f"Could not pull {image}; refusing to test a possibly stale cached image. {error}") from error + resolved = command("docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", image) + print(f"Resolved {image} to {resolved}", flush=True) + return resolved + + +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 + report = {"target": target, "image": IMAGES[target], "resolved_image": None, "server_version": None} + with tempfile.TemporaryDirectory(prefix=name) as temporary: + temp = pathlib.Path(temporary) + temp.chmod(0o755) + try: + report["resolved_image"] = pull(IMAGES[target]) + 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) + create_network(name) + (temp / "cache").mkdir() + 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", + report["resolved_image"]) + inspection = json.loads(command("docker", "inspect", name))[0] + address = inspection["NetworkSettings"]["Networks"][name]["IPAddress"] + host = Host("http://" + address + ":8096") + host.ready() + report["server_version"] = host.request("/System/Info/Public")["Version"] + print(f"Jellyfin server version {report['server_version']} ({report['resolved_image']})", flush=True) + 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) + report.update({"passed": host.passed, "status": "passed"}) + (output / "report.json").write_text(json.dumps(report, indent=2)) + print(f"{target}: {len(host.passed)} checks passed on Jellyfin {report['server_version']} ({report['resolved_image']})", flush=True) + except BaseException as error: + report.update({"passed": host.passed if host else [], "status": "failed", "error": str(error)}) + (output / "report.json").write_text(json.dumps(report, indent=2)) + raise + finally: + # 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 = [] + 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)) + for removal in (("docker", "rm", "-f", name), ("docker", "network", "rm", name)): + try: + owned(*removal) + 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..69dfafcb9 --- /dev/null +++ b/tests/inventory/generate.py @@ -0,0 +1,105 @@ +#!/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 +keeps file paths but no line numbers, line counts or locale key counts, so ordinary +edits and translation updates leave it unchanged. It changes when any of these does: +- a production .cs/.js/.html/.css/.json file or locale file is added, removed or renamed; +- an HTTP route's verb, path or method name, or the attributes between the route and + its method (such as [Authorize] policies); +- a Configuration property's name, type, initializer (default value) or constructor + assignment; +- a scheduled task (any class implementing IScheduledTask) or its Key; +- a .json/.xml/.db/.sqlite storage literal in a .cs or .js file; +- a build target's selector, TargetFramework or JellyfinVersion in JellyfinEnhanced.csproj. +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 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': 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': [], + 'storage_references': [], 'scheduled_tasks': [], 'locales': []} + # as_posix key: Path ordering is case-insensitive on Windows, so sort by string for identical output everywhere + for path in sorted(PRODUCTION.rglob('*'), key=lambda p: p.relative_to(ROOT).as_posix()): + 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}) + if category == 'Controllers' and path.suffix == '.cs': + # Each action's template is relative to its controller's class-level [Route]; + # a controller without one falls back to JellyfinEnhanced. + controllers = [(declaration.start(), next(iter(re.findall(r'\[Route\("([^"\n]*)"\)\]', declaration[1])), 'JellyfinEnhanced')) + for declaration in re.finditer(r'((?:\[[^\n]+\]\s*)*)public\s+(?:sealed\s+|partial\s+)*class\s+\w+\s*:\s*(?:ControllerBase|Controller)\b', text)] + 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 '' + prefix = next((route for start, route in reversed(controllers) if start < match.start()), 'JellyfinEnhanced') + result['routes'].append({'verb': match[1].upper(), 'path': '/' + prefix.strip('/') + '/' + match[2], + 'method': method[1] if method else None, 'file': relative, + '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}) + if path.suffix in ('.cs', '.js'): + # 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) + # Any plugin class implementing IScheduledTask, wherever it lives (Services/StartupService.cs too). + if path.suffix == '.cs': + for task in re.finditer(r'\bclass\s+(\w+)[^{;]*?:[^{;]*?\bIScheduledTask\b', text): + result['scheduled_tasks'].append({'file': relative, 'class': task[1], + 'key': next(iter(re.findall(r'\bKey\s*=>\s*"([^"]+)"', text)), None)}) + if 'js/locales/' in relative and path.suffix == '.json': + 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' + + +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 66% rename from scripts/poster-tags-parity/PosterTagParity/PosterTagParity.csproj rename to tests/poster/parity/PosterTagParity/PosterTagParity.csproj index 114f62b82..652d881c1 100644 --- a/scripts/poster-tags-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 @@ -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..650a10d5d --- /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 deletes and recreates `data/inputs`, so a previous live-server capture never mixes into an offline run; keep captures you want elsewhere (`fetch-inputs.mjs --out DIR`). 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 96% rename from scripts/poster-tags-parity/gen-resolution-data.mjs rename to tests/poster/parity/gen-resolution-data.mjs index bb105f3fe..29dfdf729 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' }); @@ -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')}, `; @@ -250,7 +251,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 79% rename from scripts/poster-tags-parity/names-expected.mjs rename to tests/poster/parity/names-expected.mjs index b1459124b..5a95ea2e5 100644 --- a/scripts/poster-tags-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,19 @@ for (const code of ['pt_BR', 'root', 'e', 'en--us', '-en', 'en-', 'x-klingon', ' codes.add(code); } +// A script variant (baku1926, colb1945, luna1918, petr1708) becomes the script only when no script or +// region is present after aliasing and it sorts first among the remaining variants. +for (const lang of ['az', 'tk', 'en', 'ru', 'sh', 'hbs', 'cnr', 'prs', 'drw', 'swc', 'tnf', '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', + 'baku1926-1994', '1994-baku1926', 'abcde-baku1926', 'baku1926-abcde', 'a1234-baku1926', 'bakuzzzz-baku1926', 'baku1926-zzzzz', + 'baku1926-1994-zzzzz', 'abcde-baku1926-fonipa', 'fonipa-baku1926-1994', 'arevela-baku1926', 'lojban-baku1926', + 'petr1708', 'abcde-petr1708', 'petr1708-zzzzz', 'luna1918-petr1708', 'petr1708-luna1918', 'mmmmm-luna1918', '1994-luna1918', + 'luna1918-heploc', 'colb1945', '1994-colb1945', 'baku1926-colb1945', 'colb1945-baku1926', 'US-petr1708', 'Cyrl-petr1708']) { + codes.add(`${lang}-${tail}`); + } +} + const expected = {}; for (const code of codes) { let name; diff --git a/tests/poster/parity/offline-inputs.mjs b/tests/poster/parity/offline-inputs.mjs new file mode 100644 index 000000000..7ea32e036 --- /dev/null +++ b/tests/poster/parity/offline-inputs.mjs @@ -0,0 +1,15 @@ +// Credential-free inputs. The profiles generator supplies the exhaustive synthetic metadata. +import { mkdirSync, rmSync, 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'); +// Start empty: later steps read every tagcache-* file here, so a stale live capture would leak in. +rmSync(inputs, { recursive: true, force: true }); +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 85% rename from scripts/poster-tags-harness/PosterTagsHarness.csproj rename to tests/poster/rendering/PosterTagsHarness.csproj index 482e652d6..1966c7a9f 100644 --- a/scripts/poster-tags-harness/PosterTagsHarness.csproj +++ b/tests/poster/rendering/PosterTagsHarness.csproj @@ -1,7 +1,8 @@