diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..d544c6e1 --- /dev/null +++ b/.coderabbit.yaml @@ -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/, 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 /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 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5ce68e7f..9af56625 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.40.0" + ".": "2.41.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d6ca60..f37f7359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/modules/border_replacerr.py b/backend/modules/border_replacerr.py index 7dd943b0..7aa1921a 100755 --- a/backend/modules/border_replacerr.py +++ b/backend/modules/border_replacerr.py @@ -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 @@ -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. @@ -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 @@ -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) @@ -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)." @@ -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) diff --git a/backend/modules/poster_renamerr.py b/backend/modules/poster_renamerr.py index aa1b7f07..371781d5 100755 --- a/backend/modules/poster_renamerr.py +++ b/backend/modules/poster_renamerr.py @@ -1244,6 +1244,45 @@ def rename_files(self, db: ChubDB, progress_ceiling: int = 100) -> tuple: self._report_progress(progress_ceiling) return output, manifest + @staticmethod + def _build_plex_notify_output(upload_result: Optional[dict]) -> dict: + """Notification payload for the plex apply path: ONLY the posters the + uploader genuinely pushed this run (action == "updated"), in the same + {asset_type: [{title, year, messages}]} shape the formatter consumes. + + The staged/rename output must NOT feed the notification here — it + lists what was staged, so a poster whose upload failed or was skipped + (unchanged bytes, re-flow retries) would spam every scheduled run as + if it had been uploaded. Failures and skips stay in the log summary. + Returns an all-empty shape when nothing genuinely uploaded; the caller + then attaches a one-line heartbeat instead of a poster list. + """ + out: Dict[str, List[Dict[str, Any]]] = { + "collection": [], + "movie": [], + "show": [], + "artist": [], + "album": [], + } + payload = (upload_result or {}).get("payload") or {} + for up in payload.get("uploaded") or []: + asset_type = up.get("asset_type") or "movie" + season = up.get("season_number") + libs = up.get("library_name") or "Plex" + msg = ( + f"Season {str(season).zfill(2)} uploaded to {libs}" + if asset_type == "show" and season is not None + else f"Uploaded to {libs}" + ) + out.setdefault(asset_type, []).append( + { + "title": up.get("title"), + "year": up.get("year"), + "messages": [msg], + } + ) + return out + def handle_output(self, output: Dict[str, List[Dict[str, Any]]]): headers = { "collection": "Collection", @@ -1630,7 +1669,11 @@ def _orphan_pass_scan_roots(self) -> List[str]: return [self.config.destination_dir] if self.config.destination_dir else [] def run_border_replacerr( - self, manifest: Optional[dict], progress_window=None, process_all=False + self, + manifest: Optional[dict], + progress_window=None, + process_all=False, + manifest_only=False, ): from backend.modules.border_replacerr import BorderReplacerr @@ -1655,7 +1698,7 @@ def run_border_replacerr( getattr(self, "_job_id", None), getattr(self, "_job_db", None) ) border.set_progress_window(*progress_window) - border.run(manifest, process_all=process_all) + border.run(manifest, process_all=process_all, manifest_only=manifest_only) self.logger.info("Finished running border_replacerr.") @@ -1930,18 +1973,28 @@ def run(self): if self.config.run_border_replacerr: with self._phase("border_replacerr"): + # kometa: full-library pass so a border-settings change + # re-borders the on-disk destination. plex: manifest + # subset ONLY — staged files are transient and only + # manifest assets upload; a full pass would re-encode + # Layer-A-skipped posters into dead temp paths from + # prior runs (leaking recreated dirs) for nothing. self.run_border_replacerr( manifest, progress_window=tail_windows.get("border"), - process_all=True, + process_all=(apply_method != "plex"), + manifest_only=(apply_method == "plex"), ) # Strict either/or: upload to Plex only on the "plex" path. The # uploader still gates per-instance on add_posters, so only - # opted-in Plex servers receive the staged posters. + # opted-in Plex servers receive the staged posters. The result + # is kept: the notification below reports genuine uploads, not + # the staged set. + upload_result: Optional[Dict[str, Any]] = None if apply_method == "plex": with self._phase("plex upload"): - PosterUploader( + upload_result = PosterUploader( db=db, logger=self.logger, manifest=manifest ).run() @@ -1972,15 +2025,38 @@ def run(self): self.full_config, self.logger, module_name="asset_renamerr" ).send_notification(asset_output) # Always notify on a successful run, even when nothing - # was renamed — gives the user a heartbeat that the - # module fired. The formatter handles the empty-output - # case with a clean "No files were renamed." field. + # happened — gives the user a heartbeat that the module fired. + # kometa: the rename output IS the outcome (files written for + # Kometa), notify from it as before. plex: notify ONLY the + # posters the uploader genuinely pushed — staged-but-skipped + # and failed posters stay in the logs, so re-flow retries + # can't spam the notification every schedule. When nothing + # uploaded, a one-line heartbeat replaces the poster list. if any(output.values()): self.handle_output(output) + notify_output: Dict[str, Any] = output + if apply_method == "plex": + notify_output = self._build_plex_notify_output(upload_result) + if not any(notify_output.values()): + payload = (upload_result or {}).get("payload") or {} + failed = payload.get("failed", 0) + if (upload_result or {}).get("success"): + empty_text = "No posters were uploaded (nothing changed)." + elif failed: + empty_text = ( + f"No posters were uploaded " + f"({failed} failed — check the logs)." + ) + else: + reason = (upload_result or {}).get( + "message" + ) or "upload did not run" + empty_text = f"No posters were uploaded — {reason}" + notify_output["empty_text"] = empty_text manager = NotificationManager( self.full_config, self.logger, module_name="poster_renamerr" ) - manager.send_notification(output) + manager.send_notification(notify_output) except KeyboardInterrupt: self.logger.info("Keyboard Interrupt detected. Exiting...") diff --git a/backend/util/notification_formatting.py b/backend/util/notification_formatting.py index 624ca5ad..45e65403 100644 --- a/backend/util/notification_formatting.py +++ b/backend/util/notification_formatting.py @@ -249,10 +249,17 @@ def add_asset_fields(assets, label): text = "\n".join([section_title] + [f" {m}" for m in msgs]) fields.append({"name": section_title, "value": f"```{text}```"}) + add_asset_fields(o.get("artist", []), "Artist") + + add_asset_fields(o.get("album", []), "Album") + if not had_any: + # empty_text lets the caller override the heartbeat line (the plex + # upload path sends "No posters were uploaded..." instead of the + # rename wording). fields = [ { - "name": "No files were renamed.", + "name": o.get("empty_text") or "No files were renamed.", "value": "", } ] diff --git a/backend/util/plex.py b/backend/util/plex.py index 092a6dab..2c04de25 100755 --- a/backend/util/plex.py +++ b/backend/util/plex.py @@ -564,7 +564,23 @@ def _locate_targets_uncached( # won't pull a same-named item of the wrong kind. candidates = section.search(title=item_title) if len(candidates) == 1: - items = candidates + # Reject a clear year mismatch; keep yearless candidates + # (metadata gap). Malformed non-empty years fail closed. + lone_year = getattr(candidates[0], "year", None) + try: + year_compatible = ( + lone_year is None + or abs(int(lone_year) - int(year)) <= YEAR_MATCH_TOLERANCE + ) + except (TypeError, ValueError): + year_compatible = lone_year in (None, "") + if year_compatible: + items = candidates + else: + self.logger.debug( + f"'{item_title}' lone title match in '{library_name}' " + f"rejected: year {lone_year} vs requested {year}" + ) elif candidates: try: target_year = int(year) diff --git a/backend/util/upload_posters.py b/backend/util/upload_posters.py index 8fbdc1e4..dc55c275 100755 --- a/backend/util/upload_posters.py +++ b/backend/util/upload_posters.py @@ -39,6 +39,10 @@ class UploadResult: reason: str library_name: Optional[str] = None match_type: Optional[str] = None + # Carried so the caller's notification can render "Title (Year)" / + # "Season NN" for genuinely uploaded posters without re-querying the DB. + year: Optional[Any] = None + season_number: Optional[Any] = None @dataclass @@ -833,17 +837,18 @@ def _sync_single_asset( if not missing_libs: # Same bytes, every library covered: refresh the mtime # fast-path key (preserving the record) and skip. - self._update_asset_database( - asset, - current_file_hash, - current_mtime, - uploaded_libraries=( - json.dumps(sorted(recorded_libs)) - if not dry_run - else None - ), - source_file_hash=source_file_hash, - ) + # Never persist in dry-run — a pretend run must not + # touch the hash/mtime record (unreachable today since + # the dry-run hash can't equal a real record hash, but + # keep the guard structural). + if not dry_run: + self._update_asset_database( + asset, + current_file_hash, + current_mtime, + uploaded_libraries=json.dumps(sorted(recorded_libs)), + source_file_hash=source_file_hash, + ) return UploadResult( asset_title=asset_title, asset_type=asset_type, @@ -933,6 +938,8 @@ def _sync_single_asset( reason="Successfully uploaded", library_name=", ".join(dict.fromkeys(uploaded_libs)), match_type=match_type, + year=asset.get("year"), + season_number=asset.get("season_number"), ) else: return UploadResult( @@ -1118,20 +1125,37 @@ def _compile_final_result( total_skipped = 0 total_failed = 0 successful_instances = 0 + # Per-poster record of every GENUINE upload this run (action == + # 'updated'), across instances. The scheduled plex path notifies from + # this — never from the staged/rename output — so a skipped or failed + # poster can't masquerade as an upload in Discord/Notifiarr. + uploaded_records: List[Dict[str, Any]] = [] for instance_result in instance_results: if instance_result.connected and not instance_result.error_message: successful_instances += 1 - updated = len( - [r for r in instance_result.uploads if r.action == "updated"] - ) + updated_results = [ + r for r in instance_result.uploads if r.action == "updated" + ] + updated = len(updated_results) skipped = len( [r for r in instance_result.uploads if r.action == "skipped"] ) failed = len( [r for r in instance_result.uploads if r.action == "failed"] ) + uploaded_records.extend( + { + "title": r.asset_title, + "year": r.year, + "asset_type": r.asset_type, + "season_number": r.season_number, + "library_name": r.library_name, + "instance": instance_result.instance_name, + } + for r in updated_results + ) total_updated += updated total_skipped += skipped @@ -1186,6 +1210,7 @@ def _compile_final_result( "updated": total_updated, "skipped": total_skipped, "failed": total_failed, + "uploaded": uploaded_records, "year_discrepancies": list(self._year_discrepancies), "instances_processed": successful_instances, "instance_results": [ diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index fd503f40..f807f26d 100755 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -1,6 +1,6 @@ services: chub: - image: ghcr.io/chodeus/chub:latest@sha256:d2250761f1d28de52d4a0d1027ef9b6923b4869c56350085dc90dc994d5f9dfa + image: ghcr.io/chodeus/chub:latest@sha256:149191a7a2436bb1107d8e8eca2951e05f63c27202fd55214a26ddbf87170749 container_name: chub restart: unless-stopped diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5edcae3..8ac65e07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,7 @@ "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.14.6", "cron-validator": "^1.4.0", - "cronstrue": "^3.21.0", + "cronstrue": "^3.24.0", "prop-types": "15.8.1", "react": "19.2.7", "react-dom": "19.2.7", @@ -20,7 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", - "@tailwindcss/vite": "^4.3.2", + "@tailwindcss/vite": "^4.3.3", "@vitejs/plugin-react": "^6.0.3", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", @@ -28,11 +28,11 @@ "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.7.0", "prettier": "^3.9.5", - "stylelint": "^17.14.0", + "stylelint": "^17.14.1", "stylelint-config-standard": "^40.0.0", "stylelint-selector-bem-pattern": "^5.0.0", - "tailwindcss": "^4.3.2", - "vite": "^8.1.4" + "tailwindcss": "^4.3.3", + "vite": "^8.1.5" }, "engines": { "node": ">=24.18.0" @@ -1233,49 +1233,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1290,9 +1290,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1307,9 +1307,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1324,9 +1324,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1341,9 +1341,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1358,9 +1358,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1378,9 +1378,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1398,9 +1398,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1418,9 +1418,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1438,9 +1438,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1534,9 +1534,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1551,9 +1551,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1568,15 +1568,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -2426,9 +2426,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5793,9 +5793,9 @@ } }, "node_modules/stylelint": { - "version": "17.14.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz", - "integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==", + "version": "17.14.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", + "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", "dev": true, "funding": [ { @@ -5811,7 +5811,7 @@ "dependencies": { "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.5", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -5823,9 +5823,9 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.3", + "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^16.2.0", + "globby": "^16.2.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -5835,12 +5835,12 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.15", + "postcss": "^8.5.16", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", - "supports-hyperlinks": "^4.4.0", + "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" @@ -6105,9 +6105,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -6364,16 +6364,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/frontend/package.json b/frontend/package.json index cf660847..c6cf8c67 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.14.6", "cron-validator": "^1.4.0", - "cronstrue": "^3.21.0", + "cronstrue": "^3.24.0", "prop-types": "15.8.1", "react": "19.2.7", "react-dom": "19.2.7", @@ -25,7 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", - "@tailwindcss/vite": "^4.3.2", + "@tailwindcss/vite": "^4.3.3", "@vitejs/plugin-react": "^6.0.3", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", @@ -33,11 +33,11 @@ "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.7.0", "prettier": "^3.9.5", - "stylelint": "^17.14.0", + "stylelint": "^17.14.1", "stylelint-config-standard": "^40.0.0", "stylelint-selector-bem-pattern": "^5.0.0", - "tailwindcss": "^4.3.2", - "vite": "^8.1.4" + "tailwindcss": "^4.3.3", + "vite": "^8.1.5" }, "engines": { "node": ">=24.18.0" diff --git a/requirements.txt b/requirements.txt index 8e2d6d62..9953d4b2 100755 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ packaging==26.2 pathspec==1.1.1 pathvalidate==3.3.1 pillow==12.3.0 -platformdirs==4.10.0 +platformdirs==4.10.1 PlexAPI==4.18.2 pluggy==1.6.0 prettytable==3.18.0 diff --git a/tests/test_border_replacerr.py b/tests/test_border_replacerr.py index 575ae20c..b19945c3 100644 --- a/tests/test_border_replacerr.py +++ b/tests/test_border_replacerr.py @@ -348,9 +348,10 @@ def set_job_context(self, *a, **k): def set_progress_window(self, *a, **k): pass - def run(self, manifest=None, process_all=False): + def run(self, manifest=None, process_all=False, manifest_only=False): captured["manifest"] = manifest captured["process_all"] = process_all + captured["manifest_only"] = manifest_only monkeypatch.setattr(border_mod, "BorderReplacerr", _FakeBorder) @@ -362,6 +363,12 @@ def run(self, manifest=None, process_all=False): assert captured["process_all"] is True assert captured["manifest"] == manifest + assert captured["manifest_only"] is False + + # plex apply path: manifest_only forwards too (hard-restricts the sweep). + renamer.run_border_replacerr(manifest, process_all=False, manifest_only=True) + assert captured["process_all"] is False + assert captured["manifest_only"] is True # ---- end-to-end: full pass borders all, second pass re-encodes nothing ----- @@ -607,3 +614,114 @@ def __exit__(self, *a): assert (dest_dir / "inman.jpg").exists() # manifest item bordered assert not (dest_dir / "outman.jpg").exists() # non-manifest item untouched + + +def _subset_harness(tmp_path, monkeypatch, exclusion_list=None): + """Shared setup for manifest-mode runs: two real source posters (ids 1/2), + a fake DB, notifications stubbed. Returns (br, dest_dir, rows_by_id).""" + import backend.modules.border_replacerr as border_mod + from backend.util.database.border_state import BorderState + + src_dir = tmp_path / "src" + src_dir.mkdir() + dest_dir = tmp_path / "dest" + dest_dir.mkdir() + + def _mkrow(id_, name): + src = src_dir / f"{name}.jpg" + Image.new("RGB", (1000, 1500), (10, 20, 30)).save(src, "JPEG") + return { + "id": id_, + "title": name, + "folder": name, + "matched": 1, + "original_file": str(src), + "renamed_file": str(dest_dir / f"{name}.jpg"), + } + + by_id = {1: _mkrow(1, "inman"), 2: _mkrow(2, "outman")} + border_state = BorderState(logger=StubLogger(), db_path=str(tmp_path / "b.db")) + + class _MediaTable: + def get_by_id(self, i): + return by_id.get(i) + + def get_all(self): + return list(by_id.values()) + + class _CollTable: + def get_by_id(self, i): + return None + + def get_all(self): + return [] + + class _FakeDB: + def __init__(self): + self.media = _MediaTable() + self.collection = _CollTable() + self.holiday = SimpleNamespace( + get_status=lambda: {"last_active_holiday": None}, + set_status=lambda *a, **k: None, + ) + self.border = border_state + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(border_mod, "ChubDB", lambda *a, **k: _FakeDB()) + monkeypatch.setattr( + border_mod, + "NotificationManager", + lambda *a, **k: SimpleNamespace(send_notification=lambda *a, **k: None), + ) + + br = _make_br() + br.full_config = SimpleNamespace() + br.config = SimpleNamespace( + log_level="info", + holidays=[], + border_colors=["#FF0000"], + exclusion_list=exclusion_list or [], + ignore_folders=[], + dry_run=False, + border_width=26, + border_workers=1, + ) + return br, dest_dir, by_id + + +def test_manifest_only_overrides_full_library_triggers(tmp_path, monkeypatch): + """manifest_only must beat process_all (and any full-library trigger): on + the plex apply path staged files are transient, so a full sweep would + re-encode skipped posters into dead temp paths from prior runs. Only the + manifest item may be written.""" + br, dest_dir, _ = _subset_harness(tmp_path, monkeypatch) + + manifest = {"media_cache": [1], "collections_cache": []} + br.run(manifest, process_all=True, manifest_only=True) + + assert (dest_dir / "inman.jpg").exists() # manifest item bordered + assert not (dest_dir / "outman.jpg").exists() # full sweep suppressed + + +def test_excluded_manifest_asset_staged_unbordered(tmp_path, monkeypatch): + """A border-EXCLUDED manifest asset must still get its raw source staged + at renamed_file (exclusion means no border, not no poster) — otherwise the + plex uploader finds nothing to read and fails it on every run.""" + br, dest_dir, by_id = _subset_harness( + tmp_path, monkeypatch, exclusion_list=["inman"] + ) + + manifest = {"media_cache": [1], "collections_cache": []} + br.run(manifest, process_all=False) + + dest = dest_dir / "inman.jpg" + assert dest.exists() + # Exact source bytes — copied, not bordered. + with open(by_id[1]["original_file"], "rb") as f: + src_bytes = f.read() + assert dest.read_bytes() == src_bytes diff --git a/tests/test_media_cache_upsert.py b/tests/test_media_cache_upsert.py index 359a34d1..e2ff88e5 100644 --- a/tests/test_media_cache_upsert.py +++ b/tests/test_media_cache_upsert.py @@ -230,3 +230,53 @@ def test_source_file_hash_persists_and_survives_resync(db): got2 = db.media.get_by_id(row["id"]) assert got2["source_file_hash"] == "deadbeef" # preserved assert got2["title"] == "Film Renamed" # arr fields still refresh + + +def test_collection_skip_columns_survive_resync(db): + """Collection analogue of the media test above: the plex upload dedup + record (source_file_hash / uploaded_libraries / file_hash / file_mtime / + original_file) must be PRESERVED across a collections re-sync upsert, or + every collection would re-upload each scheduled run. Guards the + ON CONFLICT DO UPDATE SET column list in collection_cache.upsert.""" + rec = { + "title": "Marvel Collection", + "normalized_title": normalize_titles("Marvel Collection"), + "year": None, + "tmdb_id": 100, + "tvdb_id": None, + "imdb_id": None, + "folder": "/c", + "library_name": "Movies", + } + db.collection.upsert(dict(rec), "plex_main") + row = db.collection.execute_query( + "SELECT id FROM collections_cache WHERE title='Marvel Collection'", + fetch_one=True, + ) + db.collection.update( + title="Marvel Collection", + year=None, + library_name="Movies", + instance_name="plex_main", + id=row["id"], + original_file="/posters/Marvel Collection.jpg", + file_hash="feedface", + file_mtime=123.0, + uploaded_libraries='["Movies"]', + source_file_hash="deadbeef", + ) + got = db.collection.get_by_id(row["id"]) + assert got["source_file_hash"] == "deadbeef" + assert got["uploaded_libraries"] == '["Movies"]' + + # Re-sync (same title/library/instance, refreshed id) must refresh arr/plex + # fields WITHOUT wiping the upload dedup record. + db.collection.upsert({**rec, "tmdb_id": 200, "folder": "/c2"}, "plex_main") + got2 = db.collection.get_by_id(row["id"]) + assert got2["tmdb_id"] == 200 # sync fields still refresh + assert got2["folder"] == "/c2" + assert got2["source_file_hash"] == "deadbeef" # preserved + assert got2["uploaded_libraries"] == '["Movies"]' # preserved + assert got2["file_hash"] == "feedface" # preserved + assert float(got2["file_mtime"]) == 123.0 # preserved + assert got2["original_file"] == "/posters/Marvel Collection.jpg" # preserved diff --git a/tests/test_notification.py b/tests/test_notification.py index 6d94aa76..c92cf095 100644 --- a/tests/test_notification.py +++ b/tests/test_notification.py @@ -603,3 +603,56 @@ def test_format_for_discord_keeps_small_run_detailed(): names = [f.get("name", "") for fields in data.values() for f in fields] assert any("Movie 0" in n for n in names) # per-item detail preserved assert not any("items processed" in n for n in names) # not summarised + + +def test_format_for_discord_empty_text_override(): + """The plex upload path replaces the default 'No files were renamed.' + heartbeat with an upload-accurate line via the empty_text key.""" + from backend.util.notification_formatting import format_for_discord + + cfg = SimpleNamespace(module_name="poster_renamerr") + data, ok = format_for_discord( + cfg, + { + "collection": [], + "movie": [], + "show": [], + "empty_text": "No posters were uploaded (nothing changed).", + }, + ) + assert ok + fields = data[1] + assert fields[0]["name"] == "No posters were uploaded (nothing changed)." + + +def test_format_for_discord_empty_default_unchanged(): + from backend.util.notification_formatting import format_for_discord + + cfg = SimpleNamespace(module_name="poster_renamerr") + data, _ = format_for_discord(cfg, {"collection": [], "movie": [], "show": []}) + assert data[1][0]["name"] == "No files were renamed." + + +def test_format_for_discord_renders_artist_and_album(): + """Music uploads must render, not silently fall through to the empty + heartbeat (artist/album previously weren't handled by the formatter).""" + from backend.util.notification_formatting import format_for_discord + + cfg = SimpleNamespace(module_name="poster_renamerr") + data, _ = format_for_discord( + cfg, + { + "artist": [{"title": "Queen", "year": None, "messages": ["Uploaded to Music"]}], + "album": [ + { + "title": "Greatest Hits", + "year": 1981, + "messages": ["Uploaded to Music"], + } + ], + }, + ) + names = [f.get("name", "") for fields in data.values() for f in fields] + assert any("Queen" in n for n in names) + assert any("Greatest Hits (1981)" in n for n in names) + assert not any("No files were renamed" in n for n in names) diff --git a/tests/test_plex_connect.py b/tests/test_plex_connect.py index b2b5da7d..21bcc8b5 100644 --- a/tests/test_plex_connect.py +++ b/tests/test_plex_connect.py @@ -176,6 +176,41 @@ def test_locate_targets_year_tolerant_fallback_rejects_off_by_two(): assert targets == [] +def test_locate_targets_lone_titleonly_hit_rejects_wrong_year(): + """A LONE title-only hit of a clearly different year is a different release + (e.g. a remake) and must NOT receive the poster — the single-candidate + branch is year-guarded like the multi-candidate one. Regression: a + 'Halloween' (2018) poster landed on the 1978 item when only the 1978 film + was in the library.""" + client = _client_with_year_aware_search( + year_results=[], titleonly_results=[_FakeMovie(1978)] + ) + targets = client._locate_targets("Films", "Thanks for Sharing", year=2018) + assert targets == [] + + +def test_locate_targets_lone_titleonly_hit_keeps_yearless_candidate(): + """A lone hit with NO year (Plex metadata gap) is kept — a missing year + must not reject the only candidate (mirrors _disambiguate_by_year).""" + yearless = _FakeMovie(None) + client = _client_with_year_aware_search( + year_results=[], titleonly_results=[yearless] + ) + targets = client._locate_targets("Films", "Thanks for Sharing", year=2018) + assert targets == [yearless] + + +def test_locate_targets_lone_titleonly_hit_rejects_malformed_year(): + """A lone hit with a non-empty MALFORMED year fails closed — an + unverifiable candidate must not receive the poster (only genuinely + missing years are treated as a metadata gap).""" + client = _client_with_year_aware_search( + year_results=[], titleonly_results=[_FakeMovie("not-a-year")] + ) + targets = client._locate_targets("Films", "Thanks for Sharing", year=2018) + assert targets == [] + + def _client_with_fetchitem(item_or_exc, search_counter): """PlexClient whose plex.fetchItem returns an item (or raises) and whose section.search is counted, to prove ratingKey resolution skips searching.""" diff --git a/tests/test_poster_renamerr.py b/tests/test_poster_renamerr.py index 1e3daa70..6ffda67d 100644 --- a/tests/test_poster_renamerr.py +++ b/tests/test_poster_renamerr.py @@ -1202,3 +1202,64 @@ def _match_stub(media_item, db, is_collection=False): result = m.run_poster_rename_adhoc([{"asset_type": "movie", "id": 1}]) assert result["success"] is True assert observed == [False], "match_item ran without the rebuild lock held" + + +# --- _build_plex_notify_output (plex-path notification payload) --- + + +def test_build_plex_notify_output_lists_only_genuine_uploads(): + """The plex-path notification is built from the uploader's payload + ("uploaded" = action=='updated' only) — staged/skipped/failed posters must + never appear, so re-flow retries can't spam every scheduled run.""" + upload_result = { + "success": True, + "payload": { + "updated": 3, + "skipped": 40, + "failed": 2, + "uploaded": [ + { + "title": "Film", + "year": "2026", + "asset_type": "movie", + "season_number": None, + "library_name": "Movies, Movies 4K", + "instance": "plex_main", + }, + { + "title": "Show", + "year": 2020, + "asset_type": "show", + "season_number": 2, + "library_name": "TV", + "instance": "plex_main", + }, + { + "title": "Queen", + "year": None, + "asset_type": "artist", + "season_number": None, + "library_name": "Music", + "instance": "plex_main", + }, + ], + }, + } + out = PosterRenamerr._build_plex_notify_output(upload_result) + assert [a["title"] for a in out["movie"]] == ["Film"] + assert out["movie"][0]["messages"] == ["Uploaded to Movies, Movies 4K"] + assert out["show"][0]["messages"] == ["Season 02 uploaded to TV"] + assert out["artist"][0]["title"] == "Queen" + assert out["collection"] == [] and out["album"] == [] + + +def test_build_plex_notify_output_empty_and_none_are_all_empty(): + """Zero genuine uploads (steady state) and a missing/failed upload result + both produce the all-empty shape — the caller then sends the one-line + heartbeat instead of a poster list.""" + empty = PosterRenamerr._build_plex_notify_output( + {"success": True, "payload": {"uploaded": []}} + ) + assert not any(empty.values()) + assert not any(PosterRenamerr._build_plex_notify_output(None).values()) + assert not any(PosterRenamerr._build_plex_notify_output({}).values())