Skip to content

refactor: split monolithic doctor.py into a package - #8

Closed
machetie wants to merge 58 commits into
Neoo-Blue:mainfrom
machetie:feat/plexscan-repair-checks
Closed

machetie wants to merge 58 commits into
Neoo-Blue:mainfrom
machetie:feat/plexscan-repair-checks

Conversation

@machetie

Copy link
Copy Markdown
Collaborator

Overview

This PR refactors the 2380-line doctor.py monolith into a proper doctor/ package, fixes the duplicate Seerr/check_seerr bug, and replaces the previous broken state-file locking with an atomic transaction.

Changes

  • Split doctor.py into doctor/ package modules (config, clients, state, scheduler, webui, __main__, and checks/)
  • Extract embedded UI HTML into doctor/ui.html
  • Add state_transaction() to hold the lock across load+modify+save
  • Migrate queue, seerr, repair, and missing_seasons checks to use the transaction
  • Remove the duplicate Seerr/check_seerr definitions
  • Update Dockerfile, stack-doctor.service.example, README.md, and DEPLOY.md for python -m doctor
  • Add tests/ with unit tests for pure helpers and a state concurrency regression test
  • Add AGENTS.md with run/test/deploy notes

Technical Details

  • state_transaction() uses a process-wide RLock and saves only on successful exit
  • _save_state_unlocked() now logs failures instead of silently ignoring them

Test Content

  • python3 -m py_compile doctor/... passes
  • python3 -m unittest discover -s tests -v → 26 tests OK
  • Smoke test with ENABLE_UI=true starts the dashboard and exits cleanly

Related Issues

No related issue.

Test plan

  • Package compiles
  • Unit tests pass
  • python3 -m doctor smoke test starts and binds UI
  • doctor.py references removed from repo
  • Dockerfile and systemd service updated

Generated with Devin

Neoo-Blue and others added 21 commits June 14, 2026 12:42
…) checks

plexscan (ENABLE_PLEX_SCAN): detect a Plex library scan stuck with no progress
(almost always the scanner blocking on a hung decypharr mount) and recover in
order -> restart the hung mount (reuses the decypharr hook), cancel the wedged
scan via the activities API, last-resort PLEX_RESTART_CMD.

repair (ENABLE_REPAIR): probe library media for unreadable / 0-byte / dead-symlink
files (a dead debrid link or a usenet article gone), map each to its owning
sonarr/radarr item, remove the dead file record and trigger a fresh search. Safe
by design: strike-gated so a transient hiccup never deletes; mount-safe traversal
(never follows symlinks during the walk; per-file probe is timeout-protected);
backs off entirely on a systemic/hung-mount failure so it can never mass-regrab
during an outage; load-guarded + capped per sweep.

Both are toggleable in the dashboard Checks panel + config; README and compose
example updated. Refactors the decypharr restart into a shared _decy_restart().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… logs (v0.3)

Add missing_seasons background loop, no_upgrade_profile check, Plex empty trash,
dedicated Plex UI card, colored log output, seerr sync, VERSION constant.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… super().format()

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- StreamHandler: use sys.stdout explicitly to preserve container log routing
- missing_seasons cwd: use os.path.abspath() so relative script paths don't
  produce an empty string and crash Popen with FileNotFoundError
- no_upgrade_profile: resolve target_id per Sonarr instance, not once globally,
  so multiple Sonarr instances with different profile IDs all work correctly
- Plex buttons: pass 'this' from onclick handler instead of relying on
  event.target, which is not guaranteed in all browsers for inline handlers
- Plex API endpoints: run _wr_plex_rescan/_plex_empty_trash in a background
  thread and respond 202 immediately, preventing reverse-proxy timeouts

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ainer

- Add westrepair/ with repair.py and shared/{arr,debrid,discord,requests,shared}.py
  copied from Pukabyte/westrepair main
- Update Dockerfile to copy westrepair/ and install its pip deps
  (environs, discord_webhook, requests)
- Update docker-compose.example.yml with full westrepair env var block
  (SONARR/RADARR hosts+keys, REALDEBRID_*, TORBOX_*, DISCORD_*)
- Update .env.example with missing vars (REALDEBRID_API_KEY, PLEX_TOKEN, etc.)
- Fix _wr_parse_line regexes in doctor.py to match repair.py's actual
  output format ([datetime] [mode] Title: X / Broken items: / Searching for new files)

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Merges Neoo-Blue/feat/plexscan-repair-checks into the full feature set:

- plexscan: detect wedged Plex library scans (no progress for PLEX_SCAN_STUCK_AFTER),
  recover by fixing hung decypharr mount, cancelling the scan, last-resort Plex restart
- repair: probe library for dead/broken/0-byte files with strike-gating, mount-safe
  scandir walk, per-file read timeout, abort streak + systemic backoff guards,
  then remove *arr file record + trigger automaticSearch
- _decy_restart() refactored as shared helper (used by decypharr + plexscan checks)
- All checks registered in CHECKS list and UI config schema

Keeps seerr, westrepair, missing_seasons, no_upgrade_profile from machetie/main.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Brings all features from machetie/main into feat/plexscan-repair-checks:
- seerr: Overseerr/Jellyseerr/Seerr failed-request auto-retry
- missing_seasons + no_upgrade_profile checks
- colored console log output (_ColorFormatter)
- Plex empty trash + improved rescan
- westrepair subprocess integration + shared libs
- VERSION constant, code review fixes

Branch now contains the complete feature set:
plexscan, repair, seerr, westrepair, missing_seasons, no_upgrade_profile,
janitor, warmer, decypharr, resources, providers, bazarr, queue

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Drop EN_WESTREPAIR, WR_* config vars, westrepair_loop, check_westrepair,
  _wr_parse_line, _wr_state/_wr_lock/_wr_proc, _ui_westrepair
- Remove westrepair/ directory (repair.py + shared libs) and pip deps
- Remove Westrepair UI card, JS fetch block, and /api/westrepair route
- Rename /api/westrepair/rescan route to /api/plex/rescan only
- Rename _wr_plex_rescan() -> _plex_rescan()
- Clean up Dockerfile, docker-compose.example.yml, .env.example
- Native repair check (ENABLE_REPAIR) provides equivalent functionality
  with better safety guards (strike gating, mount-safe walk, abort streak,
  systemic backoff, read timeout) and zero extra dependencies

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Four additions ported from westrepair's repair.py logic:

1. Debrid mount health guard (REPAIR_DEBRID_MOUNT)
   Skip the entire sweep if the configured debrid mount path is missing
   or empty, preventing mass-regrab during a debrid outage. Equivalent
   to westrepair's validateRealdebrid/TorboxMountTorrentsPath check but
   without requiring debrid API credentials.

2. Per-item re-grab interval (REPAIR_ITEM_INTERVAL)
   Optional sleep between each re-grab action to stay gentle on indexers/
   providers, mirroring westrepair's --repair-interval flag.

3. Season-pack upgrade detection (REPAIR_SEASON_PACKS)
   After the dead-file sweep, check all fully-downloaded sonarr seasons
   whose episode files are spread across more than one parent directory
   (a sign of individual episode grabs instead of a season pack) and
   trigger a SeasonSearch to upgrade. Direct port of westrepair's
   --season-packs mode.

4. Unmonitored media inclusion (REPAIR_UNMONITORED)
   When true, include unmonitored sonarr series and radarr movies in
   both the dead-file repair and the season-pack scan. Mirrors
   westrepair's --include-unmonitored flag.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…g_seasons()

Eliminates the external script dependency entirely. The new implementation:
- Walks all monitored Sonarr series via the existing Arr.series() / Arr.command() API
- Finds seasons that are fully monitored, old enough (MISSING_SEASONS_MIN_AGE_HOURS),
  and have zero episode files (episodeFileCount == 0, totalEpisodeCount > 0)
- Triggers SeasonSearch with a per-season cooldown (MISSING_SEASONS_RECHECK, default 24h)
  so the same season is never hammered every sweep
- Rate-limited to MISSING_SEASONS_MAX_ACTIONS searches per sweep (default 5)
- Skips season 0 (specials) and unmonitored series/seasons
- State persisted in doctor's existing state.json

Removes: MS_SCRIPT, MS_RUN_INTERVAL, MS_SEARCH_INTERVAL, missing_seasons_loop,
_ms_lock, _ms_state, _ms_proc, _ms_parse_line, _RE_MS_*, _ui_missing_seasons,
/api/missing_seasons route, startup thread.

Adds: MISSING_SEASONS_MIN_AGE_HOURS, MISSING_SEASONS_MAX_ACTIONS, MISSING_SEASONS_RECHECK

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Add pat_filename regex: extracts release name from [link] "Giving up on
  entry ... filename=<name> reason=empty_link" lines — covers the
  empty_link / all re-insertion attempts exhausted failure mode
- Add "marked as bad" to default JAN_PATTERNS so [webdav] Error streaming
  file lines from decypharr's internal bad-torrent blacklist are caught by
  the existing pat_stream regex without needing user config
- Patterns with no extractable release name (magnet_error, torrent not
  found, key not found, All re-insertion attempts, Status: 451) are
  intentionally not matched — these log lines carry no filename/path so
  the janitor cannot identify which library symlinks to quarantine

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- _sonarr_resolve now returns season_number alongside the existing tuple
- _repair_one issues SeasonSearch(seriesId, seasonNumber) first so Sonarr
  can find a season pack if one is available, falling back to EpisodeSearch
  if season number is unknown, then SeriesSearch as last resort

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… downloads

Adds REPAIR_MISSING_FROM_DISK=true opt-in mode that queries Sonarr/Radarr
download history for grabbed items where reason=MissingFromDisk, then
triggers a re-search — covering files that have no on-disk symlink to probe
(usenet direct downloads, files removed by external tools, etc).

- Arr.history(media_id): GET /history/series?seriesId= or /history/movie?movieId=
  returns paginated download history records with eventType + data.reason
- _missing_from_disk_check(state, acted, budget): walks all monitored media,
  fetches history, groups by season (sonarr) or movie (radarr) to avoid
  duplicate searches, respects REPAIR_MFD_RECHECK cooldown (default 24h),
  shares REPAIR_MAX_ACTIONS budget with the filesystem sweep
- Sonarr: triggers SeasonSearch(seriesId, seasonNumber) per missing season
- Radarr: triggers MoviesSearch(movieIds) per missing movie
- Runs after the filesystem probe sweep in check_repair so both modes
  together never exceed REPAIR_MAX_ACTIONS in one sweep
- Respects DRY_RUN, REPAIR_UNMONITORED, REPAIR_ITEM_INTERVAL

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…pgrade_profile

- Update repair row in checks table to mention MissingFromDisk history mode
  and SeasonSearch-first re-search priority
- Add full repair config table covering all REPAIR_* vars including
  REPAIR_MISSING_FROM_DISK and REPAIR_MFD_RECHECK
- Add missing_seasons section with all MISSING_SEASONS_* vars
- Add no_upgrade_profile section with NO_UPGRADE_PROFILE_NAME/ID vars
- Add missing_seasons and no_upgrade_profile rows to the checks table

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Inspired by Pukabyte/repair's verify_worker pattern. After triggering a
re-search doctor now optionally tracks whether a new grab actually lands:

- Arr.command() now returns the command ID (int) instead of True/False,
  preserving True for callers that just check truthiness
- Arr.command_status(id): polls GET /command/{id} -> status string
- Arr.history_grabbed(media_id, since_ts, entity_ids): polls GET /history
  for eventType=grabbed records after the given ISO timestamp
- _repair_record_verify(): writes a pending entry to __repair_verify__ in
  state.json with cmd_id, media_id, entity_ids, search_ts, deadline
- _repair_verify_pending(): runs at the start of each repair sweep:
    1. polls /command/{id} until completed/failed/aborted
    2. polls /history for a new grabbed event after search_ts
    3. on confirmed grab: logs "[repair:verify] GRABBED '<title>' via
       <indexer>: <sourceTitle>" and removes from pending
    4. on deadline exceeded: logs a warning and removes from pending
- _repair_one() accepts optional state= and records verify entries when
  REPAIR_VERIFY=true (both filesystem and MissingFromDisk paths)
- check_repair() calls _repair_verify_pending() at sweep start when enabled

New config vars: REPAIR_VERIFY (default false), REPAIR_VERIFY_DEADLINE (4h)

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Seasons with future air dates are now automatically skipped to prevent
triggering SeasonSearch for incomplete seasons (e.g. a show just started
airing and only has 1 of 12 episodes released). Episodes are lazy-fetched
once per series only when a candidate season is found. Inspired by
d3v1l1989/seasonarr's has_future_episodes() pattern.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Replace daemon threads in _probe_file() with ThreadPoolExecutor to prevent resource leaks
- Add cancellation flag to prevent unnecessary Plex restarts after successful scan cancellation
- Version sync already correct (both UI and startup log use VERSION variable)

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Replace filesystem walk and 128KB read probes with *arr API-first checks
- Query Sonarr/Radarr file records and check readlink target via os.path.exists()
- Group dead files by season/movie, delete records, toggle monitor off+on, and search
- Remove REPAIR_MIN_STRIKES, REPAIR_MAX_SCAN, REPAIR_READ_TIMEOUT, REPAIR_ABORT_STREAK, REPAIR_SYSTEMIC_PCT, REPAIR_RECHECK, REPAIR_FFPROBE, and MEDIA_EXTS
- Make REPAIR_LIBRARY_PATHS optional as a path filter
- Update docker-compose.example.yml and README.md for the new behavior
- Remove unused concurrent.futures import

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Add REPAIR_MAX_SYMLINKS (default 100) to cap total dead symlinks processed per sweep
- Keep REPAIR_MAX_ACTIONS (default now 20) as the cap on search commands (seasons/movies)
- Process dead files as whole season/movie units; skip a group if it would exceed the symlink cap
- Update log summary to report both commands issued and total symlinks processed
- Update UI schema, docker-compose.example.yml, and README.md

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Replace single DOCTOR_INTERVAL sweep loop with a scheduler
- Fast checks (queue, providers, decypharr, plex, plexscan, resources, bazarr, seerr)
  run every DOCTOR_FAST_INTERVAL (default 180s)
- Slow checks (repair, janitor, missing_seasons, no_upgrade_profile)
  run every DOCTOR_SLOW_INTERVAL (default 1800s)
- Support per-check overrides via <CHECK>_INTERVAL
- Add bounded concurrency (DOCTOR_SCHEDULER_CONCURRENCY) and per-check locks
- Keep event mode webhooks for immediate full sweeps
- Add UI endpoints: POST /api/sweep and POST /api/check/<name>
- Update UI schema, docker-compose.example.yml, and README.md

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Split 2380-line doctor.py into doctor/ package (config, clients, state, checks, scheduler, webui, __main__)
- Add atomic state_transaction() to prevent lost updates across concurrent checks
- Remove duplicate Seerr/check_seerr definitions from the old monolith
- Update Dockerfile, systemd service, README, and DEPLOY.md for python -m doctor
- Add unit tests for pure helpers and a concurrency regression test
- Add AGENTS.md with run/test/deploy notes

BREAKING CHANGE: Deployments must now run `python -m doctor` and copy the `doctor/` package instead of a single doctor.py file.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@augmentcode

augmentcode Bot commented Jun 19, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: Refactors the project from a single large doctor.py script into a structured doctor/ package and modernizes scheduling/state handling.

Changes:

  • Split the monolithic implementation into modules: config, clients, state, scheduler, webui, and checks/.
  • Introduced a per-check scheduler with separate fast/slow intervals and bounded concurrency.
  • Replaced previous state save behavior with an atomic state_transaction() to avoid lost updates under concurrency.
  • Fixed the prior duplicate Seerr/check implementation by centralizing the Seerr client and check.
  • Extracted the embedded dashboard HTML into doctor/ui.html and expanded UI endpoints (manual sweep / per-check trigger / Plex actions).
  • Added new checks (e.g. plexscan, repair, missing_seasons, no-upgrade profile) and updated docs/env examples accordingly.
  • Updated Docker/systemd entrypoints to use python -m doctor.
  • Added a small unit test suite covering helper functions and a concurrency regression test for state transactions.

