diff --git a/.github/workflows/codeql-lint.yml b/.github/workflows/codeql-lint.yml index af950d1a..4572f318 100644 --- a/.github/workflows/codeql-lint.yml +++ b/.github/workflows/codeql-lint.yml @@ -15,6 +15,10 @@ on: - 'backend/**' - 'main.py' - 'requirements.txt' + - 'requirements-*.txt' + - 'pyproject.toml' + - 'Makefile' + - 'deploy/docker/compose.yaml' - 'frontend/package-lock.json' - 'deploy/docker/Dockerfile' - '.dockerignore' @@ -32,6 +36,10 @@ on: - 'backend/**' - 'main.py' - 'requirements.txt' + - 'requirements-*.txt' + - 'pyproject.toml' + - 'Makefile' + - 'deploy/docker/compose.yaml' - 'frontend/package-lock.json' - 'deploy/docker/Dockerfile' - '.dockerignore' @@ -165,7 +173,8 @@ jobs: # named after a develop-only extension may exist anywhere in the tree. branch-isolation-guard: name: Branch Isolation Guard - if: github.ref == 'refs/heads/main' || github.base_ref == 'main' + # Always runs (no job-level if) so it stays a satisfiable needs: dependency for + # the docker jobs on every ref; the step below no-ops on non-main-bound refs. runs-on: ubuntu-latest steps: @@ -175,6 +184,11 @@ jobs: - name: Fail if develop-only extension code is present run: | set -eu + # Only main-bound refs must be extension-free; pass on any other ref. + if [ "${GITHUB_REF}" != "refs/heads/main" ] && [ "${GITHUB_BASE_REF:-}" != "main" ]; then + echo "Not a main-bound ref — branch-isolation check not applicable." + exit 0 + fi leaks=$(git ls-files -- '*cl2k*' \ 'backend/extensions/*' ':!backend/extensions/__init__.py' \ 'frontend/src/extensions/*' ':!frontend/src/extensions/index.js') @@ -189,7 +203,7 @@ jobs: docker-validate: name: Docker Validate (PR) if: github.event_name == 'pull_request' - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard] runs-on: ubuntu-latest steps: @@ -226,7 +240,7 @@ jobs: docker-push: name: Docker Build & Push if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard] runs-on: ubuntu-latest # Serialize with the release-please docker-version job so concurrent pushes # to the same :latest tag don't race on GHCR auth. cancel-in-progress is @@ -289,7 +303,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} notify-failure: - needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, docker-push] + needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard, docker-push] if: failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') uses: chodeus/chodeus-ops/.github/workflows/notify-discord.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main with: diff --git a/.github/workflows/repo-events.yml b/.github/workflows/repo-events.yml index 1c9bbfec..0d07446a 100644 --- a/.github/workflows/repo-events.yml +++ b/.github/workflows/repo-events.yml @@ -14,6 +14,9 @@ on: discussion: types: [created] +permissions: + contents: read + jobs: notify: uses: chodeus/chodeus-ops/.github/workflows/repo-events.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e7e81ad8..1b6a7863 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -5,6 +5,10 @@ on: - cron: '0 5 * * *' # daily workflow_dispatch: +permissions: + issues: write + pull-requests: write + jobs: stale: uses: chodeus/chodeus-ops/.github/workflows/stale-issues.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 863e1c57..904d5051 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.36.0" + ".": "2.38.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f25e8d6c..0017a816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,81 @@ All notable changes to CHUB are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.38.0](https://github.com/chodeus/chub/compare/v2.37.0...v2.38.0) (2026-07-04) + + +### Features + +* **extensions:** add stream_prefixes() hook for extension stream routes ([7ce6841](https://github.com/chodeus/chub/commit/7ce684101687a3060d68b1938a98ffbc9d86078d)) + + +### Bug Fixes + +* LIKE escaping, optimize clobber-guard, transient-cache, 0-is-falsy, logout cache, CI perms ([01237f6](https://github.com/chodeus/chub/commit/01237f6adbcffc6381d97fca45ff2a76256742c9)) +* **plex_metadata:** don't cache a bloat scan when the Plex DB copy failed ([0518256](https://github.com/chodeus/chub/commit/0518256751ebf691571bf35fc433b3890f7b7647)) + +## [2.37.0](https://github.com/chodeus/chub/compare/v2.36.0...v2.37.0) (2026-07-01) + + +### Features + +* **auth:** short-lived scoped stream tokens for image/SSE URLs ([1b5e8e4](https://github.com/chodeus/chub/commit/1b5e8e46daafad2d349d0b9b1b65cebce7eba5fd)) +* **fields:** add generic action_button settings field type ([b4ec1e2](https://github.com/chodeus/chub/commit/b4ec1e2c403fa9f12f156493551f35f037609ebf)) +* **gdrive:** confirm drive removal and optionally delete local folder + cache rows ([5182971](https://github.com/chodeus/chub/commit/5182971a3e4083fa683e56d349598ab51611ef5c)) +* **instances:** support renaming an instance end to end ([49c65ed](https://github.com/chodeus/chub/commit/49c65ed3d9e7b60230e125dba6f70d90445a200b)) +* **posters:** unified, readable CL2K/MM2K provenance stamp ([9ff36a6](https://github.com/chodeus/chub/commit/9ff36a6aa622460a5da446c07c3ff56f795b570c)) +* **stats:** count Sonarr at the episode level (shows/seasons as context) ([00044c8](https://github.com/chodeus/chub/commit/00044c8213653051e64f40e1097900c382b5782b)) +* **stats:** order Artist above Album in By type ([dc9d94e](https://github.com/chodeus/chub/commit/dc9d94e819fcd8bbbb92b1e6da2b411cd1c0ecef)) +* **stats:** pair By type with By instance, consistent card borders ([df45aa7](https://github.com/chodeus/chub/commit/df45aa7d29318618a30fe8ea1bcbd418bc39f6f2)) +* **stats:** regroup Library Statistics by synced instance ([e1853ac](https://github.com/chodeus/chub/commit/e1853ac2bbbb8a7f4b57a59c1c89319c3d2791a0)) +* **stats:** show Sonarr Series AND Episodes as separate cards ([ac3fa0e](https://github.com/chodeus/chub/commit/ac3fa0e74ef3a70e8f8380f885f02ba24da7104d)) +* **sync:** background instance reconciliation from the Instances page ([0618712](https://github.com/chodeus/chub/commit/06187121d53097caf9f05c838b2dbfc06a9c1839)) +* **webhooks:** banner when ingest endpoints are unauthenticated ([8177c90](https://github.com/chodeus/chub/commit/8177c900a77ebdee954fd8c1de81a6838c5b04b2)) + + +### Bug Fixes + +* **auth:** keep the stream token warm + rebuild image URLs when it lands ([ac0758b](https://github.com/chodeus/chub/commit/ac0758bf92a0e4d27638a8683dc0f277ab86af60)) +* backend correctness and reliability fixes ([03191ea](https://github.com/chodeus/chub/commit/03191ead5422f7871ee4d1c2aa86a5a17a18cefa)) +* **config:** split a shared-webhook notification destination by event ([1908da5](https://github.com/chodeus/chub/commit/1908da560ef49fbb52fd1617135155dc956ad53f)) +* **data-loss:** guard destructive poster/nohl/upload paths ([274588c](https://github.com/chodeus/chub/commit/274588ce7b7d0df4cef43d871bbf0d6c9298dd9d)) +* **docker:** download rclone to its checksummed filename so sha256sum -c passes ([10dddfa](https://github.com/chodeus/chub/commit/10dddfad573968b6e160876c3eb530bc5785b1f0)) +* **fields:** action_button ignores a stale result after its row changes ([89b3586](https://github.com/chodeus/chub/commit/89b35865286446686a90c6fc68d7d555643b935e)) +* **frontend:** cache invalidation, callbacks, and falsy-value bugs ([5219923](https://github.com/chodeus/chub/commit/52199231c1f01a6430a0a7c35caa6a6d1e48d4cd)) +* **frontend:** FloatField 0-value, duplicates-resolve cache, instance keys ([583bb7e](https://github.com/chodeus/chub/commit/583bb7ebb090dbc7fbd65a4a6168c30138f25d92)) +* **instances:** case-insensitive source on rename + composite UI-state keys ([079bdce](https://github.com/chodeus/chub/commit/079bdce8252738a56783a9a625cc47efd5ea1b8a)) +* **instances:** disable redirects on SSRF-validated health/test probes ([184ffd9](https://github.com/chodeus/chub/commit/184ffd97d99dfde95c6c93a24c6a0a00b217478e)) +* **instances:** don't clobber stored API key with the redacted placeholder ([3878d05](https://github.com/chodeus/chub/commit/3878d058b7d2e7b993fff812e2cddc7e2b022997)) +* **instances:** read sync_schedule from the nested section response ([4d1666e](https://github.com/chodeus/chub/commit/4d1666e93f9131d0c0a75b6348d0d3d056718060)) +* **instances:** resolve redacted API key by URL when renaming ([837e23b](https://github.com/chodeus/chub/commit/837e23bc02db20d432f989ac78f89ef4b36248ae)) +* **jobs:** collapse a composite trigger to its readable base ([876e854](https://github.com/chodeus/chub/commit/876e854cada3229f99090df15a234a9d05c91cbd)) +* **jobs:** humanize the job-type label ([28967b8](https://github.com/chodeus/chub/commit/28967b8129c6b4a7183c5b8d205a575453e13f4f)) +* **logger:** redact secrets in tracebacks, yaml OAuth tokens, and the Notifiarr key ([05ef9df](https://github.com/chodeus/chub/commit/05ef9df8eddbe233efe2aab86a459faa3327dffa)) +* **modules:** redact secrets in GET /api/modules/{name} ([8dc024c](https://github.com/chodeus/chub/commit/8dc024c4f341debc8d277963f1b983f1e6827c96)) +* **notify:** stop redacted placeholder clobbering destination webhooks ([b38a128](https://github.com/chodeus/chub/commit/b38a1289fa17418f0ca24e2ef9aa17c7e12d22e2)) +* **schedule:** reject malformed schedule strings at the API ([40e2233](https://github.com/chodeus/chub/commit/40e22334899a2d3fef2fe996f6606d9e3e8039d1)) +* **security:** fail closed on config error + warn on unauthenticated webhook ingest ([23336ef](https://github.com/chodeus/chub/commit/23336ef7f488ca3fd991276aeac440950294b019)) +* **security:** fail-closed SSRF guard, key-exfil + redirect guards, auth-section write block ([012ce45](https://github.com/chodeus/chub/commit/012ce4563b04fdbcae94482e30a23b7d45accaad)) +* **stats:** derive Lidarr has_content from track files for accurate library stats ([28dfaff](https://github.com/chodeus/chub/commit/28dfaff77ac7adb7504d8a5b0a57f5b0b24df433)) +* **stats:** exclude unmonitored-artist albums from Lidarr Missing ([a4e6eab](https://github.com/chodeus/chub/commit/a4e6eabf726189efcca898bf229a3b0c1900a645)) +* **stats:** normalize instance source case (title-cased source broke labels/order/freshness) ([3b4165e](https://github.com/chodeus/chub/commit/3b4165ee504391a5d9492ef45fbbe1d72a7dd9fb)) +* **stats:** normalize media_cache.source at the write boundary ([5236831](https://github.com/chodeus/chub/commit/52368316db4f284708b21f1ae3d8e6c702e7264e)) +* **stats:** read the matched_posters_stats key so Top contributors renders ([ae30605](https://github.com/chodeus/chub/commit/ae30605394ebbd262a8fc10af6b8523f0e46fba0)) +* **stats:** release-gate Missing + add Upcoming; drop Statistics refresh ([a46a416](https://github.com/chodeus/chub/commit/a46a4160a2b675e5a92222063a89a157a70342dc)) +* **unmatched:** move recently-matched provenance stamp to the poster top-left ([3f04e25](https://github.com/chodeus/chub/commit/3f04e258c7b9981808b1e368a4d6b1c9b9da81be)) +* **webhooks:** stop clobbering per-season has_content/monitored on upsert ([5ab982c](https://github.com/chodeus/chub/commit/5ab982cfe701ad003f9ca134ba72e56a24014672)) +* **worker:** scope startup job reset to the worker's own partition ([982a7b1](https://github.com/chodeus/chub/commit/982a7b15cdd92ef774ec79b275be1b7271e46aa5)) + + +### Performance + +* **instances:** drop the live wanted/missing call from the Instances page ([cc5a075](https://github.com/chodeus/chub/commit/cc5a07561db2f1843789d1d845d2a6c091344c12)) + + +### Refactoring + +* **instances:** drop the service-type badge from instance rows ([838dab5](https://github.com/chodeus/chub/commit/838dab5976f2ea79ab6671a7a1cead4894bbb189)) + ## [2.36.0](https://github.com/chodeus/chub/compare/v2.35.0...v2.36.0) (2026-06-28) diff --git a/backend/api/main.py b/backend/api/main.py index b9956859..5d6e5ed4 100755 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -36,7 +36,7 @@ webhooks as webhooks_router, ) from backend.api.utils import error, get_logger -from backend.extensions import extension_routers +from backend.extensions import extension_routers, extension_stream_prefixes from backend.util.auth import decode_access_token from backend.util.config import ( ConfigError, @@ -173,7 +173,8 @@ async def dispatch(self, request: Request, call_next): # valid for GET requests on the stream allowlist — never for mutations or # general API access, so a leaked image/SSE URL can't do more than that. if payload.get("scope") == "stream" and ( - request.method != "GET" or not path.startswith(STREAM_PATH_PREFIXES) + request.method != "GET" + or not path.startswith(STREAM_PATH_PREFIXES + extension_stream_prefixes()) ): self._log_unauthorized(request, "stream token off stream route", path) return JSONResponse( diff --git a/backend/api/posters.py b/backend/api/posters.py index 5b96f518..4a2ec9eb 100644 --- a/backend/api/posters.py +++ b/backend/api/posters.py @@ -1112,6 +1112,17 @@ def _optimize_posters_sync( dest_path = ( base + target_ext if needs_convert else full_path ) + # Don't clobber a different pre-existing file at the + # converted extension (e.g. a Kometa .jpg beside a + # Plex .png) — leave both, mirror poster_self_heal. + if dest_path != full_path and os.path.exists(dest_path): + os.unlink(tmp.name) + logger.warning( + f"optimize target exists, skipped to avoid " + f"clobber: {dest_path}" + ) + skipped += 1 + continue shutil.move(tmp.name, dest_path) if dest_path != full_path: if os.path.exists(full_path): @@ -2364,9 +2375,13 @@ async def delete_gdrive_local( # left behind either way. import shutil + # Delete the CONFIGURED folder (re-resolved), authorized by the membership + # match above — never the raw request path, so a validated request string + # can't reach rmtree. target == req_real by that match. + target = os.path.realpath(matched.location) folder_removed = False - if os.path.isdir(req_real): - shutil.rmtree(req_real) + if os.path.isdir(target): + shutil.rmtree(target) folder_removed = True deleted_rows = db.poster.delete_by_path_prefix(matched.location) @@ -3832,10 +3847,18 @@ async def delete_poster( try: # Find media items that were matched to this poster by original_file if full_path: + # Escape LIKE metacharacters so a basename with %/_ can't + # unmatch the wrong media rows (mirror poster_cache). + basename_like = ( + os.path.basename(full_path) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) media_items = ( db.media.execute_query( - "SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ?", - (f"%{os.path.basename(full_path)}%",), + "SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ? ESCAPE '\\'", + (f"%{basename_like}%",), fetch_all=True, ) or [] diff --git a/backend/extensions/__init__.py b/backend/extensions/__init__.py index 7da05840..aecbef71 100644 --- a/backend/extensions/__init__.py +++ b/backend/extensions/__init__.py @@ -16,6 +16,9 @@ (consumed by backend/util/config.py). tables() -> list[TableDefinition] Extra database tables (consumed by SchemaManager.__init__). + stream_prefixes() -> Iterable[str] + URL path prefixes whose GET routes accept a scope="stream" token + (merged into AuthMiddleware's STREAM_PATH_PREFIXES allowlist). Hook functions should import their payloads lazily (inside the function body, not at manifest import time): manifests are imported while core @@ -84,3 +87,14 @@ def extension_notification_formatters() -> Dict[str, Any]: for entry in _collect("notification_formatters"): merged.update(entry) return merged + + +def extension_stream_prefixes() -> Tuple[str, ...]: + """URL path prefixes on which an extension's GET routes accept a + scope="stream" token, merged into AuthMiddleware's STREAM_PATH_PREFIXES so an + extension can serve /SSE endpoints authenticated by a short-lived stream + token instead of a header. Each manifest's ``stream_prefixes()`` returns an + iterable of str prefixes. Empty on main (no extensions).""" + return tuple( + prefix for prefixes in _collect("stream_prefixes") for prefix in prefixes + ) diff --git a/backend/util/database/media_cache.py b/backend/util/database/media_cache.py index b418dbe0..35f5f7fa 100755 --- a/backend/util/database/media_cache.py +++ b/backend/util/database/media_cache.py @@ -872,8 +872,22 @@ def search( # whose normalized form has the punctuation stripped. Raw # title LIKE is the case-insensitive fallback for exact # stored substrings. Same fix as poster_cache.search. - sub = ["normalized_title LIKE ?", "title LIKE ?"] - sub_params: List[Any] = [f"%{normalize_titles(query)}%", f"%{query}%"] + # Escape LIKE metacharacters so a query with %/_ matches literally + # (mirrors poster_cache.delete_by_path_prefix). + norm_esc = ( + normalize_titles(query) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + raw_esc = ( + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + ) + sub = [ + "normalized_title LIKE ? ESCAPE '\\'", + "title LIKE ? ESCAPE '\\'", + ] + sub_params: List[Any] = [f"%{norm_esc}%", f"%{raw_esc}%"] # Also match an id pasted from a filename tag ({tmdb-…}/{tvdb-…}/ # {imdb-tt…}) or a bare IMDb id, so users can search by id. tmdb, tvdb, imdb = parse_search_id(query) @@ -894,9 +908,14 @@ def search( params.append(asset_type) if genres: - genre_conditions = ["genre LIKE ?" for _ in genres] + genre_conditions = ["genre LIKE ? ESCAPE '\\'" for _ in genres] conditions.append(f"({' OR '.join(genre_conditions)})") - params.extend(f"%{g}%" for g in genres) + params.extend( + "%" + + g.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + "%" + for g in genres + ) if year_min is not None: conditions.append("CAST(year AS INTEGER) >= ?") diff --git a/backend/util/database/poster_cache.py b/backend/util/database/poster_cache.py index d90e46d5..718479f3 100755 --- a/backend/util/database/poster_cache.py +++ b/backend/util/database/poster_cache.py @@ -92,8 +92,14 @@ def esc(s: str) -> str: ] sub_params: list = [norm_pat, raw_pat] else: - sub = ["normalized_title LIKE ?", "title LIKE ?"] - sub_params = [f"%{normalize_titles(query)}%", f"%{query}%"] + sub = [ + "normalized_title LIKE ? ESCAPE '\\'", + "title LIKE ? ESCAPE '\\'", + ] + sub_params = [ + f"%{esc(normalize_titles(query))}%", + f"%{esc(query)}%", + ] tmdb, tvdb, imdb = parse_search_id(query) if tmdb is not None: diff --git a/backend/util/plex_metadata.py b/backend/util/plex_metadata.py index 305dcddb..339f62c5 100644 --- a/backend/util/plex_metadata.py +++ b/backend/util/plex_metadata.py @@ -562,6 +562,28 @@ def _canonical_rank(b: Dict[str, Any]) -> tuple: "scanned_at": time.time(), }, } + # A failed Plex DB copy leaves in_use empty, so every poster on disk looks like + # deletable bloat with a blank title. When that happens WITH posters present, + # return an unavailable result and DON'T cache it: a transient DB blip must not + # be negative-cached (the per-variant delete has no in-use guard, so a poisoned + # cache could delete active artwork). An empty tree can't misclassify anything, + # so it still caches normally. + if db_path is None and bundles: + return { + "bundles": [], + "libraries": [], + "media_types": [], + "variant_kinds": [], + "unavailable": True, + "reason": "Could not read Plex's database (it may be busy); run the scan again.", + "stats": { + "bundle_count": 0, + "variant_count": 0, + "bloat_count": 0, + "bloat_size": 0, + "scanned_at": time.time(), + }, + } _cache_put(cache_key, result) return result diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 078d400b..5da8decb 100755 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -81,8 +81,10 @@ COPY --from=builder /usr/bin/rclone /usr/bin/rclone # Copy built frontend assets COPY --from=builder /app/frontend/dist ./frontend/dist -# Copy app source files excluding node_modules etc (already filtered by .dockerignore) +# Copy app source from the builder. .dockerignore only filters the host context, +# not --from=builder, so drop the builder's npm-ci node_modules to stay lean. COPY --from=builder /app . +RUN rm -rf /app/frontend/node_modules # Copy frontend build output into /app/public (served via STATIC_DIR env var) RUN mkdir -p /app/public && \ diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index 7f1c42b9..9e1f833c 100755 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -1,6 +1,6 @@ services: chub: - image: ghcr.io/chodeus/chub:latest@sha256:1d25f1f819fa9248f5bb674f3b6695bcf0bdbcd958d68951a732c2650b79208e + image: ghcr.io/chodeus/chub:latest@sha256:aa34f9de43275074d250e82583966634bc34155334dd77fa452f7633dbefba5b container_name: chub restart: unless-stopped diff --git a/frontend/src/components/fields/primitives/InputBase.jsx b/frontend/src/components/fields/primitives/InputBase.jsx index 275f0621..0bc539f3 100755 --- a/frontend/src/components/fields/primitives/InputBase.jsx +++ b/frontend/src/components/fields/primitives/InputBase.jsx @@ -45,7 +45,7 @@ export const InputBase = ({ { const logout = useCallback(() => { localStorage.removeItem(TOKEN_STORAGE_KEY); clearStreamToken(); + // Drop the in-memory GET cache so a later login in the same tab can't be + // served the previous session's cached config/media/list responses. + apiCore.clearCache(); setToken(null); setUser(null); }, []); diff --git a/tests/test_cleanarr_scan_jobs.py b/tests/test_cleanarr_scan_jobs.py index 0b2d5210..0ea5d99c 100644 --- a/tests/test_cleanarr_scan_jobs.py +++ b/tests/test_cleanarr_scan_jobs.py @@ -52,6 +52,26 @@ def test_plex_metadata_scan_job_warms_cache(tmp_path): assert pm.get_cached_transcoder(plex_path) == {"count": 0, "size_bytes": 0} +def test_scan_with_posters_but_no_db_is_unavailable_and_not_cached(tmp_path): + from backend.util import plex_metadata as pm + + pm.invalidate_cache() + plex_path = str(tmp_path) + # A real uploaded poster on disk (extension-less hash under a .bundle/Uploads/), + # but NO Plex DB to copy — so the scan can't tell what's in use. It must NOT flag + # every poster as deletable bloat and cache that: a transient DB blip would then + # drive the (unguarded) per-variant delete to remove active artwork for the TTL. + uploads = tmp_path / "Metadata" / "Movies" / "a" / "x.bundle" / "Uploads" / "posters" + uploads.mkdir(parents=True) + (uploads / "abc123").write_bytes(b"posterdata") # extension-less = a real upload variant + + res = pm.scan_bundles(plex_path, force=True) + assert res.get("unavailable") is True + # A transient DB failure is not negative-cached — a re-scan once the DB is + # readable returns the real answer. + assert pm.get_cached_scan(plex_path) is None + + def test_plex_metadata_scan_job_requires_plex_path(): from backend.util.job_processor import _process_plex_metadata_scan_job diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b8eca93b..94b8d66b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -14,6 +14,7 @@ def test_aggregates_have_correct_empty_types(): assert isinstance(extensions.extension_modules(), dict) assert isinstance(extensions.extension_config_fields(), dict) assert isinstance(extensions.extension_tables(), list) + assert isinstance(extensions.extension_stream_prefixes(), tuple) def test_core_module_registry_intact(): @@ -31,3 +32,14 @@ def test_config_builds_with_extension_fields_applied(): def test_schema_builds_with_extension_tables(): manager = SchemaManager() assert "media_cache" in manager.tables + + +def test_stream_prefixes_collects_and_flattens(monkeypatch): + # A manifest's stream_prefixes() is flattened into the aggregate tuple. + class _FakeManifest: + @staticmethod + def stream_prefixes(): + return ("/api/ut-ext/",) + + monkeypatch.setattr(extensions, "_manifests", lambda: (_FakeManifest,)) + assert extensions.extension_stream_prefixes() == ("/api/ut-ext/",)