Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# .coderabbit.yaml — CHUB (Chodeus' Media Script Hub)
# FastAPI + SQLite Python backend (main.py, backend/) + React 19 / Vite / Tailwind v4 frontend (frontend/).
# Shared infra: keep this file byte-identical on main and develop (add on main, then merge main -> develop).
language: "en-US"

reviews:
profile: "chill"
request_changes_workflow: false
auto_review:
enabled: true
drafts: false
# main is the default branch and is always auto-reviewed; develop is the
# active dev branch that feature branches are cut from and PR into.
base_branches:
- "^develop$" # anchored — base_branches are regex; unanchored could match feature/develop etc.

path_filters:
- "!**/*.db"
- "!**/*.png"
- "!assets/**"
- "!docs/**"
- "!wikiscreens/**"
- "!design_handoff*/**"
- "!logs/**"
- "!refs/**"
- "!templates/**"
- "!frontend/dist/**"
- "!**/node_modules/**"
- "!**/__pycache__/**"
- "!frontend/package-lock.json"
- "!CHANGELOG.md"

path_instructions:
- path: "backend/**/*.py"
instructions: |-
CHUB Python backend review rules — flag any of these failure modes:
- FAIL CLOSED: any auth / webhook-secret / API-key / config-load / DNS / DB guard that returns None/empty, skips, or calls the next handler on error MUST deny (401/403/503), never pass through. `if config and not allowed(): deny` is a bug — it skips the check when config is falsy. An `except ...: config = None` branch must reject the request, not continue with defaults.
- Read LIVE config each pass: long-lived threads, schedulers, job processors and request handlers must re-read config every iteration/request, not capture a config object at startup — config is REPLACED (not mutated) on reload, so a snapshot goes stale.
- No long-lived token in a URL: image/SSE/EventSource/webhook URLs that can't send an Authorization header must use a short-lived, scope-limited token (see create_stream_token / STREAM_SCOPE), never the full session JWT or an admin token.
- Don't cache a transient failure as a negative result: distinguish genuine not-found (cacheable) from network/5xx/timeout/rate-limit/breaker-open (never cache) — a blip must not suppress a valid answer later.
- Redact secrets on EVERY read path (per-module, per-section, per-instance, export, diagnostics), not just GET /config. Make redaction STRUCTURAL — mask by sensitive leaf-key name — so a newly added secret field is covered automatically, not per-endpoint.
- Confirm the recovery before the destructive step: delete-then-refetch / delete-then-research must guarantee the recovery runs even if the confirm times out, or a transient failure becomes permanent loss.
- Destructive filesystem ops (unlink/rmtree/rm -rf): re-confine the RESOLVED physical target (os.path.realpath) and re-assert it is inside the allowed root before deleting — a string-only under-root/".." check is defeated by a symlinked path component. When two delete branches exist (soft vs hard, file vs tree), diff their guards: a check present on one branch and missing on its sibling is the bug. An empty "in-use"/"keep" set means a FAILED READ, not "delete everything" — fail closed.
- Validate the NORMALIZED path for allowlist/traversal checks (../ collapse defeats string-only checks).
- Comments: navigational/instructional only (1-2 line what/gotcha), no why/history essays; match existing density.
- path: "main.py"
instructions: |-
HTTP entrypoint / middleware wiring:
- FAIL CLOSED auth: any authentication / webhook-secret / API-key middleware that can't load its config or secret must return 401/503 — never `except ...: await call_next(request)`, which silently disables auth for the whole API when config is unparseable.
- Read live config per request; do not snapshot a config object at import/startup that goes stale when config is replaced on reload.
- Never mount an endpoint that accepts a long-lived session JWT in the query string; URL-embedded auth must be the short-lived, stream-scoped token only.
- path: "backend/util/database/**/*.py"
instructions: |-
SQLite cache/data layer:
- Escape % and _ in SQL LIKE patterns with an explicit ESCAPE clause — especially delete-by-prefix / clear-by-prefix — or a value containing % or _ wildcard-matches sibling rows and deletes/returns too much.
- Invalidate the LIST and SEARCH caches on mutation, not just the single item: a delete/patch/insert of /x/{id} must also drop the /x list cache and any search-result cache, or lists show stale or deleted rows.
- Don't persist a transient failure (network/5xx/timeout/rate-limit) as a negative/empty cache entry — only cache genuine not-found; a blip must not suppress a valid value on later reads.
- Parameterize every query; never string-format user/config values into SQL.
- path: "backend/util/logger.py"
instructions: |-
Log redaction:
- Redaction must run at the formatter on the FULLY RENDERED line (msg, args AND the exc_info traceback), on every handler — HTTP-client exceptions embed secret-bearing URLs in the traceback.
- Cover URL PATH-SEGMENT secrets, not just query params: e.g. ?X-Plex-Token=, ?apikey=, /passthrough/<key>, bearer tokens. A regex that only masks query strings misses path-embedded secrets.
- Mask by sensitive key name structurally so new secret fields are redacted automatically.
- path: "backend/api/posters.py"
instructions: |-
Poster / GDrive endpoints — high-stakes local-delete path:
- /gdrive/delete-local must authorize by gdrive_list MEMBERSHIP (realpath-match against a currently-configured gdrive_list entry), NOT is_path_allowed — is_path_allowed keys off roots that exist on disk and would wrongly refuse (and skip the row purge for) a drive whose folder was already deleted. Do NOT add an is_path_allowed gate to this handler.
- Fail closed: if config can't be loaded, return 503 and delete nothing.
- Re-confine the RESOLVED path (os.path.realpath) and re-assert membership before any unlink/rmtree; reject a resolve to filesystem root. A string-only ".." check is defeated by a symlinked component.
- Invalidate the poster LIST + search caches after a delete/mutation, not just the touched row.
- path: "frontend/src/**/*.{js,jsx}"
instructions: |-
React 19 / Vite frontend:
- 0 and "" are FALSY: use ?? / Number.isFinite / explicit null checks (not ||) for any value where 0 or "" is legitimate (counts, offsets, timeouts, indices, ratings) — `value || fallback` silently replaces a real 0.
- useEffect/useMemo/useCallback deps: follow exhaustive-deps — include EVERY reactive value the effect/callback reads. To control excess re-runs, memoize the object/callback at its source (useMemo/useCallback) or depend on a stable identity field (e.g. item.id) ONLY when that field fully determines the work; never drop a value the body reads just to silence a re-run (that is a stale-closure bug). A non-memoized object/callback gets a new identity every parent render so `[obj]`/`[onDone]` re-runs then — a memoized one does not.
- Clean up on unmount: clear timers/intervals, abort fetches, close EventSource/WebSocket and stop polling loops in the effect's cleanup return.
- Never embed the full session JWT in an <img>/EventSource/link URL — use the short-lived stream token (useStreamToken) for URL-embedded auth.

tools:
# ruff + eslint intentionally left to CI (codeql-lint.yml already runs Ruff,
# ESLint, stylelint, prettier, CodeQL) — CodeRabbit adds gitleaks (net-new
# secret scanning) plus AI review + the path_instructions above.
gitleaks:
enabled: true
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.40.0"
".": "2.41.0"
}
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to CHUB are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2.41.0](https://github.com/chodeus/chub/compare/v2.40.0...v2.41.0) (2026-07-14)


### Features

* **asset-renamerr:** two-column config settings page ([447a612](https://github.com/chodeus/chub/commit/447a612562dbe071b3e2b1a0406818a19dc742fe))
* **plex-maintenance:** two-column config settings page ([e475ff4](https://github.com/chodeus/chub/commit/e475ff4b978dbb99f8cb461dd2f2f7aa764c432a))
* **poster-cleanarr:** library opt-out, hide music "(unknown)" clutter, clearer asset-pass settings ([c6d8bea](https://github.com/chodeus/chub/commit/c6d8bead55947462ea48548f2572d697851251c1))
* **poster-cleanarr:** show section-toggle descriptions, split the bloat-pass settings ([4ce0a2d](https://github.com/chodeus/chub/commit/4ce0a2db854189d6b45238e323300ab7192f22f8))
* **poster-cleanarr:** two-column config page with pass cards and help tooltips ([d0682e0](https://github.com/chodeus/chub/commit/d0682e025c75e2c269f0dada954677e376e16928))
* **poster-renamerr:** two-column config settings page ([ce36a4f](https://github.com/chodeus/chub/commit/ce36a4fad64e1b0b0c3c038e504977e78e8438d4))


### Bug Fixes

* **deps:** update all non-major dependencies ([#335](https://github.com/chodeus/chub/issues/335)) ([58022c8](https://github.com/chodeus/chub/commit/58022c873664e2ae74790cb64634dc29faae90d4))
* **notifications:** honor Discord retry_after and summarize huge runs ([3ca7f2a](https://github.com/chodeus/chub/commit/3ca7f2a0eba5c55f36fde0c12b1e68aeec075696))

## [2.40.0](https://github.com/chodeus/chub/compare/v2.39.0...v2.40.0) (2026-07-10)


Expand Down
77 changes: 60 additions & 17 deletions backend/modules/border_replacerr.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# modules/border_replacerr.py

import contextlib
import filecmp
import hashlib
import logging
import os
import re
import shutil
import tempfile
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
Expand Down Expand Up @@ -380,6 +382,40 @@ def _asset_excluded(self, asset: dict) -> bool:
return True
return False

def _stage_unbordered(self, asset: dict) -> None:
"""A border-excluded asset still needs its poster staged: with borders
enabled poster_renamerr skips the copy (the border op is the sole file
writer), so an excluded title would otherwise never get a file at
renamed_file — on the plex path the uploader then fails to read it on
every run, and on the kometa path the destination stays empty. Copy
the raw source to renamed_file (temp-name + os.replace, crash-safe)
when it is missing or differs; never in dry-run. Best-effort — a
failure only warns.
"""
src = asset.get("original_file")
dest = asset.get("renamed_file")
if not src or not dest or self.config.dry_run:
return
try:
if not os.path.exists(src):
return
if os.path.exists(dest) and filecmp.cmp(src, dest, shallow=False):
return
os.makedirs(os.path.dirname(dest), exist_ok=True)
tmp = f"{dest}.chub-tmp-{os.getpid()}"
try:
shutil.copyfile(src, tmp)
os.replace(tmp, dest)
except OSError:
with contextlib.suppress(OSError):
os.remove(tmp)
raise
self.logger.debug(f"[EXCLUDED_COPY] {dest} ← {src} (unbordered)")
except OSError as e:
self.logger.warning(
f"Could not stage excluded asset '{asset.get('title')}': {e}"
)

def _collect_matched_assets(self, db) -> list:
"""Return every matched media row + matched collection row, with
exclusion_list / ignore_folders applied.
Expand All @@ -397,12 +433,14 @@ def _collect_matched_assets(self, db) -> list:
if row.get("matched") != 1:
continue
if self._asset_excluded(row):
self._stage_unbordered(row)
continue
assets.append(row)
for row in db.collection.get_all():
if row.get("matched") != 1:
continue
if self._asset_excluded(row):
self._stage_unbordered(row)
continue
assets.append(row)
return assets
Expand All @@ -418,7 +456,20 @@ def _should_process_all(manifest, process_all: bool, reset_all: bool) -> bool:
manifest subset (webhook/adhoc imports)."""
return bool(process_all or reset_all or manifest is None)

def run(self, manifest: Optional[dict] = None, process_all: bool = False):
def run(
self,
manifest: Optional[dict] = None,
process_all: bool = False,
manifest_only: bool = False,
):
# manifest_only is a HARD restriction for the plex apply path: staged
# files live in a per-run temp dir and only manifest assets upload, so
# a full-library sweep (process_all, a holiday reset_all edge, or a
# missing manifest) would re-encode skipped posters into dead temp
# paths from prior runs — recreating and leaking those dirs for
# nothing. It overrides every full-library trigger.
if manifest_only:
manifest = manifest or {"media_cache": [], "collections_cache": []}
with ChubDB(logger=self.logger) as db:
if self.config.log_level.lower() == "debug":
print_settings(self.logger, self.config)
Expand All @@ -434,7 +485,9 @@ def run(self, manifest: Optional[dict] = None, process_all: bool = False):
skipped = 0
failed = 0
gate_skipped = 0
if self._should_process_all(manifest, process_all, reset_all):
if not manifest_only and self._should_process_all(
manifest, process_all, reset_all
):
self.logger.debug(
"Full-library border pass: reprocessing all matched media "
"and collections (includes already-moved posters)."
Expand All @@ -454,21 +507,11 @@ def run(self, manifest: Optional[dict] = None, process_all: bool = False):
f"Asset ID {asset_id} not found in {source}. Skipping."
)
continue
if (
self.config.exclusion_list
and asset["title"] in self.config.exclusion_list
):
self.logger.debug(
f"Skipping '{asset['title']}' (in exclusion_list)."
)
continue
if (
self.config.ignore_folders
and asset.get("folder") in self.config.ignore_folders
):
self.logger.debug(
f"Skipping '{asset['title']}' (folder in ignore_folders)."
)
if self._asset_excluded(asset):
# Excluded from borders, not from posters: stage the
# raw source so the asset still reaches its
# destination / the Plex uploader without a border.
self._stage_unbordered(asset)
continue
assets.append(asset)

Expand Down
Loading
Loading