Technical Notes: The refactor keeps the project stdlib-only, and moves most shared logic into reusable clients/helpers while allowing checks to run concurrently without clobbering persistent state.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 3 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread doctor/webui.py Outdated
checks.append({"name": "detail-page warm", "on": bool(WARM_PLEXLOG_CMD or WARM_PLEXLOG_FILE)})
return {"version": VERSION, "mode": MODE, "dry_run": DRY_RUN, "load": round(host_load(), 2), "checks": checks}
def _ui_warmer():
rec = [{"title": r["title"], "why": r["why"], "ago": int(time.time() - r["ts"])} for r in reversed(_warm_recent)]

@augmentcode augmentcode Bot Jun 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doctor/webui.py:100 — _ui_warmer() references _warm_recent/_warm_count, but those names won’t be imported via from .checks import * (leading-underscore symbols aren’t exported), so /api/warmer will raise NameError when the UI loads.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread doctor/checks/missing_seasons.py Outdated
added_str = ser.get("added") or ""
try:
import email.utils
added_ts = email.utils.parsedate_to_datetime(added_str).timestamp() if added_str else 0

@augmentcode augmentcode Bot Jun 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doctor/checks/missing_seasons.py:65 — email.utils.parsedate_to_datetime() typically expects an RFC2822 date, but Sonarr’s series.added is usually ISO8601; if parsing fails added_ts becomes 0 and the min-age gate may be bypassed, triggering searches too early.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread doctor/checks/repair.py Outdated
return False
target = os.readlink(fp)
if not os.path.isabs(target):
target = os.path.join(os.path.dirname(fp), target)

@augmentcode augmentcode Bot Jun 19, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doctor/checks/repair.py:44 — Using target.startswith(REPAIR_DEBRID_MOUNT) can match unintended prefixes (e.g. /mnt/debrid also matches /mnt/debrid2), which could make repair act on symlinks outside the intended mount root.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

machetie and others added 7 commits June 19, 2026 18:00
After the package split, _warm_recent and _warm_count live in the
warmer check module (imported as _warmer). The _ui_warmer() function
in webui.py was still referencing the bare names, causing a NameError
whenever the /api/warmer endpoint was served.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Extend the startup instance guard in main() to cover repair,
  missing_seasons, no_upgrade_profile, and providers in addition
  to queue; any of these enabled with no INSTANCE_* vars now
  exits with an explicit error instead of silently doing nothing.
- Upgrade log.debug to log.warning in check_repair and
  check_missing_seasons for the no-instances early-return path,
  so the condition is visible if the check runs mid-flight.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Raise default MISSING_SEASONS_MAX_ACTIONS from 5 to 25.
- Shorten default MISSING_SEASONS_RECHECK from 24h to 6h.
- Run missing_seasons every 15m by default while keeping other slow
  checks at 30m (via MISSING_SEASONS_INTERVAL default).
- Add MISSING_SEASONS_SORT_BY (mixed/added/episodes) to prioritize
  older and/or larger seasons first.
- Refactor check into shared _run_missing_seasons() with candidate
  gathering + sorting + capped processing.
- Add backfill_missing_seasons() and --backfill-missing-seasons flag
  that ignores the cap and recheck cooldown for a one-shot backlog
  clear, then resumes normal scheduling.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Audit & fix janitor bugs:
  - Make quarantine paths robust by using abspath and fp.lstrip("/").
  - Skip symlinks whose destination already exists in the quarantine dir.
  - Use context manager for manifest.json write.
  - Allow the check to run with only a log source (no JANITOR_LIBRARY_PATHS),
    logging an alert instead of silently returning when dead releases are
    found but cannot be quarantined.

- Add operational error scanning on the same log tail:
  - panic/fatal, rate-limit, cloudflare/blocked, auth, network/timeout.
  - Configurable extra patterns via JANITOR_ERROR_PATTERNS.
  - Throttled alerts via JANITOR_ALERT_COOLDOWN (default 5m).

- Probe decypharr API (DECY_URL) on root and /api/status:
  - Warns on 5xx, 401/403, and unreachable.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… helper

The janitor only matched /__all__/ in symlink targets, so dead releases
served from /mnt/altmount/complete/ were never quarantined. Update the
regex to capture release folders from both mount roots.

Also add stack-doctor-patch.py so the auto-update watcher can re-apply
local fixes after pulling upstream commits.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Fix scheduler "last=...s ago" log and add startup sweep logging
- Disable ANSI colours for Docker logs (NO_COLOR support)
- Split repair.py into focused modules under doctor/checks/repair/
- Add HTTP retry/backoff to Arr._req()
- Add filesystem-only orphan dead-symlink scan
- Add lightweight type hints to config helpers and Arr client

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ings

The default JANITOR_ERROR_PATTERNS included raw HTTP codes (401, 403,
429) compiled as plain substrings. This caused hundreds of false matches
per sweep because those digit sequences appear inside alldebrid torrent
IDs and hex hashes in the decypharr log (e.g. id=600004012 contains
401, hash ...f403... contains 403).

The hardcoded _JAN_OP_PATTERNS already match the same codes with \b
word boundaries, so the bare integers in the defaults were redundant.

