Response-quality work, all aimed at giving the model enough context to
answer accurately:
- Every chat's system prompt now carries a live library map (today's
date, counts, and the exact names of channels, domains, zims, and
inventories, capped and cached 60s). Exact spellings are in context
before the first token, and the list_collections round-trip — minutes
on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
turns are trimmed first, the current question is never dropped, and
an over-budget turn gets a clear "start a new conversation" error
instead of a silently truncated prompt. cache_prompt reuses the KV
cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
default (the 4B was running at 8k instead of 16k), and llama-server
runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
mechanical checks (finished, called the right tool first, included a
WROLPi link, no external URLs) so prompt changes stop being validated
by anecdote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the FX-8320 the first token took over a minute, and Sanic's default
60-second RESPONSE_TIMEOUT killed the stream before anything was sent.
Raise the timeout above the chat loop's own 600s hard ceiling, and emit
a status event before every model call so the stream is never silent —
for proxies and for the user staring at a slow machine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portable (GGML_NATIVE=OFF) x86-64 build still assumes AVX2, which
SIGILLs on older CPUs — found deploying to an AMD FX-8320. x86_64 now
builds all CPU-variant backends with runtime dispatch (GGML_BACKEND_DL
+ GGML_CPU_ALL_VARIANTS), the same scheme as llama.cpp's official
release binaries; arm64 keeps the baseline build, which runs on both
Pi 4 and Pi 5. The binary and its dlopen'd backends install together
in /opt/llama-cpp (the loader scans the executable's directory) with
$ORIGIN rpath, symlinked into /usr/local/bin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live testing surfaced three ways small models went wrong summarizing a
library: they gave up after one empty search (misspelled channel names
found nothing), so zero-result searches now carry a retry hint and the
Research prompt requires list_collections before channel/site questions
and search_videos(channel_id) before claiming a channel is summarized;
they presented the familiar YouTube source URL instead of the WROLPi
link, so listings omit the source URL (archive detail keeps it); and
they invented links for channels, so channel collections now carry
their real /videos/channel page link. Search descriptions state that
search_str is optional for browsing the newest items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three findings from running Qwen3-1.7B against the docker stack:
the llm image lost libgomp when the build tools were removed
(llama-server needs the OpenMP runtime); the Help prompt let the
small model answer from memory instead of the docs, so it now
requires search_help before any answer; and help search results
called the slug "doc" while get_help_doc's argument is "slug", so
the model invented slugs — the names now match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
publish_ai_models.py produces the CDN artifacts for every AI_MODELS
entry — GPG-signed .meta4 sidecars (reusing the map trust chain) and
the signed ai/models.json manifest — and uploads them with s3cmd.
The bundled catalog now carries the real sha256/size of the two Qwen
GGUFs, which match Hugging Face's LFS records for the Apache 2.0
unsloth quants, and whose signed meta4s verify against the committed
public key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every MCP tool is now one /api/ai call with minimal text rendering;
the formatting, link building, and truncation logic it used to carry
untested lives (tested) in modules/ai. Tool names are unchanged for
external clients; a source-scan test guards the names and that the
proxy only reaches /api/ai. New lean blueprint endpoints back the two
tools that had no equivalent: GET archives/{id} (detail + snapshot
history) and GET videos/{id}/comments (paged author lines). Paged
content is read up to the old 50k-char bound. get_inventory_items now
takes the inventory slug (inventories are config-only; the old
numeric-id endpoint no longer exists). This also brings wrolpi_mcp
into version control (venv/ is already gitignored).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app's first streaming client: sse.js reads the /api/chat POST body
with fetch + a stream reader (apiCall's 60s JSON path cannot stream),
parsing SSE events with abort support. AiChatContext holds the
conversation (browser-only, per design) so it survives visiting the
Manage tab; switching modes starts a fresh conversation. The Chat tab
has the three-button mode picker, per-mode suggestion chips, streamed
answers with clickable relative WROLPi links, tool-activity indicators,
a slow-hardware banner, and a "not ready" state pointing at Manage.
ChatMessage states are in the theme-sample gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New AI nav item and /ai route with Chat and Manage tabs (Chat is a
placeholder until the SSE chat lands). The Manage tab mirrors the
Map/Zim download UX: the model catalog with the RAM-based tier
recommendation and a Pi 4 performance hint, downloads through the
download manager (progress on the Downloads page), active-model
select, enable toggle, idle-unload minutes, and llama-server
start/stop through the Controller (compose service name in docker,
systemd unit natively). AiModelRow is in the theme-sample gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift. Manage endpoints
never become tools. A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.
POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout. Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
install_llama_cpp.sh builds llama-server from the SHA-pinned v0.3.0
source tarball (single source of truth: on-device upgrade, Pi image,
Live ISO, and the new docker/llm container all call it; version-marker
makes re-runs no-ops). start_llama_server.sh refuses to start unless
ai.yaml enables AI and the active model file exists — which also covers
unmounted media. wrolpi-llm.service is installed but deliberately
never enabled at boot: the Controller starts it on demand (registered
in managed_services; the llm compose service follows the samba
pattern), and the API's perpetual idle-unload worker asks the
Controller to stop it after the configured idle minutes. llama-server
is localhost-only natively, internal-network-only in docker, and never
proxied by Caddy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ai.yaml is the source of truth (opt-in: enabled=False by default) with
active model, idle-unload minutes, context override, and tool-call
bound. The model catalog follows the map pattern: a bundled constant
keeps the Manage tab working offline, the CDN manifest (ai/models.json
+ detached GPG signature) wins when reachable. GGUF downloads clone
the map-extract flow — the per-file meta4 is GPG-verified once and the
same bytes handed to aria2c, so the verified hash is the enforced one;
unverified downloads are refused. Models land in <media>/ai/models/
(gated by the existing media_mounted download rules). Manage endpoints
(catalog with RAM-tier recommendation, settings) live under
/api/ai/manage/ which the future agent loop excludes from its tool
catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search the wrolpi-help markdown in-process: files are parsed on demand
and cached by mtime; title matches outrank headings outrank body. No
database table — the corpus is dozens of small files. Pages are read
as paged markdown. The docker api container gets a read-only bind
mount of the help docs at the native /opt/wrolpi-help location; a
missing help directory degrades to empty results with a message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proxy the Controller under /api/ai so the model sees one API: an
aggregate /status to start every diagnosis, lean service list, recent
service logs (lines clamped), and disks + SMART. One controller_client
helper resolves the Controller base URL (docker service name vs
localhost). Every proxy degrades to an `errors` entry when the
Controller is unreachable — never a 500.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lean, flat, LLM-shaped read-only endpoints over the library: global,
video, archive, and doc search; paged captions, archive text, and zim
entries; collections, inventories, and a media-directory text reader.
Links are server-computed; big content is paged, never truncated.
This ports the formatting layer from wrolpi_mcp (untouched for now)
into tested code. First phase of ai.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0🅰️0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test_search_videos_does_not_block_event_loop asserted a wall-clock
threshold (echo < 0.4s), which fails on loaded CI machines from
scheduler noise alone. Replace the timing with ordering: the mocked
search parks its thread on an Event, the echo is sent only once the
handler is provably inside the query, and the test asserts the echo
completes while the query is still held open. Events are only waited
on off the event loop, so a regression fails the ordering assertion
instead of deadlocking.
Verified both directions: passes repeatedly against the real endpoint,
and fails when the endpoint is changed to run the search on the loop.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* files: left-align the preview toolbar, Open joins the button row
The modal actions row packs its children to the right, which suits a
confirming button but stranded the preview's toolbar. The toolbar now
fills the row so its buttons read from the left, with Open first in
the same row as Download. On phones the tags selector always takes its
own line instead of squeezing in beside the buttons.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* tags: the add-tag button is violet like every other tag button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* share: a real button everywhere but the navbar corner
The share trigger was a bare anchor-wrapped glyph, which matched the
navbar's other corner icons but looked out of place beside real buttons
in the file preview toolbar. It is an IconButton by default now; the
navbar keeps the bare glyph via asIcon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* files: readable text previews in dark themes
A same-origin text document inherits the page's color-scheme, so dark
themes rendered the browser's default light text on the preview's
forced white background. Force a light color-scheme on the preview
iframe so text is always black on white.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* files: text previews follow the theme
Instead of pinning the preview iframe to a light scheme, follow the
theme: dark, night, and amber render the document light-on-dark against
the theme's panel color; light stays black-on-white.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Uploaded singlefiles are base64-encoded into JSON before being sent to the
archive container, so an 88MB upload becomes a ~118MB request. Sanic's default
REQUEST_MAX_SIZE of 100MB rejected it with a 413 before the handler ran, and the
API surfaced this as KeyError: 'singlefile'.
Only the Docker deployment is affected; Debian/RPi extract in-process.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Completed once-downloads are never written to the downloads config, and
importing the config (which runs on every startup) deleted any DB Download
the config did not list. That erased the last_download_attempt rows the
per-domain and global daily limits count from, so a restart reset the count.
Keep completed once-downloads on import; pending/failed and recurring
downloads still follow the config as the source of truth.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page
Three response-time fixes found by benchmarking the read-only API endpoints
hot and cold on real libraries:
* `switch_worker` runs on the Sanic worker's event loop every 0.1s and called
the blocking `multiprocessing.Event.wait(timeout=1)`. With no switch pending
(the normal state) this froze the loop for a second at a time; about a third
of all requests, including trivial ones, took ~1,000 ms. It now polls
`is_set()` and returns immediately.
* The search handlers (videos, files, archives, docs, zims) and `/api/statistics`
ran synchronous SQLite queries on the event loop, so one slow search stalled
every other request on that worker. They now run via `asyncio.to_thread`.
* The unfiltered Videos page (`POST /api/videos/search` with only an order)
used `mimetype LIKE 'video/%' OR ...` plus `COUNT(*) OVER()`, which visits
every video's index entry before LIMIT applies: seconds cold on a large
library on a spinning disk, and ~10 s hot for a deep page. It now seeks each
exact mimetype on the (mimetype, effective_datetime) index for `limit + offset`
rows and merges them, with the total from a separate count cached for 60 s.
Results are identical to the generic query.
Also adds `scripts/api_perf.py` (times every read-only endpoint against a host,
optionally restarting the API over SSH for cold numbers) and
`scripts/api_perf_compare.py` (merges per-host results). Host profiles live in a
gitignored JSON file; an example is included.
Measured on an 8-core docker host with a 10 GB library: median hot request
1,007 ms -> 22 ms; Videos page 1 hot 287 -> 108 ms, page 50 1,345 -> 120 ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Serve the video description from its own endpoint instead of Video.__json__
`Video.__json__` computed `description` as `c_text or get_video_description()`,
where the fallback opens and parses the video's info json from disk. The list
endpoints serialize every result with `__json__`, so any page containing videos
without an indexed description (common: many videos have none) read their info
json files on every request, cold, for a field the list never displays. The
description is not part of search either: search runs against the FTS copy of
`c_text`, so the fallback never fed the index.
Drop `description` from `__json__` and serve it from
`GET /api/videos/<id>/description`, like comments and captions. The endpoint
returns the indexed `c_text`, falling back to the info json only there, for a
row indexed before descriptions were stored. The player page fetches it through
`useVideoExtras` alongside the comments.
Tests assert the list and detail responses never touch the info json, and cover
the endpoint's indexed, fallback, and not-found cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address review: thread-local zim sessions, fast-path equivalence test, comments
* Zim search, single-zim search, and search estimates run libzim off the event
loop, each opening its own session inside the worker thread rather than
passing the request's SQLAlchemy Session across threads.
* The Videos fast-path test now runs the same requests through the generic
query and asserts identical rows and paging for both date orders. The one
intentional difference is documented: past the end, the fast path still
reports the true total.
* switch_worker returns before its try block so the empty path does not clear
the event; its comment states the invariant only. The copied "synchronous
SQLite" comments are reduced to one line in one place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* videos: a collaboration video's channel directory conflict killed the download
YouTube collaboration videos appear in every collaborator's feed but their
metadata names only the primary uploader's channel. When that channel's
computed directory already belonged to an existing Channel that no longer
matched by source_id/url/name, channel creation raised
ChannelDirectoryConflict and the download failed unrecoverably. Now the
channel that owns the directory is used instead of creating a conflicting one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: trim comments that restated the code
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
NAME_PARSER's suffix whitelist had no audio suffixes (and no mkv/m4v),
so a correctly-named audio file got no published date and sorted to the
very end of its Channel -- which reads as missing. The whitelist now
carries the media suffixes a Video FileGroup can lead with.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* videos: an uploaded audio file never appeared in its Channel
The upload flow asks Video.can_model, which only accepted video/*, so an
uploaded .flac was indexed with no Video row -- and the batch modeler's
`indexed != True` gate then excluded it on every later refresh, leaving
the file permanently invisible in its Channel.
can_model now accepts audio/* like the batch modeler always has, and the
modeler gates on the Video row being missing (mirroring the doc_modeler
fix for the same class of bug) so already-stuck files heal on the next
refresh. Audio playlists (.m3u detects as audio/x-mpegurl) are excluded
from both paths and unmodeled by video_cleanup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: widen the playlist denylist, prove modeler termination
The denylist gains audio/x-scpls and audio/scpls -- .pls detection
varies by libmagic version, and any audio/* playlist type must be
refused wherever it comes back. Modeling one file is extracted as
_model_video so a test can force persistent failure and prove the loop
terminates; failed_ids is recorded before the PYTEST re-raise so no
failure path can re-select an id forever. Playlist mimetypes are bound
through json_each like the rest of video_cleanup, and the comments
state the invariants instead of the history.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: the playlist test must not depend on libmagic's m3u opinion
CI's libmagic detects .m3u as text/plain where a Mac's says
audio/x-mpegurl. The invariant is that a playlist mimetype is never
modeled by either path, so the test forces the mimetype like the other
denylist tests instead of asserting what detection returned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* common: config writes must be atomic and version-checked under the save lock
A config file must never be observable in a truncated state: writes go to a
temporary file in the same directory and are renamed into place. The version
check in ConfigFile.save() reads the config file, so it must hold
config_save_lock like the write does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* collections: item writes must begin as SQLite writers
The add/remove/reorder item endpoints read before their first write; a
deferred transaction upgrading at that write fails instantly with "database
is locked" when any other connection wrote in between, bypassing
busy_timeout. Open their sessions with get_db_session(commit=True) so the
transaction takes the write lock at BEGIN, matching post_download.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: error responses must not commit the write session; trim comments
Map exceptions to 4xx outside the get_db_session(commit=True) block so an
error response never commits, and serialize inside the session. Close the
mkstemp descriptor when fdopen never took ownership. Trim comments and test
docstrings to what the code and assertions actually cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* app: the navbar corner wrapped onto a second row on small phones
Every status-icon slot carried a 1.5em separator margin -- ~80px across
three slots -- which wrapped the mobile bar's whole corner onto a second
row on any screen narrower than about 380px. The margin is now 0.25em on
both bars: the corner's gap and the slots' own padding still space the
icons, and on desktop the freed room just goes back to the links that
useOverflowNav folds into "More".
The mobile bar is extracted as MobileNav (mirroring DesktopNav) so a
Cypress suite can mount it and hold it to one row at phone widths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: prove 320px with a faithful fixture, trim the CSS narration
The suite claimed 360px covered narrower devices because the fixture was
wider than production, but the fixture also lacked the real theme
toggle's 0.8em right margin, so the safety margin was overstated. The
fixture now matches a phone -- 32px logo home link, the toggle's margin,
and a coarse pointer so the search link drops its keyboard hint, with a
test asserting the hint is really absent -- and 320px is measured
directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The channel downloader refreshes a Channel's files before downloading,
already passing send_events=False, but the parameter was never plumbed
into the file worker, so every pre-download refresh notified the user.
FileTask now carries send_events and handle_refresh skips the
Refreshing/Refreshed events when it is False. User-requested refreshes
and global refreshes still send Events.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* e2e: exercise the FileBrowser footer buttons against real files
Sixteen Cypress tests covering Rename, Move, Delete (including the
tagged-file force-delete flow), Ignore/Unignore, Upload, and scoped
Refresh, with no API mocking: fb:* tasks seed a cypress-fb/ sandbox
inside the media directory, the UI mutates it through the real API, and
assertions check the disk as well as the table. Skipped in CI, which
runs only the React dev server.
Background jobs (move, refresh) only queue work and worker_status reads
idle while jobs wait, so cy.refreshFiles / cy.waitForEvent poll the
events feed for completion events newer than the server's clock, and
fail loudly when one never arrives. Every suite cleans up
unconditionally: sandbox files, DB rows via a final refresh, test tags
and their tags/ directories, and any leaked ignored-directory config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* e2e: act on review — drain stragglers, teardown that cannot be skipped
Events carry no job id, so waitForEvent cannot prove WHICH job finished.
The rename tests now drain the parent-directory refresh their rename
queues before ending, so the next test's refresh wait cannot be
satisfied by a straggler while the DB still reflects the old tree; move
waits match the destination in the completion message.
Teardown must always reach the disk wipe: a failed command aborts the
rest of its hook, so the tag cleanup and the DB-purge refresh are now
best-effort (failOnStatusCode: false, no assertions), and the unignore
runs after every test instead of once per spec. History and
forward-looking product notes trimmed from comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
perpetual_shutdown is set by the real before_server_stop listener, so any
earlier async_client teardown in the same pytest-xdist worker leaves it
True on the module-global app and the watchdog returns before reclaiming.
Failed under -nauto, passed alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Starting the file-worker pump in every Sanic worker made 5 processes
race the shared status dict, the busy flag, and the same directories.
Non-owners now run only a reclaim watchdog: they refresh nothing unless
the owner PID is gone or its heartbeat is stale.
Production wait_for_job polls every 0.1s instead of 100 Hz. A failed
rename includes the recorded job error in the 409 body.
A directory rename waits inline on a move job. When that move failed
(stale FileGroup rows colliding on primary_path), handle_move reverted
the files and sent a failure event but left the job pending. wait_for_job
only understands 'complete', so POST /api/files/rename spun until its
300s timeout and the Rename modal never closed.
The same sequence could kill file processing for the life of the process.
perpetual_signal treated any CancelledError as terminal and logged
nothing. Separately, perpetual_tasks_started is a shared Event that
survives the worker that set it: docker auto-reloads and worker crashes
left every later after_server_start as a no-op. Queued refreshes sat
untouched while worker_status reported idle. Restarting the API was the
only recovery.
Mark failed jobs failed and raise immediately from wait_for_job. Catch
unexpected cancellation and reschedule unless the app is actually
stopping. Track the perpetual-loop owner PID and retake the claim when
that process is gone; every Sanic worker still runs the file-worker
pump. Harden transfer_queue against the cross-process Empty race, and
stop request processes stealing from the shared public queue.
* upgrade: restart services when the upgrade fails before code changes
The upgrade script stops wrolpi-api and wrolpi-app before fetching, so a
failed fetch, signature verification, or checkout left the WROLPi down
with unchanged code on disk. The EXIT trap now restarts both services
for failures that occur before scripts/upgrade.sh begins modifying the
system. The post-checkout verification failure rolls the branch back to
the previously-running commit first, so services never restart on an
unverified commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* upgrade: only restart services when rollback lands on the known-good commit
Review fixes: a failed rollback no longer leaves the restart flag set, so
services cannot start on an unverified or mixed tree; the non-atomic
checkout failure path now rolls back too; trimmed narrative comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* downloads: a batch of failed downloads that retried in lockstep
`calculate_next_download` gives every deferred download the same 3^attempts
backoff, measured from the moment it failed. Downloads that fail together
therefore retry together, and a synchronized burst against one host is what
gets a whole batch rejected -- which defers them all again, on the same
schedule. The backoff grows, but the batch never breaks up.
Jitter each retry forward by up to half its backoff. The backoff stays a
floor, so nothing is retried early; a recurring download is still capped at
its own frequency, so nothing is retried later than it was before.
Deferred scheduling is no longer an exact value, so the tests that pinned it
assert a floor and a ceiling instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* review: comments that narrated the design instead of the code
Trim the jitter comment to a single WHY and the test docstring to its summary
line. The reasoning that led here belongs in the PR, not beside the
arithmetic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* downloads: a deferred once-download that nothing ever retried
Deferring a download only schedules it: `defer()` sets the status and
`calculate_next_download` sets `next_download`. Acting on that schedule was
`renew_recurring_downloads`, which filters on `frequency != None` -- so only
recurring downloads were ever marked "new" again.
A once-download has no frequency. One transient error (an HTTP 403 from the
host, a dropped connection) deferred it with a `next_download` that nothing
read, and it stayed deferred until someone retried it by hand.
`delete_old_once_downloads` did not clear it either -- that requires a
`last_successful_download`, which a download that never succeeded does not
have. So every transient failure added one more permanently-stuck row to the
downloads list.
Add `renew_deferred_once_downloads`, called from `do_downloads` alongside the
recurring renewal. `attempts` is preserved, so `calculate_next_download`'s
3^attempts backoff keeps growing across retries: a URL that never succeeds
backs off toward never instead of retrying at a fixed interval.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* review: comments that narrated the bug instead of the contract
The new docstrings restated why the bug existed and how the fix was reasoned
about. That history belongs in the PR, not in code that has to stay true.
Keep the invariant that a reader needs: attempts are not reset, so the backoff
grows. In the test, the assertions already document the contract.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ui: a download form that emptied itself after every submit
A successful submit reset the whole form, so adding a second download meant
re-choosing the tags, the destination and the video settings that the first
one had already been given.
It now empties only the URLs. Everything else stays for the next download,
and a Clear button beside Download puts the form back to its defaults when
the next one is unrelated.
`useForm` gains `clearPathsOnSuccess`; `clearOnSuccess` is untouched for the
forms that want the whole reset.
* review: the one download form that kept its URLs
Scrape took a `clearOnSuccess` prop that no caller passed, so mapping it to
the URL paths left create-mode scrape clearing nothing. It follows the same
rule as Videos and Archive now: the edit form supplies `onSuccess`, and only
that keeps the form as it was.
`clearPaths` also wrote every path it was given, so a `urls` form gained a
`url: ''` it never declared -- which the comment beside it and the test named
for it both said it would not do. It skips a path neither the defaults nor
the form has, and the test asserts the key is absent.
* ui: two upload dialogs that had drifted apart
The dashboard and the file browser each had their own upload component: two
dropzones, two overwrite toggles, two copies of the progress-bar rendering,
against the same `useUploadFile` state. The file browser's dialog was made
fullscreen with a drop target that grows; the dashboard's stayed a `large`
dialog with a strip to drop onto, because it was a different file.
They differ in one thing that is real: the file browser knows the destination
because the user selected it, and the dashboard has to ask. That is now the
`destination` prop -- `undefined` asks, a string uploads there, and '' is the
media directory rather than a missing destination.
Everything else is one component, so the next change to either entry point
lands on both.
* review: an upload dialog that looped on the media directory
Pressing Upload in the file browser with nothing selected passes `''` -- the
media directory. Three checks along that path read it as "no destination":
* The clearing effect ran, and `doClear` is a new function every render whose
own setState scheduled the next one. React ended it with "Maximum update
depth exceeded", 16 warnings deep, on the default Upload path. The effect
now clears only while asking, and `doClear` is stable.
* The title read `Upload to: /media/wrolpi/`.
* `doUpload` bailed, so a drop on the media root uploaded nothing.
All three predate this branch; moving the dialog is what put them behind one
component. Tests cover the title, what the hook is told, and that a fixed
destination is never the thing that empties the queue.
The upload dialog was 682x367 on a 1440x900 desktop -- 47% of the width, 41% of the
height -- and the drop target inside it was a strip about 90px tall. A small thing to
drag a file onto, with the file browser it came from covering most of the screen behind.
Upload and Move ask for `fullScreen`, which is edge to edge on a phone and 90% above the
tablet breakpoint: filling a desktop reads as the app having been replaced rather than as
something opened over it. Move holds a DirectorySearch above a row per path being moved,
which grows with the selection; 620px showed a handful and scrolled the rest.
Two things had to follow. Mantine sizes a fullscreen dialog's content to the viewport but
leaves the body at its content height, so the footer landed two thirds up the screen with
a quarter of the viewport empty beneath it -- the body takes the leftover now and the
actions ride to the bottom of it. And the drop target itself grows into the room, with a
floor so it stays large once progress bars claim their own space.
Rename and the confirmations keep their sizes: a fullscreen dialog for one text field or
one sentence reads as broken. New Folder keeps `large` too -- it is a text field and a
path preview, the same shape as rename, not the directory picker its name suggests.
`size='fullscreen'` is a DIFFERENT thing -- full width, height from the content, which is
what a log or a preview wants. Both are documented where the sizes are declared, and the
gallery grows a fullscreen example so the difference is visible in all four themes.
339 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean.
* review: the 375px phone this fix had not been measured on
Answers the five findings on #660.
* Recurring's four fixed columns wanted 416px between them, so at 375px the URL
column got exactly 0 and the table overflowed again -- the same bug as before,
moved down from 540px. Widths are phone-first now, in col-* classes, widening
at the tablet breakpoint.
* The in-progress button in Completed At carried "5 MBps / 3m44s" -- 169-188px
measured, against 124px of column. It shows the percent; the modal it opens
already lists speed, ETA and bytes, and the full text is on the button's title.
* The select column was 2.5em: 16.5px of content for a 22px checkbox, which hung
into the URL column. 3em holds the checkbox and its padding.
* The Cypress fixtures were a raw <table>, so tableLayout would read 'fixed' even
with the selector that loses to Mantine -- the test could not fail for the reason
it claimed. They mount SortableTable, Table.HeaderCell onSort and a real
Checkbox now, and the header check measures the text rather than skipping cells
with no element child.
* Comments trimmed to what cannot be recovered from the code.
Fixing the first surfaced one more: once URL became the small column, its own header
overran -- "URL" and its arrow need 72px against 58 -- and a single word cannot wrap.
A header is clipped by its column as a last resort.
Measured at 375px: once 46/58/123/146, recurring 44/100/104/57/69, nothing
overflowing. 69 suites / 1214 jest tests pass.
The Cypress tests here are NOT verified: the component runner does not start in this
repo (see the separate cypress investigation), so every layout claim above rests on
direct measurement in Chrome, not on that suite.
* review: the 375px phone this fix had not been measured on
Answers the five findings on #660, and now runs against a component suite that can
actually execute (#661).
* Recurring's four fixed columns wanted 416px between them, so at 375px the URL got
exactly 0 and the table overflowed -- the same bug as before, moved down from 540px.
Widths are phone-first now, in col-* classes, widening at the tablet breakpoint.
* The in-progress button in Completed At carried "5 MBps / 3m44s", 169-188px against
124px of column. It shows the percent; the modal it opens already lists speed, ETA
and bytes, and the full text is on the button's title.
* The select column was 2.5em: 16.5px of content for a 22px checkbox, which hung into
the URL column. 3em holds the checkbox and its padding.
* The fixtures were a raw <table>, so tableLayout read 'fixed' even with the selector
that loses to Mantine -- the test could not fail for the reason it claimed. They
mount SortableTable, Table.HeaderCell onSort and a real Checkbox now.
* Comments trimmed to what cannot be recovered from the code.
Two defects the restored suite caught, neither of them findable before it ran:
* The elastic URL column ends up narrower than its own header -- "URL" and its arrow
need 72px against 58 -- and one word cannot wrap, so the column clips. A sized column
must fit its label; only the elastic one may clip. That is what the header test
asserts, per kind of column, rather than measuring text and calling it the label: the
first version measured the text alone, missed the sort arrow, and passed while the
button overran by 14px.
* Recurring's Control column was 4.5em, 70px of content for a 78px "Control". Sized
for the word rather than the button it holds: 5.2em.
At 375px: once 46/58/123/146, recurring 33/100/104/57/80, nothing overflowing and no
sized header clipped. The URL column there is 33px -- the accepted trade for keeping
every column on a phone.
336 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean.
* review: a header assertion that stopped at the padding box
Three findings on #662.
* The sized-header check compared scrollWidth to clientWidth, which is the padding box,
so a label could overflow its content box into the padding and pass -- the same class
of miss the 4.5em Control failure was meant to close. It measures the label's longest
word (plus the sort arrow, where there is one) against the content box now.
That found two more columns tuned to their exact limit and clipping by a pixel:
Download Frequency wanted 79.4px of a 78.1px box, Completed At 82.3 of 81.9. Every
sized column now has margin over its own label.
* SortableTable stopped applying `width`, leaving dead keys at the Tags and Zim call
sites. Removed, so a column is sized one way. CollectionTable's `width` is NOT dead
-- it converts sixteenths to percentages -- so Channels, Docs, Playlists and Archive
keep theirs.
* Comments trimmed again: the percent button, the header clip, the ButtonGroup rule and
the Cypress helper.
336 component tests pass, 69 suites / 1214 jest tests pass.
* ui: a phone header short enough to leave room for the URL
Five full-length headers do not fit a phone. Sized so each held its own label, the
recurring URL column came out 20px -- narrower than one character and its ellipsis, a
column present but carrying nothing.
A long header carries both forms and `.label-*` shows one: Freq and Done below the tablet
breakpoint, Download Frequency and Completed At above it, where there is room. The mobile
widths are sized for the short word.
At 375px the URL column goes from 20px to 105px on the recurring table and 54px to 97px on
the once table. Nothing overflows, nothing clips, and every column stays.
336 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean.