* Distrust compromised eMudhra intermediates in certifi and Deno
The private keys of eight eMudhra intermediate CAs can be recovered from
their certificates, so anyone can mint a trusted certificate for any host.
* Require certifi>=2026.10.19; yt-dlp, requests and curl-cffi use its bundle,
and 2026.10.19 removes the emSign ECC Root CA - G3 root they chain to.
* Set DENO_TLS_CA_STORE=system for the API (systemd + Docker) and the archive
container so Deno follows the apt-patched system store instead of roots
that only change with a Deno upgrade.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep Deno's bundled roots in the archive container
browserless/chrome:1.61.1 is Ubuntu 22.04 whose newest ca-certificates is
from 2023, older than Deno 2.8.3's bundled roots. The system store only
becomes the better choice once the archive base image is upgraded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perpetual: clear in-progress flags after a crash; downloads only in the perpetual process
Found by a kill -9 stress test on a device:
A SIGKILL inside `with flags.file_worker_counting` (or any of the file
worker's progress flags) left the shared flag set, so every later refresh
raised "flag is already set" until the whole API was restarted.
reconcile_after_predecessor now clears the perpetual process's in-progress
flags (file_worker_*, global_refresh_active) and reports it.
create_downloads started a dispatch in whatever process called it, so an
API worker executed downloads (and could take transcode_lock) despite the
single-process design. It now dispatches only inside the perpetual
process (or under pytest); the download loop picks new rows up within
seconds otherwise.
The shared restart cooldown drops from 60 s to 15 s: it only has to
outlast the manager acting on a request, and a replacement that dies
right away was left dead for the rest of the minute.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* systemd: KillMode=mixed for the API so main shuts its own children down
With control-group kill, SIGTERM reaches the multiprocessing Manager at
the same moment as Sanic's main process; main's cleanup then writes
worker state to a dead Manager (broken pipe) and exits 1, so a plain
`systemctl stop` is recorded as a failure whenever a child takes a
moment to stop (a transcode being killed, for instance). Signal only
main; it stops the workers and the perpetual process itself.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perpetual: clear an in-progress flag only when its holder is dead
`with flag:` now records the holder pid in shared_ctx.flag_holders and
drops it on exit. The reconcile step clears the flags only the perpetual
process ever enters (file_worker_counting, file_worker_discovery,
global_refresh_active) whenever they are set, and the flags API workers
also enter (file_worker_modeling / indexing / cleanup via refresh_sync,
map_search_building via the map rebuild endpoints) only when the
recorded holder is dead. file_worker_busy is no longer touched: a
collection move in an API worker holds it as a mutex, and
process_queue drops a stale one itself.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Speed Test calculator: ping, download, and upload against the API
Users asking whether the hotspot is fast enough, or how many people can
stream video at once, had no objective tool that works offline. This
adds one under Calculators > Tools & Reference.
Backend (wrolpi/speedtest.py, /api/speedtest):
* ping: tiny no-store JSON the browser times for round trip latency.
* download: streams slices of a pre-built random buffer for a clamped
number of seconds (0.1-15). Random so no proxy or compression can
flatter the result; slices so no bytes are generated per request.
* upload: streaming-body handler that reads and discards the body and
reports bytes and elapsed time. Requests over 8 MiB are refused.
* info: the client address Caddy forwarded plus how many tests are in
flight. Sanic now trusts one proxy hop (PROXIES_COUNT = 1) so
request.remote_addr is the browser, not Caddy. Nothing else read
that field before.
There is deliberately no one-test-at-a-time lock. Several people
testing at once is how a user learns how the link shares between
streamers, so the server only counts in-flight tests (a shared_ctx
Value created pre-fork so every worker sees the same number) and the
UI reports how many others ran alongside.
Both Caddyfiles route /api/speedtest/* unbuffered, as the SSE chat
route already is, so throughput is measured live rather than in bursts.
Frontend:
* speedtest.js holds the logic with no React in it (bucketed
throughput, steady-state average that drops the first second, ping
stats, stream-capacity tiers, hotspot address check) so Jest drives
it with fake fetch and streams.
* SpeedTestCalculator runs 20 pings, a 10 s download, and a 10 s upload
with a live figure and rolling trace, then shows Mbps and MB/s, ping
verdict, the client address with a hotspot badge, and a table of how
many 480p..4K streams the download speed supports. Upload is
sequential POSTs of one 4 MiB Blob because Safari cannot stream a
request body. Nothing is saved.
* Sparkline is a new inline-SVG ui component (no chart dependency),
added to /theme-sample and checked in all four themes. A missing
sample is a gap, not a plunge to zero.
The page states that it measures the network path, not the drive that
actually serves video files.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* speedtest: bound uploads in time, close the Start race, address review
Review on #708 found three bugs and some polish:
* Start could be clicked again while the first fetchInfo() was still
pending, because the phase only left `idle` after that await. A
second click made a second AbortController and a second concurrent
run, and Cancel only reached the later one. start() now refuses when
a controller already exists and enters a `starting` phase
synchronously, so the button is Cancel before the first await.
* The upload phase could overrun the advertised 10 s by however long
the in-flight 4 MiB POST took: on a slow hotspot, the very link this
tool exists for, that is tens of seconds. runUpload() now aborts the
in-flight request at the phase deadline (counting only completed
requests) and uses 1 MiB bodies so a few-Mbps link still completes
several inside the phase.
* The server upload sink was capped by size only, so a slow or paused
body could hold a worker until 8 MiB eventually arrived. It now also
enforces MAX_UPLOAD_SECONDS (15 s, matching download) and answers
408 when exceeded. Both caps together are what "cannot hold a
worker" actually requires.
Also: a NaN `seconds` now gets a NaN message instead of "must not be
negative"; the module, Sparkline, and gallery docstrings are trimmed
to purpose; and the upload counter is covered for the mid-stream 413,
the 408, and a broken body.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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>
A corrupt or partial *.zim (e.g. an interrupted download) is rejected by
kiwix-manage ("Cannot add zim ..."). start_kiwix_serve.sh could then leave
Kiwix down and reported a misleading "Could not find any Zim files" — and
if the bad zim was the only one, exit 2 killed the service with no restart.
- Import each zim independently; log and skip a failed add so the remaining
valid zims are still served.
- Always ensure a library.xml exists (empty if needed) and exec kiwix-serve
unconditionally, so a corrupt/absent zim never stops the server starting.
- Fix the mount-wait loop, which never incremented COUNT (infinite wait
instead of a 30s timeout).
- Add MEDIA_DIRECTORY override so the script is testable.
- Add Restart=on-failure to wrolpi-kiwix.service (matches wrolpi-api.service)
so transient failures self-heal.
- New test harness runs the script with fake kiwix-manage/kiwix-serve shims.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All three build targets move from Debian 12/bookworm to Debian 13/trixie
for fresh installs; existing Debian 12 systems remain supported in place.
Docker: api/help on python:3.13-trixie, controller on 3.13-slim-trixie,
app on node:20-trixie (matches trixie's nodejs 20), samba/zim on
debian:13. Full backend suite passes on Python 3.13 in the api container.
debian-live-config: --distribution trixie; postgres teardown for the
SQLite cutover (pg_dropcluster hook block removed, wrolpi-bootstrap.sh no
longer creates a cluster or calls the deleted initialize_api_db.sh — the
API creates wrolpi.db itself on first start). README documents the
SQLite layout and drops Etcher recommendations.
pi-gen: upstream has no trixie branch — the arm64 branch now defaults to
RELEASE=trixie; KEYBOARD_KEYMAP=en_US is rejected by trixie's
console-setup (ckbcomp validates), use 'us'; stop stamping a Postgres
.pgpass into /etc/skel.
Package lists (both targets): postgresql-15/postgresql-doc/libpq-dev
deleted; libtiff5-dev renamed to libtiff-dev; software-properties-common
and cpufrequtils removed from trixie (Controller CPU throttle falls back
to sysfs, dead cpufreq-set sudoers rule removed); locales and gnupg added
(no longer arrive transitively under --apt-recommends false); sqlite3 CLI
added for reset_tags.sh.
help.sh: replace the Postgres service/port/socket/cluster/psql checks
(all false FAILEDs after the SQLite cutover) with SQLite checks:
wrolpi.db exists, PRAGMA quick_check, file_group table + row count, run
as the wrolpi user so root never creates the -shm sidecar.
Both build scripts take a WROLPI_BUILD_DIR override so development builds
can run beside the release pipeline's build trees; the sudo re-exec
passes it through explicitly (a plain sudo would drop it).
test_common.py: the truncate tests hard-coded sys.getsizeof results from
the 3.11 object layout (str/list shrank in 3.12); assert the contract
(fits budget, prefix, monotonic in budget) so they pass on 3.11 and 3.13.
Verified: trixie ISO (3.7G) and pi-gen desktop image (4.3G) both built
from scratch and passed their sanity checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new wrolpi-help-script.service oneshot runs /opt/wrolpi/help.sh so the
Controller UI can run system diagnostics and show the output from the
journal. The confirmation modal hides the warnings block for scripts
with no warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two parallel live-build configs (debian-live-config/ for the installer
ISO and wrolpi-portable/ for the Live USB) carry the same chroot hook
with subtly different fixes — the portable side had accumulated the
better implementation (npm build, SHA-pinned blobs, dynamic Postgres
version, partition-by-start-sector, controller.yaml sentinel, autologin,
branding). This commit folds those improvements into debian-live-config/
and retires wrolpi-portable/.
The Postgres data + config directories now bind-mount to
/media/wrolpi/config/postgresql/{data,config} regardless of deployment
shape (Live USB, Calamares-installed system with external drive,
Calamares-installed system with no external drive). Result: pulling
a WROLPi external drive from one host and plugging into another
brings the entire indexed library along — no re-indexing. Previously
the DB lived on /mnt/persistence/postgresql-{data,config} on Portable
and on /var/lib/postgresql (root) everywhere else; moving a drive
required rebuilding the DB on the new host.
Single wrolpi-bootstrap.{sh,service} replaces wrolpi-portable-bootstrap.
The script detects deployment by probing for a live-boot medium mount:
present → Live (create persistence partition if missing); absent →
Installed (skip partition step; Postgres bind-mount and cluster init
still run). --debian-installer is dropped from lb config; Calamares
will land in a follow-up commit as the graphical installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A WROLPi drive can move between hosts and across deployment styles
(Portable, RPi, Debian). /etc/fstab is host-specific and on the Portable
image it's part of a read-only squashfs, so persisting secondary mounts
there was both wrong-host and lost-on-reboot. This change makes
/media/wrolpi/config/fstab.yaml the single source of truth for WROLPi-
managed mounts and introduces a new wrolpi-mounts.service that reconciles
live state to match.
Architecture:
- The Controller writes intent (fstab.yaml) but never calls mount(8) or
umount(8) itself. Every mutation triggers `systemctl restart
wrolpi-mounts.service`; the Controller then verifies the outcome via
the same MountExecutor abstraction the service uses.
- wrolpi-mounts.service runs a thin entry point that constructs a real
Reconciler with a SubprocessMountExecutor and calls apply(). The
reconciler mounts what's in fstab.yaml but missing from the live state,
and unmounts paths it previously claimed that are no longer in
fstab.yaml. A managed-set file in /run/wrolpi-mounts/managed prevents
the reconciler from ever touching user-mounted paths it didn't own.
- One-time migration (marker-gated) lifts both /etc/fstab WROLPi entries
and any controller.yaml drives.mounts entries into fstab.yaml.
/etc/fstab is backed up to /etc/fstab.wrolpi-migration.backup;
migrated lines are commented in place with a "migrated to fstab.yaml:"
prefix so a host admin can hand-recover.
Test approach: dependency injection in place of patching. The
MountExecutor protocol has SubprocessMountExecutor (production) and
FakeMountExecutor (in-memory, records calls). The Reconciler accepts an
executor in its constructor, so the 20 reconciler tests + 13 migration
tests + 18 fstab_yaml tests use real files in tmp_path with zero
mock.patch calls. test_disks_api.py drops to mocks only for the
host-touching read helpers; the mount/unmount paths now run the real
Reconciler against the fake executor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Create a new Docs module that consolidates PDF and eBook handling into a
unified system with subjects and authors support. This replaces the
separate PDF/eBook modelers with a single Doc model backed by an
alembic migration (rename ebook to doc).
Key changes:
- New modules/docs package with models, API, extractors, and lib
- Subject and author metadata extraction with case-insensitive
normalization for cleaner PDF metadata
- Frontend Docs pages with AuthorsPage, SubjectsPage using
CollectionTable, and doc card deletion
- Global search integration for subjects and authors
- Generic collection item count support
- Frontend file card metadata adjusted to match collection sort
- Bump yt-dlp version
- Replace nginx with Caddy as the web server for both Docker and native
(RPi/Debian) deployments. Caddy handles HTTPS termination, reverse
proxying to all services (API, React app, Controller, Kiwix, Help, Map),
and serves the media/download file browsers.
- Generate a Root CA and signed leaf certificates for HTTPS. The Root CA
uses Name Constraints to restrict signing to local addresses only
(.local, localhost, and RFC 1918 private IP ranges), mitigating MITM
risk if the CA key is compromised.
- Add an HTTP landing page that guides users through one-time CA
certificate installation on iOS, Android, macOS, Windows, Linux, and
Firefox, with auto-redirect when the cert is already trusted.
- Add trailing-slash redirects for /controller, /docs, /media, and
/download so bare paths don't fall through to the React catch-all.
- Add a Root CA download link on the Settings page under Configs.
- Update help.sh diagnostics for IPv6 listener detection and HTTP/2
status codes.
- Remove all nginx config files and the https_proxy Docker container.
- Bump wrolpi-help submodule with certificate documentation.
Replacing Channels and Domains with Collection.
Moving downloading relationships from Channel to Collection.
Created domains.yaml config to manage tagged domain collections.
Adding turn-key upgrade solution in the UI.
Storing video settings in session storage to help user start downloads consistently.
Applying channel tag name to new channels when download videos or channels.
Initializing map tiles when importing and resetting map.