Changes:
- doctor/config.py: remove 401, 403, 429 from default JANITOR_ERROR_PATTERNS
- doctor/checks/janitor.py: compile user-supplied patterns with \b...\b
  word boundaries so any future short tokens cannot match inside numeric
  strings
- doctor/checks/decypharr.py: distinguish dead FUSE (EIO/ENOTCONN) from
  a hung read; log separate message and pass reason to restart hook
- Dockerfile: add docker-ce-cli via official apt repo so restart hooks
  using docker commands work when /var/run/docker.sock is bind-mounted

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
root and others added 21 commits June 23, 2026 02:09
…coverage

Previously the check would push any cached multi-season pack regardless of whether
it actually covered the show's missing seasons. This adds:

- _pack_season_range(): parse S01-S05 range from pack title
- _incomplete_seasons_covered(): count of missing seasons inside that range
- _rank_packs(): sort by most coverage first, then widest pack, then quality weight;
  exclude packs with zero overlap entirely
  e.g. for a show with only S06+S07 missing:
    S01-S04 pack -> excluded (covers 0 missing seasons)
    S01-S06 pack -> 1 covered (pushed if cached)
    S01-S07 pack -> 2 covered (preferred over S01-S06)
- _incomplete_series() now returns incomplete season numbers per series
- Logging now shows which seasons are missing and how many each pack covers
- 25 new unit tests covering all new helper functions

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… decoupling, budget bug

Six targeted improvements from a maintainability/performance review:

1. Scheduler log bug (P1): last_run[cid] was overwritten to now before the
   "last=Xs ago" log line, so it always printed "last=0s ago". Fixed by
   capturing elapsed before updating.

2. O(1) zurg cache lookup (P3): _is_cached() previously called os.listdir()
   and scanned all 11k+ entries for every pack for every series (~29M string
   comparisons per sweep). Now _zurg_cache_set() builds a normalised frozenset
   once at sweep start, reducing per-pack checks to O(1) set membership.

3. Single title parse per pack (P4): _rank_packs() now returns (pack, pr, covered)
   triples instead of plain pack dicts. The push loop unpacks them directly,
   eliminating the redundant _pack_season_range() + _incomplete_seasons_covered()
   calls that previously ran again for every pack.

4. State decoupling (P2): multipack.py was reading __missing_seasons__ state
   directly using a hardcoded key format. Extracted searched_series(state, arr_name)
   as a public helper in missing_seasons.py. The key format is now owned in one
   place; multipack imports and calls the helper. Also fixes the split(":", 2)
   limit so arr names containing ":" are handled correctly (P8).

5. _missing_from_disk_check return value (P9): repair/main.py was discarding the
   updated acted count returned by _missing_from_disk_check, so the budget was not
   correctly tracked across the mfd and symlink sub-checks. Fixed with acted = …

6. missing_seasons interval side-effect (P7): config.py was mutating os.environ at
   import time to inject MISSING_SEASONS_INTERVAL=15m, which is invisible to
   _check_interval() and surprising. CHECKS table is now 5-wide with an optional
   default_interval_override field; _check_interval() uses it as a fallback between
   the env var and the speed-based global default.

All 95 tests pass.

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…st coverage

