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/",)