Commit graph

2258 commits

Author SHA1 Message Date
dependabot[bot]
2be657a788
build(deps): update fastapi requirement in /controller (#604)
Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.138.2...0.140.13)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.140.13
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:30:02 -06:00
dependabot[bot]
03a0bd6dd4
build(deps): update docker requirement in /controller (#603)
Updates the requirements on [docker](https://github.com/docker/docker-py) to permit the latest version.
- [Release notes](https://github.com/docker/docker-py/releases)
- [Commits](https://github.com/docker/docker-py/compare/7.1.0...7.2.0)

---
updated-dependencies:
- dependency-name: docker
  dependency-version: 7.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 16:29:59 -06:00
Roland
0efd0bac94
review: describe the device, not its address
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Regression docstrings named the LAN address of the box a bug was found
on.  The address is not part of the finding and does not belong in the
repository; the symptom and the shape of the failure are what a reader
needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:16:00 -06:00
Roland
06ac0d2055 review: ffprobe out of the write transaction, and three tests that were watching nothing
All three bugs confirmed; the two suggestions taken.

[bug] `video_modeler` awaited ffprobe -- a subprocess per video, up to 20
per batch -- inside its `commit=True` session.  With IMMEDIATE as the
default that holds the write lock for the whole batch, which is the same
starvation this branch is curing in `video_cleanup`.  (It was already
partly true before: the batch's first `session.flush([video])` took the
lock ahead of the remaining ffprobes.)  The batch is now read, ffprobed
with no transaction open, then written.  `get_or_create_ffprobe_json` in
modules/videos/common.py is the path-only entry point that makes that
possible; `Video.get_ffprobe_json` delegates to it and keeps appending
the cache file to its FileGroup.  `validate()` -- and the poster
generation it can trigger -- is still inside the write transaction, and
now says so.

[bug] `test_download_dispatched_outside_immediate_transaction` probed
`getattr(_immediate_txn, 'active', False)`, which a ContextVar never has,
so it passed unconditionally.  It now probes the actual write lock from a
competing connection under `production_like_sessions`.  Mutation-checked:
moving the dispatch back inside the claim transaction fails it.

[bug] `asyncio.create_task` snapshots the current Context, so a flag held
for a write session's lifetime was inherited by every task spawned inside
it -- and the parent's `reset()` cannot reach a child's copy, leaving
that task issuing BEGIN IMMEDIATE for its *read* sessions.
`create_downloads` spawns `dispatch_downloads` exactly that way, so every
user-created download hit it.  The flag now lives only across the
session's opening `connection()` call, which also means the write lock is
taken when the session opens rather than at the caller's first statement.

[suggestion] `renew_recurring_downloads` scanned every recurring Download
under the write lock, including the usual cycle where nothing is due.  It
reads first now and takes the lock only for the rows that change,
re-checking status since the dispatcher may have claimed one in between.

[nit] Parenthesized the directory OR in the claim join.

`probe_write_lock_is_held` moved to conftest alongside
`write_lock_held_briefly`; three suites use it now.

Suite: 1712 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:15:50 -06:00
Roland
daf2ba39d4 db: write intent is the opt-in, not a second session helper
Three "database is locked" failures on the docker WROLPi, one cause each:

  * `INSERT INTO download` from POST /api/download -- a user creating a
    video download while a refresh ran (2026-08-06 14:06:46);
  * six `INSERT INTO file_group` from `upsert_file`, uploads landing in
    the same three-minute window;
  * `UPDATE download SET status` from `renew_recurring_downloads`.

All three are the deferred read-then-write trap: a transaction that
begins as a reader and upgrades at its first write is refused
*instantly* when another connection wrote in between, because
busy_timeout is skipped for lock upgrades.  `get_immediate_db_session`
existed to avoid it, but an opt-in helper only works if every author
remembers, and read-then-write is the ordinary shape of a write.  So it
is gone: `get_db_session(commit=True)` -- the existing declaration of
write intent -- now begins IMMEDIATE.  Rails 7.1 and Django 5.1 made the
same call, defaulting SQLite transactions to IMMEDIATE rather than
asking application code to opt in.

That default is only safe if write transactions stay short, and one did
not.  `video_cleanup` ran its whole Channel-claiming join --
`UPDATE video ... FROM channel ... LEFT JOIN file_group`, O(channels x
file_groups) -- inside one write transaction: 110 to 130 seconds each of
the eight times it appears in the log, on a library with ~1500 channels.
While it ran, *every* writer on the box failed, including ones that
began IMMEDIATE, because the hold far exceeds the 30s busy_timeout.  The
14:04-14:06 failures above sit inside its 130-second window.  The join
is now a read -- WAL readers never block writers -- and only the
resulting UPDATEs take the lock, 500 rows per transaction.
`claim_videos_for_channels` had the same join and the same fix; channel
import calls it with every channel in channels.yaml.

Also here:

  * `renew_recurring_downloads` was a read session with an inner commit;
    it is a write session now.  Its computed `next_download` values now
    persist even when nothing was renewed -- that is the point of
    computing them.
  * POST /api/download no longer writes through `request.ctx.session`.
    That session is committed by the response middleware, so a writer
    would hold the lock across the whole request; a write session
    commits before the handler returns, ahead of the background dispatch
    it fires.
  * `_immediate_txn` is a ContextVar, not a threading.local.  The
    writers that set it include coroutines, and one asyncio thread runs
    many of them interleaved.
  * `write_lock_held_briefly` moved from the move tests into conftest,
    since two suites now need it.

Both regression tests were verified by mutation: reverting either site
alone fails its own test, and the API one fails with the production
error verbatim.

Sources: fractaledmind.com/2023/12/11/sqlite-on-rails-improving-concurrency,
tenthousandmeters.com/blog/sqlite-concurrent-writes-and-database-is-locked-errors,
berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:15:50 -06:00
Roland
457df5f31a
Bump version to 0.28.7-beta 2026-08-07 15:16:33 -06:00
Roland
c79d006a06
review: flush before the rename, not just after one delete
Move the flush to immediately before FileGroup.move so it covers every
deletion queued in this function, not only the one found by
get_by_path.  The earlier missing-file deletions were already reaching
the DB -- the get_by_path query autoflushes them -- but that is an
incidental property of the query sitting between them and the rename,
not something the rename should depend on.

Also assert the kept Video's old path is gone, so the test reads a move
rather than a copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:08:40 -06:00
Roland
3a57e28cc6 fix: the deleted duplicate still held the path we renamed onto
`delete_duplicate_videos` deletes the smaller copy sitting at the
destination path, then renames the copy it keeps onto that path.
`FileGroup.delete` unlinks the files right away but only queues the row
deletion on the session, and SQLAlchemy emits UPDATEs before DELETEs
within a flush -- so the rename's UPDATE of `primary_path` collided with
the row that was about to be deleted:

    sqlite3.IntegrityError: UNIQUE constraint failed: file_group.primary_path

The whole download transaction rolled back, so a re-downloaded video
lost its DB record while its files stayed on disk (and the previous
copy's files were already unlinked).

Flush the delete before claiming the path.  The earlier missing-file
deletions in the same function never hit this because the `get_by_path`
query between them autoflushes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:08:31 -06:00
Roland
4b9593580d
fix: a video download requests one format, not every matching format
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
The yt-dlp `-f` selector was built by joining every selector of every
requested resolution with a comma.  In yt-dlp a comma means "download all
of these formats"; `/` is the fallback operator.  The resolution map is
ordered best-to-worst and its entries already use `/` internally, so the
comma was never the intent.

With the default resolutions this handed yt-dlp ~45 comma-separated
selectors and asked it to download every one that matched.  A 720p video
matched 8 of them (including duplicates, which yt-dlp does not dedupe), so
the whole per-download pipeline ran 8 times against the same output path:
subtitles downloaded, thumbnail downloaded, info.json written, subtitles
converted, thumbnail converted, existing subtitles deleted, repeat.  The
duplicate work was invisible because each pass overwrites the last.

Join with `/` so the resolution list behaves as the preference chain it
reads as.
2026-08-05 17:25:19 -06:00
Roland
b9a892b203
fix: act on review — don't hold the write lock across the source-tree walk
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
Two review points, both correct.

The planning session was opened before the filesystem collection that runs inside it, so
BEGIN IMMEDIATE held the write lock across every glob and directory walk - trading an
instant abort for minutes of global write starvation on a large channel.  The collection
now finishes before the session opens; only the temp-table load, the FileGroup reads and
inserts, and the commit run under the lock.  `file_source_set` is built during that same
pass instead of re-globbing every file source a second time.

`test_file_worker_move_survives_concurrent_writer` held the lock across the whole move, so
planning waited it out and the execute phase usually met no contention - the test would
have stayed green if only the execute site regressed.  It now takes the lock after planning
returns.  Verified by mutation: reverting either site alone fails its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:53:44 -06:00
Roland
dbbcab4d90 fix: a concurrent writer no longer aborts a file move with "database is locked"
Tagging a Channel with "move files" checked failed the whole move: the Collection's
directory was updated, but its files stayed put.

`build_move_plan_bulk` reads FileGroups and then INSERTs the ones missing from the DB,
inside a transaction that begins *deferred*.  SQLite refuses a mid-transaction lock
upgrade immediately — busy_timeout is skipped for upgrades to avoid deadlock — so any
concurrent writer (a refresh, a download, a config save) aborted planning outright.
The same pattern in `_execute_move_chunks` fails the same way on its Directory INSERT.

Both transactions now begin as writers, so busy_timeout absorbs the contention.  That
requires the writes nested inside the move to share its transaction rather than open a
second connection, which would deadlock against the lock the move already holds:
`_bulk_update_file_groups_db` now runs on the caller's session, and the old-directory
cleanup deletes its Directory record through that session instead of via
`delete_directory`.  As a side effect, a failed move no longer leaves earlier chunks'
path updates committed after their files have been moved back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:52:23 -06:00
Brian S.
adce235a7e
Strip whitespace from inventory item string values on save
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
modules/inventory/common.py: _normalize_items now strips leading/trailing whitespace from any string-valued item field before persisting an inventory (name, category, subcategory, location, etc.). Non-string values (numbers, None) are left untouched.

Closes #52
2026-07-30 17:34:46 -06:00
Roland
540081203f
controller: restore useConfigs and useDownloads
The toggle-hook de-duplication replaced a range of customHooks.js that also
contained these two unrelated hooks, breaking the production build. Restored
byte-identical from master; exports now match master exactly.

Jest missed this because it only compiles what tests import — `npm run build`
is what catches a missing export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:42:34 -06:00
Roland
07af0c9f28 controller: de-duplicate the hardware toggles
The Hotspot, Bluetooth, Desktop, VNC and Throttle toggles had each been written
out longhand at four layers. Collapse the shared parts:

- useSubsystemToggle() replaces five near-identical hooks. This also fixes a
  latent bug: only useDesktop/useVnc restored state when a request failed, so a
  failed Bluetooth/Throttle/Hotspot toggle left  at null and — since the
  status poll sees no change — the toggle stayed disabled and read as
  unsupported until it remounted.
- SubsystemToggle renders all five, with the Desktop's stop confirmation and
  VNC's desktop gating as declared options rather than bespoke copies.
- The fallback UI's four update*/toggle* pairs become one config-driven
  implementation (-100 lines).
- _raise_for_action() centralises the 13 endpoint failure checks, including
  VNC's 409-vs-500 split.

Deliberately not shared: the six get_*_status_dict() builders (payloads
genuinely differ) and the per-subsystem mechanisms (nmcli/rfkill/sysfs/systemctl).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:48:08 -06:00
Roland
5313ab57ef controller: report and stop every installed VNC backend
Greptile P1 (security): with both stacks installed, selection keyed only off
wayvnc's unit and config. If RealVNC was the one running, status reported VNC
as off and stop targeted wayvnc — leaving the screen remotely accessible while
the UI claimed VNC was off. Reproduced on 10.0.0.8: RealVNC serving on 5900
reported as running=false.

Now every installed backend is reported with its own active state: status is on
when any backend runs and names the one actually serving, and stop stops all
running backends. wayvnc is still preferred for starting.

Greptile P2 (latency): both units are probed in a single multi-unit
`systemctl show`, and the status dict no longer re-probes, so VNC adds one
subprocess to /api/stats rather than three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:05 -06:00
Roland
2328524ab2 controller: VNC toggle, gated on the Desktop running
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.

Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.

VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.

Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.

Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:05 -06:00
Roland
a0fa03e6c5
controller: leave a usable terminal when stopping the desktop
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Stopping the display manager left the Pi's monitor black with only a blinking
cursor. The console VT is already in the foreground with a getty and a live
shell on it (verified: /dev/vcs1 holds the login banner and prompt) — the
display manager just hands the display back without the console being
repainted, so nothing is visible.

Activate the console VT, then bounce off another VT to force a modeset and
redraw, and print a short message telling the user the Controller stopped the
desktop and how to start it again.

Best-effort throughout: skipped when no getty is on the console VT, a chvt or
console-write failure never fails the stop, and being stranded on the repaint
VT is reported rather than claimed as success. Verified on 10.0.0.8 hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:07:36 -06:00
Roland
ab5f416b3f
upgrade: report git branch via --show-current so a stale release tag can't break upgrades
git rev-parse --abbrev-ref HEAD reports "heads/release" when a stale
tag named release exists alongside the release branch (tags outrank
heads in git ref resolution).  check_for_update then looked up the
nonexistent origin/heads/release and permanently reported no updates
available, and the Controller UI pre-filled "heads/release" as the
upgrade branch.

Use git branch --show-current everywhere instead; it never
disambiguates.  A detached HEAD (empty output) now defaults to the
release branch for upgrades; a genuine git failure still returns None.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:03:33 -06:00
Roland
9e8050bd48
controller: restore Desktop toggle state when a start/stop request fails
Greptile P1: on failure the status poll never re-syncs (the desktop did
not change), so the toggle stayed disabled until remount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:19:47 -06:00
Roland
3e559e1bf8 controller: Desktop start/stop toggle; rename hotspot/bluetooth actions to what they do
Add a Desktop toggle to the Controller (both UIs) that starts/stops
display-manager.service. Fail open: the unit is never enabled/disabled,
so the desktop always returns on reboot; users who want it disabled
across reboots can set the default target themselves.

Rename the hotspot and bluetooth actions to reflect their real behavior:
- enable/disable_hotspot -> start/stop_hotspot (/api/hotspot/start|stop)
- enable/disable_bluetooth -> unblock/block_bluetooth (rfkill semantics,
  /api/bluetooth/unblock|block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:19:36 -06:00
Roland
a4eeba14ee
downloads: surface daily-limit stall to users
Add daily_limit_reached to the DownloadManager summary (/api/status):
True when due (new, non-recurring) downloads exist and every one of
them is blocked by the global or per-domain daily download limit.
Show a "Max Daily Downloads Reached" banner on the Downloads page when
set (and the manager is neither disabled nor stopped).

Previously a queue stalled by the daily limit looked broken: the UI
reported 0 pending with no explanation, and the skip reason was only
visible at DEBUG log level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:58:37 -06:00
Roland
2baa9cbd2e
controller: address hotspot protocol review findings
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
- Reset a saved protocol the newly probed device does not support
  instead of offering it (both UIs); a successful probe is
  authoritative, the saved value is only kept when probing fails.
- Ignore out-of-order protocol probe responses when switching devices
  quickly (both UIs).
- Verify the WPA2 rollback after a failed WPA3 activation; report that
  the hotspot may be down instead of claiming the revert succeeded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:12:34 -06:00
Roland
8687816b84 controller: selectable hotspot protocol (WPA2/WPA3) with device capability probing
Probe the selected WiFi device with iw to determine which hotspot
protocols it can host: WPA2 requires AP mode; WPA3 requires SAE in AP
mode (SAE_OFFLOAD_AP, or a mac80211 driver with the CMAC cipher for
software SAE).  The Raspberry Pi's brcmfmac supports neither, so Pis
correctly offer WPA2 only (verified against real Pi 4/Pi 5 iw output).

The protocol is saved as hotspot.protocol in controller.yaml.
enable_hotspot() now always sets the Hotspot connection's key-mgmt
explicitly (sae + required PMF for WPA3) so a reused profile cannot
keep a stale protocol, and reverts to WPA2 if the driver rejects SAE.

New GET /api/hotspot/protocols endpoint; protocol dropdowns added to
both the ControllerPage React UI and the fallback controller UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:12:24 -06:00
Roland
2355095391
Bump version to 0.28.6-beta
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2026-07-23 19:58:16 -06:00
Roland
1a88fb4a5b
controller: report idle (0 RPM) fans; prefer the CPU fan over case fans
The RPi 5 firmware stops the fan entirely when cool, so a 0 RPM
reading is normal and must not be hidden. On multi-fan machines,
prefer a CPU-labeled hwmon group over faster case fans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:52:58 -06:00
Roland
42a017294e controller: report cooling fan RPM in CPU status
The RPi 5 fan connector exposes a tachometer via the pwmfan hwmon
device. Report it as fan_rpm in cpu_stats and show it in both the
Controller fallback UI and the React Status page. Hidden when no fan
is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:52:47 -06:00
Roland
f635671266
controller: don't strand the API stopped on a failed primary unmount
Review feedback: skip the service stop when the primary mount is already
unmounted (a no-op request must not cause an API outage), and restart
wrolpi-api (best effort) when umount fails since the drive is still
mounted and usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:23:05 -06:00
Roland
43f6ed2b8e controller: allow unmounting the primary drive, stopping the API first
Support swapping in a backup drive: /api/disks/unmount now accepts the
primary mount (previously rejected as reserved). It stops wrolpi-api so
open files are released, then umounts directly since the reconciler
never manages the primary mount. The /etc/fstab entry is left in place
so a reboot restores the mount.

Both UIs now offer Unmount for the primary mount and warn that the API
service will be stopped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:22:53 -06:00
Roland
5a11e23a56
controller: expose kind/group in the services API response
The React ControllerPage partitions services on `service.group`, but
ServiceStatusResponse did not declare the new kind/group fields, so
Pydantic silently stripped them from /api/services and the React UI
rendered one flat, ungrouped table. The fallback UI was unaffected
because it is server-rendered without the response model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:44:59 -06:00
Roland
36f84941db
install: always install serve; resolve it via PATH in wrolpi-app
wrolpi-app crash-loops with 203/EXEC when /usr/local/bin/serve is missing.
Two independent causes, both fixed here:

1. The `single-file --version ||` guard in the install scripts only installed
   serve (and readability) when single-file was ABSENT. On a machine that
   already has single-file (a prior install or a baked image), serve was
   skipped, and re-running install.sh could never repair it. Install each npm
   global independently based on its own presence.

2. The service hardcoded /usr/local/bin/serve, which breaks when npm's global
   prefix puts serve in /usr/bin (depends on how node was installed). Resolve
   serve via PATH with `/usr/bin/env serve`.

Verified on a fresh Raspberry Pi OS Trixie install (10.0.0.8): wrolpi-app is
active with 0 restarts and serves 200 with the PATH-resolved ExecStart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:21:58 -06:00
Roland
cd11b5b7b7
downloader: don't leak processing_domains on failed claim or missing row
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Addresses two processing_domains leaks raised in review; each could permanently
exclude a domain and eventually stall the scheduler until restart.

1. Claim-transaction failure (Greptile P1): `_add_processing_domain` mutated shared,
   non-transactional state before the claim transaction committed.  A commit failure
   skipped dispatch but left the domain claimed forever.  Fix: defer both the
   processing_domain add AND the signal dispatch to after the transaction commits;
   track claimed domains in a local `claimed` list so the loop's own dedup /
   concurrency cap is unchanged.

2. Row deleted before dispatch (Greptile P1): if a claimed download was deleted
   between commit and the signal running, `Download.find_by_id` raised before
   `download_domain` was set, so the handler's `finally` released nothing.  Fix:
   derive `download_domain` from the URL (`urlparse(url).netloc`, the same value the
   dispatcher claimed) up front, so cleanup always runs.

Regression tests written bug-first (both fail before this change):
- test_signal_releases_processing_domain_when_download_missing
- test_dispatch_releases_claimed_domains_when_transaction_fails

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:33:08 -06:00
Roland
f341e386ad downloader: fix self-deadlock that silently stopped all downloads
`_dispatch_new_downloads` claimed downloads inside a `get_immediate_db_session()`
(BEGIN IMMEDIATE) transaction and then `await`ed `api_app.dispatch(...)` while that
write transaction was still open.  Under NullPool (production, one connection per
session) the dispatched `signal_download_download` opens its OWN connection and —
inheriting the thread-local `_immediate_txn` flag — also issues BEGIN IMMEDIATE,
which blocks on the write lock the outer transaction still holds until busy_timeout
(30s) expires: `(sqlite3.OperationalError) database is locked [SQL: BEGIN IMMEDIATE]`.
Every dispatch cycle died on the first download, so nothing downloaded at all.

Fix: claim the downloads inside the transaction, collect (id, url), and dispatch the
signals AFTER the `with` block commits and releases the write lock.

Diagnosed live on 10.0.0.9 (downloads had silently stopped); with this change the
14 stuck recurring downloads drained immediately and no lock errors recurred.

Why tests missed it: the `test_session` fixture returns a single shared
(engine, session) for every `_get_db_session` call, so all sessions in a test ride
one connection -- the second-connection write-lock contention that causes the
deadlock cannot occur.  Added a regression test asserting the invariant instead: the
download signal must be dispatched with no immediate transaction active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:32:57 -06:00
Roland
86c49320dc
downloader: add diagnostic debug logging to the dispatch path
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Investigating downloads stalling on 10.0.0.9 (recurring downloads going overdue
without dispatching). The dispatch path had blind spots that hid the cause, so add
targeted DEBUG logging, change-detected where the cadence would otherwise flood:

- blocked_download_reason(): single source of truth for can_download that returns
  *why* downloading is blocked. dispatch_downloads() logs the reason (and the
  unblock transition) only when it changes, instead of silently returning every 5s.
- is_within_download_window(): logs the OPEN/CLOSED transition with the computed
  local time, window, and timezone — surfaces any clock/timezone mismatch (the box
  shows system UTC-5 vs configured America/Denver) at the moment of evaluation.
- _dispatch_new_downloads(): per-cycle snapshot (new count, processing_domains,
  daily global_count, download_wait) plus a bounded tally of why due downloads were
  NOT dispatched. Prime suspect is a leaked processing_domains slot (a dispatch that
  dies without cleanup blocks that domain, and once 4 leak, everything) — that path
  previously had no logging.

Behavior-preserving: can_download semantics are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:07:18 -06:00
Roland
327071e878
check_database_integrity.sh: address greptile review
- Preserve MEDIA_DIRECTORY/WROLPI_DB across the sudo re-exec (sudo
  strips them from the environment, discarding the override).
- Reject unknown options and a second positional path with a usage
  error instead of silently checking the wrong database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:47:48 -06:00
Roland
72af014828 scripts: set exec bit on delete_similarly_named_videos.py
It has a python3 shebang but was missing the executable bit; all other
scripts in ./scripts are executable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:47:35 -06:00
Roland
420e4957c9 help.sh: move DB integrity scan out of startup diagnostics
PRAGMA quick_check reads every page of the database, so on a large DB or
a slow (e.g. USB spinning) disk it can run for many minutes, making
help.sh (run by the wrolpi-help-script service) appear to hang.

Drop the quick_check from help.sh's fast startup diagnostics and add a
standalone scripts/check_database_integrity.sh for on-demand checks.
The new script runs sqlite3 as the wrolpi user (WAL -shm access),
reports elapsed time, and supports --full for an exhaustive
integrity_check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:47:35 -06:00
Roland
e51e5fb2fb controller: group services into Core vs Optional & Maintenance
The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.

Services are now split into two groups computed once in the backend and
exposed as `service.group`:

* Core Services      - persistent services enabled at boot (api, app, caddy,
                       kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
                       upgrade, *-dev, discovered wrolpi-*), plus persistent
                       services the user has disabled at boot (e.g. Samba with
                       no shares configured).

Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.

Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:46:13 -06:00
Roland
331e1eceda
Bump version to 0.28.5-beta 2026-07-22 19:22:53 -06:00
Roland
f3a9a9f67c
install: run install in a neutral C.UTF-8 locale
Fresh installs (and SSH sessions that forward LC_* variables from the client)
can reference an ungenerated locale, causing apt/perl/python to emit
"Cannot set LC_CTYPE to default locale" warnings throughout the install log.

Export LC_ALL=C.UTF-8 for the install run. C.UTF-8 is built into glibc, always
present, and locale-neutral, so it silences the warnings without generating a
locale, without adding a dependency, and without changing the user's configured
locale (e.g. an en_GB.UTF-8 system stays en_GB.UTF-8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:55:06 -06:00
Roland
ec2b584429 install: create Controller venv during fresh install
The Controller venv (/opt/wrolpi/controller/venv) was only ever created by
upgrade.sh and reset_virtual_environments.sh, neither of which runs during a
fresh install. install_raspberrypios.sh and install_debian_12.sh created only
the main API venv, so on a clean machine wrolpi-controller crash-looped with
203/EXEC ("Unable to locate executable /opt/wrolpi/controller/venv/bin/python")
because repair.sh unconditionally starts the service.

Create the Controller venv inline right after the main venv, mirroring how the
main venv is already built in these scripts. Verified on a fresh Raspberry Pi OS
Trixie install (10.0.0.8): wrolpi-controller comes up active with 0 restarts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:55:00 -06:00
Roland
5bbbef3f0b db: address review — confirm WAL was applied; refuse fuseblk mounts
Two fixes from PR review:

- `PRAGMA journal_mode=WAL` returns the mode SQLite actually selected and can leave the
  prior rollback journal without raising.  Confirm the returned mode instead of assuming
  the request succeeded, so a silent WAL refusal correctly falls back to TRUNCATE +
  synchronous=FULL rather than caching WAL with the weaker synchronous=NORMAL.

- `fuseblk` (block-backed FUSE such as ntfs-3g) has unreliable byte-range locking that
  can corrupt the database under the workers' concurrent access.  Move it out of the
  WAL-incompatible allow-list into an UNSAFE_LOCKING_FS_TYPES refuse-list (renamed from
  NETWORK_FS_TYPES).  NTFS on a modern kernel mounts via the native `ntfs3` driver, which
  stays allowed; exFAT is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:54:30 -06:00
Roland
8988a45195 db: allow exFAT/FAT/NTFS media drives by degrading from WAL to a rollback journal
A media drive formatted for Windows compatibility (exFAT/FAT/NTFS) cannot support
SQLite WAL: it needs mmap'd shared-memory those filesystems don't provide, so
`PRAGMA journal_mode=WAL` raised `(sqlite3.OperationalError) disk I/O error` on
every connection, taking down the whole API (observed on 10.0.0.8; the tell was
`Media directory has the wrong permissions: 0o40777`, since these filesystems can't
store Unix permission bits).

The engine now tries WAL first and, on failure, falls back to a TRUNCATE rollback
journal with synchronous=FULL (crash-safe, since WROLPi is off-grid).  The decision
is memoized per-engine so WAL isn't re-attempted or re-logged on every fresh
NullPool connection.  The bootstrap environment check no longer refuses these
drives; it warns once that write-concurrency is reduced and recommends ext4.
Network filesystems remain refused: their locking is unsafe regardless of journal mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:54:30 -06:00
Roland
6411ccc893
Bump version to 0.28.4-beta
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
2026-07-22 12:32:05 -06:00
Roland
7a343be36a
downloader: fix "database is locked" on read-then-write dispatch
The download dispatcher (_dispatch_new_downloads) reads the `new` downloads
then claims each by writing last_download_attempt, all in one ORM
transaction. Since ORM transactions begin *deferred*, that write is a lock
upgrade (SHARED->RESERVED). SQLite skips busy_timeout for lock upgrades to
avoid deadlock, so under concurrency the write failed instantly with
"database is locked" instead of waiting out the 30s busy_timeout. This
surfaced repeatedly on the SQLite build after the Postgres->SQLite cutover.

Add get_immediate_db_session(), a committing session that takes the write
lock up front (BEGIN IMMEDIATE) so busy_timeout absorbs the contention --
mirroring what the raw-cursor get_db_curs path already does. Plain
get_db_session stays deferred so ordinary writes and reads keep WAL
read-concurrency; only read-then-write call sites opt in. Convert the
dispatcher to use it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:26:13 -06:00
Roland
8cc8bee373
Bump version to 0.28.3-beta
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2026-07-20 19:15:28 -06:00
Roland
aeebe8d833
zim: address PR review — config race, YAML null, path traversal, pinned build
Triage of Greptile findings on the default-Zim / configurable-directory PR:

- Stale config on restart (P1): settings update() only queues an async
  background_save, and the restart switch has no ordering guarantee (the switch
  worker pops LIFO), so start_kiwix_serve.sh could read the old zims_destination.
  Save the config synchronously before activating the restart switch.
- YAML null (P1): read_config_value.sh now treats an unquoted null/~ as unset
  (returns the default), matching PyYAML/the Python config layer.  A quoted
  "null" remains the literal string.
- Path traversal (P1/security): the settings API now rejects a zims_destination
  containing "..", and start_kiwix_serve.sh defends in depth by falling back to
  `zims` for any absolute/traversal/empty value, so Kiwix can never scan or
  rewrite library.xml outside the media root.
- Reproducible build (P2): pin the zim-tools image to 3.7.0 instead of :latest.

Tests added for null handling, traversal rejection (API + script).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:10:09 -06:00
Roland
76aafc5da9 repair: copy wrolpi/blobs into /opt/wrolpi-blobs on install and upgrade
The image builds (pi-gen, debian-live, Docker) already ship wrolpi/blobs, but a
fresh install on existing Debian and an in-place upgrade did not pick up the
default Zim.  repair.sh runs on both paths (both install scripts and upgrade.sh
call it) and already manages /opt/wrolpi-blobs immutability, so copy the
committed blobs there -- after the immutable flag is cleared and before it is
re-applied.  Local copy only, preserving repair.sh's no-internet guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:09:57 -06:00
Roland
19f4958fb5 zim: ship a default fallback Zim and honor the configured Zim directory
Two related gaps in how WROLPi serves Kiwix content:

1. A fresh WROLPi (or one whose zims are all corrupt/absent) showed a blank
   Kiwix.  Every image now ships a small built-in default Zim that Kiwix falls
   back to, telling the user to download real Zim files.

2. The startup scripts hardcoded the `/zims` directory, ignoring wrolpi.yaml's
   `zims_destination` (which the Python side already honors).

Changes:
- New wrolpi/scripts/read_config_value.sh: a zero-dependency (awk) reader so
  non-Python scripts can reliably read top-level wrolpi.yaml scalars.  Also a
  config_value() wrapper in wrolpi/scripts/lib.sh.
- New wrolpi/scripts/default_zim/: HTML source + a reproducible builder
  (ghcr.io/openzim/zim-tools) that outputs the committed wrolpi/blobs/default.zim.
- New wrolpi/blobs/: any committed blob here is copied to /opt/wrolpi-blobs by
  the pi-gen and debian-live builds; the Docker zim image bakes it in.
- start_kiwix_serve.sh honors zims_destination and falls back to the default
  Zim when no functioning zim is found.
- docker/zim/entrypoint.sh gains the same default-Zim fallback.  It needs no
  config read: it already scans the whole mounted media dir, and
  zims_destination is always a subdirectory of that mount.
- Changing zims_destination via the settings API now restarts Kiwix so it
  serves the new directory without a reboot.

Tests: new test_read_config_value.py, extended test_start_kiwix_serve.py
(configured dir + default-Zim fallback), and a root_api test for the restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:09:57 -06:00
Roland
ddb8b4b590
help.sh: add Kiwix service, port 9085, and zim-corruption checks
The Kiwix section only checked library.xml and the Caddy HTTPS proxy on 8085,
which made a common failure hard to diagnose: kiwix-serve down while the
wrolpi-kiwix unit still reads "running" (its ExecStart is a bash wrapper), and
corrupt/incomplete zims silently missing from the library.

- Check the wrolpi-kiwix systemd unit exists and the service is up (parity
  with the api/app/controller sections).
- Check kiwix-serve is actually listening on 9085 and responds over plain
  HTTP there, isolating a kiwix failure from a Caddy proxy failure on 8085.
- Compare *.zim files on disk to <book> entries loaded in library.xml; a
  shortfall flags corrupt or incompletely-downloaded zims.

Verified against a healthy Debian 13 RPi (10.0.0.9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:42:14 -06:00
Roland
d48b86a783
Bump version to 0.28.2-beta 2026-07-20 15:25:38 -06:00