Bugs fixed:
- state.py: duplicate 'import time' and 'import threading' (introduced during
  last session's import cleanup rollback)
- webui.py: two 4-tuple CHECKS unpacks that crashed /api/status and
  /api/check/<id> after CHECKS was widened to 5-tuple in bca81d6

Cosmetic fix:
- plex.py: _plex_sections() read os.environ.get('PLEX_URL') / ('PLEX_TOKEN')
  instead of the already-imported module constants; also removed a redundant
  inline 'import xml.etree.ElementTree as ET' inside the function body

Structural refactors (behaviour preserved):
- scheduler.py: introduce CheckEntry namedtuple for the CHECKS table.
  Wrong-width entries now raise TypeError at import time rather than
  ValueError mid-sweep (the class of bug that broke __main__.py in 2a9367b).
  Positional unpacking at all three existing sites continues to work unchanged.
- clients.py: add Arr.quality_profiles() and Arr.update_series() public methods
  so callers don't need to use the internal _req() for these two common
  Sonarr operations.
- no_upgrade.py: replace the three direct _req() calls with the new public
  methods (quality_profiles, series, update_series).

Tests added (67 new tests, total 162):
- tests/test_churn.py (27 tests): full state machine for _churn_record and
  _churn_remonitor — all three actions (report, park, backoff), accumulation
  below limit, escalating levels, remonitor conditions, sentinel handling
- tests/test_queue.py (28 tests): stuck_reason() all six predicates,
  check_queue() strike accumulation, DRY_RUN, MAX_ACTIONS cap, per-arr
  filtering via only=, remove() exception handling, None queue response
- tests/test_no_upgrade.py (12 tests): profile lookup (by name and explicit
  ID), series filtering (status, completion, profile match), update_series
  call verification, failure isolation

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Add 33 tests for repair/dead_symlinks.py: _radarr_dead_files,
  _sonarr_dead_files, _repair_radarr_movie, _repair_sonarr_season
  (filtering, toggle/search orchestration, DRY_RUN, REPAIR_VERIFY)
- Add 40 tests for janitor.py: _scan_operational_errors regex matching,
  _read_log_tail file/command fallback, _jan_alert throttle, _probe_decy_api
  HTTP health probing, false-positive suppression for hash/ID substrings
- Add 23 tests for plexscan.py: _is_scan_activity predicate,
  check_plex_scan stuck detection, progress-advance reset, 3-step recovery
  cascade (mount probe, cancel, restart), DRY_RUN guard, rate-limiting
- No production code changes; all tests use unittest.mock.patch on
  star-imported module globals and MagicMock for Arr/Plex instances
- Suite total: 258 tests, 0 failures

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Replace ad-hoc module-level mutable cells with a small private _State
class that exposes .value and .reset(). This makes check state easier to
inspect and reset in tests without changing behavior.

- decypharr.py: _fuse_strikes = [0] -> _State(0), _decy_last_restart = [0.0] -> _State(0.0)
- plexscan.py: _scan_seen = {} -> _State({}), _plex_last_restart = [0.0] -> _State(0.0)
- Update test_decypharr.py and test_plexscan.py to use .value / .reset()
- No behavioral changes; all 258 tests pass

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…tils.py

Move http_code, run_cmd, run_output, and host_load to a new doctor/utils.py
module so config.py can focus on environment parsing and logging setup.

- doctor/utils.py: new module containing the four stateless helpers
- doctor/config.py: remove helper definitions and unused imports; re-export the
  four names from doctor.utils for full backward compatibility
- tests/test_utils.py: 14 new characterization tests covering the same
  inputs, outputs, error handling, and subprocess behavior
- No behavioral changes; scheduler, checks, state format, env vars, and
  star-import compatibility are preserved
- Full suite: 272 tests pass, 1 skipped

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
…ctor/utils.py

Extract the four stateless utility helpers from doctor.config.py into a new
doctor/utils.py module. doctor.config.py re-exports them so existing star-imports
and direct imports keep working without changes.

- doctor/utils.py: new home for http_code, run_cmd, run_output, host_load
- doctor/config.py: remove helper definitions; import the four names from utils.py
- tests/test_utils.py: target the new doctor.utils location for mocks and imports
- No logging, env parsing, scheduler, check, or state changes
- Zero third-party dependencies
- Full suite: 272 tests pass, 1 skipped

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
…instances from CHECKS

Add a needs_instances field to the CheckEntry namedtuple and mark each check
in the CHECKS table. doctor/__main__.py now derives _needs_instances from the
table instead of maintaining a separate hardcoded list of (name, flag) pairs.

- doctor/scheduler.py: CheckEntry gains needs_instances; CHECKS entries updated;
  all positional unpacks widened to 6-tuple
- doctor/__main__.py: _needs_instances = [cid for cid, en, ..., needs in CHECKS if en and needs]
- doctor/webui.py: widen CHECKS unpacks to 6-tuple

Behavior preserved: same checks trigger the "require at least one instance"
error, and the error fires under the same conditions. Scheduler order, state
format, env names, and star-imports are unchanged.

Full suite: 272 tests pass, 1 skipped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
…eck imports

Replace the from .checks import * in doctor/scheduler.py with an explicit list
of the 13 check callables referenced by the CHECKS table. This makes the
scheduler's dependencies visible and narrows the surface area of the checks
package without changing check implementations or other star-imports.

- doctor/scheduler.py: explicit import of check_bazarr, check_decypharr,
  check_janitor, check_missing_seasons, check_multipack,
  check_no_upgrade_profile, check_plex, check_plex_scan, check_providers,
  check_queue, check_repair, check_resources, check_seerr
- doctor/checks/__init__.py left unchanged per Phase 3 boundaries
- CHECKS still resolves all functions to the same module-level callables
- No scheduler semantics, state format, env var, or startup behavior changes
- Full suite: 272 tests pass, 1 skipped

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
…ports

Replace the five star-imports in doctor/__main__.py with explicit imports of
only the names the entry point actually uses.

- from .config import * -> explicit import of VERSION, MODE, EN_WARMER, EN_UI,
  PLEX_URL, PORT, UI_PORT, DRY_RUN, WARM_PLEXLOG_CMD, WARM_PLEXLOG_FILE, log
- from .clients import * -> INSTANCES, load_instances
- from .checks import * -> backfill_missing_seasons, plexlog_loop, warmer_loop
- from .scheduler import * -> CHECKS, scheduler_loop
- from .state import * -> removed entirely; __main__.py does not use state
  directly (individual checks import it as needed)

No behavior changes; scheduler order, startup error behavior, state format,
env vars, and deployment model are untouched. Standard-library imports in
__main__.py were left as-is to avoid unrelated cleanup.

Full suite: 272 tests pass, 1 skipped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
Lock in the public names that doctor.checks must expose before converting
its __init__.py from star-imports to explicit re-exports.

- Verify all 13 check_* functions are available from doctor.checks
- Verify backfill_missing_seasons, warmer_loop, and plexlog_loop are available
- Verify the warmer module is importable as doctor.checks.warmer (used by webui.py)
- Simulate the exact import patterns used by scheduler.py, __main__.py, and webui.py

20 new tests; full suite: 292 tests pass, 1 skipped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
…re-exports

Replace the 13 wildcard imports in doctor/checks/__init__.py with explicit
imports of the check entry points and auxiliary functions. Define __all__ so
the public surface of doctor.checks is well-defined and no longer leaks the
config/client/state names that individual submodules happen to import.

Explicitly re-exported:
  - 13 check_* functions used by scheduler.py
  - backfill_missing_seasons used by __main__.py
  - warmer_loop and plexlog_loop used by __main__.py

Submodules are still importable as attributes (e.g. doctor.checks.warmer) but
are not included in wildcard exports. The package compatibility boundary is
preserved for all known consumers.

Full suite: 292 tests pass, 1 skipped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
webui.py already imports the warmer module explicitly as _warmer and imports
_plex_rescan / _plex_empty_trash from .checks.plex. The wildcard import from
doctor.checks was no longer needed after checks/__init__.py became explicit,
so remove it to narrow the import surface further.

No behavior change; full suite: 292 tests pass, 1 skipped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158242242+devin-ai-integration[bot]@users.noreply.github.com>
Replace all remaining star-imports in top-level doctor/ modules with
explicit named imports identified by AST analysis:

- scheduler.py: from .config import * → 20 explicit names
- clients.py:   from .config import * → 4 names (BLOCKLIST, REMOVE_CLIENT, TIMEOUT, log)
- state.py:     from .config import *, from .clients import * → 7 explicit names
                + remove 11 dead stdlib imports (sys, re, signal, subprocess,
                  logging, logging.handlers, urllib.*, xml.etree.ET, datetime, timezone)
- webui.py:     from .config import *, from .clients import * → 21 explicit names

All 292 tests pass. Each change is independently rollback-safe.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…star-imports)

Replace from ..config import *, from ..clients import *, from ..state import *
in all 20 check/repair submodules with minimal explicit named imports
identified by AST analysis + util-reexport audit:

Batch 1 (no state dependency): bazarr, plex, resources, providers, decypharr, janitor
Batch 2 (state-using): queue, missing_seasons, seerr, multipack, no_upgrade, plexscan
Batch 3 (repair/): common, dead_symlinks, main, missing_from_disk, orphan, verify, warmer
  - season_pack.py: both star-imports were completely dead — removed entirely

After this commit, grep -r 'import \*' doctor/ returns nothing.
All 292 tests pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
webui.py:    __all__ = ["_build_server"]
scheduler.py: __all__ = ["CHECKS", "CheckEntry", "scheduler_loop", "sweep",
                          "_run_scheduled_check"]

No behavioral change. These modules have no star-import consumers; __all__
documents the intended public surface for IDEs and future readers. Both lists
match exactly what __main__.py and webui.py already import by name.

292 tests pass, skipped=1 unchanged.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
CI (Phases 1-3)
- New ci.yml: test → lint → smoke → publish pipeline.
  publish (docker-push) is gated on all three checks passing.
- Retire the standalone docker-publish.yml (replaced by ci.yml publish job).
- /healthz smoke test: curl the container after docker run in CI.

Lint gate (Phase 2, ruff F-only)
- pyproject.toml: select = ["F"], per-file ignores for intentional re-exports.
- Critical bug fixes found during ruff pass:
  - repair/main.py: `import time` was missing; `time.sleep()` would raise
    NameError at runtime.
  - repair/verify.py: local `import datetime` inside function shadowed the
    module-level import (F811 redefinition); removed duplicate.
- 205 dead stdlib imports removed across check modules (ruff --fix F401).
- doctor/config.py: restored compat re-export of utils helpers with
  `# noqa: F401`; added same marker to __init__.py and repair/__init__.py
  re-exports so ruff won't strip them in future passes.
- All existing tests updated to match the cleaner import structure.

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Phase 4: seerr.py + repair/missing_from_disk.py  (34 tests)
  test_seerr.py:
    - early-exit on missing config / unreachable client
    - retry path: counter incrementation, exception swallowing
    - SEERR_MAX_TRIES gate (hard stop + unlimited=0 mode)
    - SEERR_MAX cap, DRY_RUN mode, stale-key cleanup

  test_repair_missing_from_disk.py:
    - sonarr: SeasonSearch triggered, season-dedup per sweep, budget decrement
    - radarr: MoviesSearch triggered, {"records":[...]} dict unwrap
    - REPAIR_UNMONITORED flag, recheck cooldown, DRY_RUN
    - history/media-list exception handling

Phase 5: repair/main.py + repair/orphan.py  (35 tests)
  test_repair_main.py:
    - early-exit: no instances, load too high, mount not OK
    - REPAIR_VERIFY, symlink sweep counts, REPAIR_MAX_ACTIONS/SYMLINKS caps
    - REPAIR_SEASON_PACKS sub-check + DRY_RUN
    - REPAIR_MISSING_FROM_DISK, REPAIR_ORPHAN_SCAN toggles
    - per-arr sweep exception swallowing

  test_repair_orphan.py:
    - _collect_known_paths(): sonarr/radarr path collection, other-kind skip,
      missing-id/path handling, exception swallowing
    - _orphan_dead_symlink_scan(): no-REPAIR_LIBS early exit, non-dir warning,
      known-path exclusion, live-file exclusion, orphan detection, 20-path cap

Phase 6: repair/verify.py  (15 tests)
  test_repair_verify.py:
    - _repair_verify_pending: no-op on empty state, unknown-arr removal,
      command poll (terminal / None status), cmd_done skip,
      grab detection, deadline expiry, multi-entry independence
    - _repair_record_verify: key structure/slug, deadline arithmetic,
      non-int cmd_id stored as None, entity_ids defaults, ISO timestamp

Phase 7: warmer.py  (25 tests)
  test_warmer.py:
    - _host_path: prefix rewrite, no-colon/no-map cases
    - _limit_parts: zero/negative/positive WARM_PARTS
    - _warm_record: counter increment, 80-entry ring buffer
    - _warm_file: load guard, cooldown check, stat failure, read timeout
    - warm_cycle: load skip, WARM_MAX_CYCLE cap, failed-warm pass-through
    - _warm_targets: no-sources, ondeck/sessions interaction, next-ep
      near-end guard, duplicate-path dedup

Total tests: 292 → 401  (+109)  all passing.

Generated with Devin

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Phase A — correctness fixes (tests first, behavior preserved)
  A1: repair/missing_from_disk — break→continue for non-sonarr/radarr
      instances so prowlarr before sonarr no longer silently skips all
      MFD checks; budget=0 still breaks as before
  A2: repair/main — move _orphan_dead_symlink_scan outside
      state_transaction; it is a read-only fs walk that needs no lock
  A3: clients.history_grabbed — replace lexicographic ISO timestamp
      string compare with datetime.fromisoformat epoch comparison;
      fixes false-negative on millisecond dates (T04:55:30.5Z) and
      unparseable-date false-positive; also switch _repair_record_verify
      to datetime.now(timezone.utc) (deprecation fix)
  A4: seerr cleanup — filter id=None before building live set so
      str(None)='None' can no longer pin a stale state key

Phase B — observability (additive, no behavior change)
  B1: scheduler — add _check_runs dict + _record_run(); populated by
      _run_scheduled_check (ok/error/skipped/deferred) and sweep (ok/error)
  B2: webui/_ui_status — merge _check_runs into each check's JSON entry
      (last_start, last_end, last_duration, last_outcome, last_error,
      run_count, error_count); nulled defaults for checks never run
  B3: webui — add GET /api/state endpoint (authenticated, EN_UI-gated)
      returning full state.json for operator inspection
  B4: warmer — add 5 per-cycle metadata vars (last_cycle_ts, duration,
      warmed, candidates, skipped_load); exposed via /api/warmer

New test files: test_clients_history_grabbed (15), test_scheduler_run_metadata (11),
  test_webui_status (11), test_webui_state_endpoint (5)
Expanded: test_repair_missing_from_disk (+2), test_seerr (+2), test_warmer (+6)
Total: +52 tests → 453 passing

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- doctor/clients.py: remove unused `timezone` import (datetime.timezone
  was imported alongside datetime but never referenced after A3 fix)
- tests/test_clients_history_grabbed.py: remove unused MagicMock and
  clients_mod imports; retain `patch` which is used by patch.object
- tests/test_scheduler_run_metadata.py: remove unused `threading`,
  `patch`, and `call` imports; retain `MagicMock`
- tests/test_webui_status.py: remove unused `patch` import

All 453 tests pass; ruff reports no issues.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…al re-search

The janitor used to quarantine every symlink under a release once any file in that
release was reported dead by Decypharr. This over-quarantined healthy episodes
(e.g. most of Mr. Robot was removed when only S03/S04 files were actually dead).

Now the janitor:
- Extracts the exact dead file path (RELEASE/filename.mkv) from the Decypharr logs
- Only quarantines the specific symlink(s) pointing to that file
- Records the dead file in persistent state with the original library path

The repair check then reads that state and, even if the symlink is already gone,
maps the dead file back to the correct Sonarr series/season/episode. For ended
shows this triggers the existing hierarchical SeriesSearch (whole-show/multi-season
replacement first), so a few dead episodes cause a complete-series re-search.

Also adds a fallback that guesses the series from the release name and parses
SxxEyy/SxxEyy-Ezz from the filename when the symlink was already removed before
the new code ran.

Tests added for file-level quarantine, state recording, path parsing, release-name
guessing, and episode-range parsing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@dreulavelle

Copy link
Copy Markdown

holy shit

…sarr + Plex

Two modes:
- tagged: delete only pulsarr-tagged shows (cleanup Pulsarr DB + Plex watchlist)
- all: delete any show matching criteria (empty Plex trash after)

Criteria: year < MAINTAINER_MIN_YEAR, status ended, added >=N days ago,
not watched in N days per Tautulli, optional pulsarr tag filter.

New clients: Tautulli (watch history), Pulsarr (watchlist exclusions, DB access).
DB access reads plexTokens from pulsarr.db configs for Plex watchlist removal.
- Replace broken sqlite3 DB cleanup with Pulsarr API watchlist exclusions
- Fix Pulsarr client API paths (/health, /v1/watchlist-exclusions)
- Add DB fallback for user lookup when API auth unavailable
- Fix tag parsing: extract correct Plex username from pulsarr-user-* tags
- Add MAINTAINER_ROOT_FOLDER_PATHS config to exclude anime_shows
- Change default MAINTAINER_PULSARR_TAG_PREFIX to 'pulsarr' (no dash)
- Fix webui Pulsarr health check path
machetie added a commit to machetie/stack-doctor that referenced this pull request Sep 7, 2026
Audit Neoo-Blue#6 + Neoo-Blue#8. Generated a grouped config reference (274 env knobs by prefix) into docs/.
Organised clutter (moves, not deletes): 22 doctor.py.bak -> src/backups/, 28 handoff .bak ->
data/backups/handoff/, 29 retired one-off audit/fix scripts -> _retired-ad-hoc/. The data dir
now holds only the 2 live modules (placeholder_ops.py, rules_engine.py).
@machetie machetie closed this Sep 16, 2026
@machetie
machetie deleted the feat/plexscan-repair-checks branch September 16, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants