Skip to content

Archive delete: truthful results, unlock & confirm-through; UI/serving performance - #30

Merged
RobXYZ merged 3 commits into
RobXYZ:mainfrom
Anonymouse6661:archive-delete-truthfulness-and-ui-perf
Aug 5, 2026
Merged

Archive delete: truthful results, unlock & confirm-through; UI/serving performance#30
RobXYZ merged 3 commits into
RobXYZ:mainfrom
Anonymouse6661:archive-delete-truthfulness-and-ui-perf

Conversation

@Anonymouse6661

Copy link
Copy Markdown
Contributor

Summary

Archive → Actions → Delete could appear to do nothing — and in one failure mode it lied about working. This PR makes the delete pipeline truthful end-to-end, adds the missing unlock affordance, and fixes the main UI/serving performance hazards found along the way. Everything was reproduced, fixed, and verified against a live Unraid deployment of v2.5.

The delete bug(s)

Investigating a real "delete does nothing" report surfaced three stacking causes:

  1. Protected clips were refused silently. Anything dashcam-locked (event_type='ro' — every clip the camera wrote to /Movie/RO/, i.e. all G-sensor/event footage) or user-locked was filtered out of the delete, and the UI toast dropped the protected count: deleting 12 RO clips read "Deleted 0, skipped 0" with no explanation. There was also no way back from Mark read-only — the lock route hardcoded True and no unlock route existed, so a mis-click was permanent short of editing the SQLite DB by hand.
  2. A failed file unlink still deleted the index row. With the classic NAS misconfiguration (container PUID/PGID lacking write+execute on the recordings share), os.remove raises PermissionError, the warning is swallowed, and _delete_index_row() runs anyway: the tile disappears, the toast says "Deleted N", no space is freed, and the clip re-appears on the next rescan. The same swallow sat in the retention sweep, where it was worse — bytes_freed was credited for the file's size (read via getsize before the failed unlink), so quota bookkeeping drifted while the disk-pressure loop kept "deleting".
  3. Day-card totals went stale — after a successful delete the open day re-rendered but the day header kept the old clip count and GB figure until a manual reload.

What changed

Backend correctness (web/services/retention.py, web/services/queue.py, web/routers/queue.py)

  • _delete_clip_files now returns (bytes_freed, ok), where ok reflects whether the primary .mp4 was actually removed; on failure it reports 0 bytes freed. delete_clip keeps the index row when ok is false.
  • delete_clips reports {deleted, skipped, protected, protected_names, failed}. Failed clips keep both their index and queue rows. protected_names lets the UI target exactly the refused clips on a follow-up.
  • Loop wedge guards: in the disk-pressure pass and make_room_for, clips whose unlink fails leave the candidate pool for that pass — previously the oldest-first query would re-select the same un-unlinkable clip forever.
  • New POST /api/queue/unlock (the reverse of /api/queue/lock), and an optional force flag on POST /api/queue/delete for an explicit, confirmed delete of protected clips.
  • Day-folder rmdir pruning is batched once per delete batch instead of one failing syscall per clip.

UI (web/static/app.js, web/static/index.html)

  • The delete toast now reports all four outcomes (Deleted N, M won't re-download, K read-only/locked kept, J failed — see Logs), and when protected clips were refused the UI asks once — "N of the selected clip(s) are read-only or locked… Delete them anyway?" — and force-deletes exactly those names on confirm.
  • Clear read-only joins Mark read-only in the Actions menu.
  • loadDays() runs after delete/skip so day-card totals stay correct.
  • The CSRF 403 retry now retries once instead of recursing unbounded.

Performance

  • Filmstrip hover-scrub dwell + abort (app.js): loading fired on raw mouseenter with no cancellation — sweeping the cursor across a 240-tile day queued one ffmpeg sprite job per tile crossed, saturating the browser's ~6 connections per origin and blocking every other API call behind sprite generation (the UI would go dead for tens of seconds and spin up array disks). Loads now require 350 ms of hover and abort via AbortController on mouseleave.
  • Event-loop discipline (web/routers/archive.py): _fetch_clip (a synchronous sqlite connect + os.path.isfile on the recordings mount) was called directly from three async def handlers; on a spun-down array one isfile can take hundreds of ms, and a day view issues ~240 of them. Now wrapped in asyncio.to_thread.
  • makedirs memoization (web/services/thumbs.py, web/services/filmstrip.py): the cache-path helpers ran os.makedirs — a NAS metadata round-trip — on every call; now once per process per directory.
  • GZipMiddleware (web/app.py): app.js (~200 KB) and day payloads (~80 KB at 240 clips) were served uncompressed on every fetch.
  • Sync-time re-render churn (app.js): queue_changed fires on every item transition during a download session; the 300 ms resetting debounce could starve refreshes under a steady stream and, once idle, wipe-and-rebuild every open day per transition (innerHTML wipe, ~240 recreated <img>s, Leaflet teardown, double getBoundingClientRect FLIP passes). It's now a coalescing throttle (widened to 2 s while a sync is active), and renderDayBody keys each render on its payload + render-shaping settings and skips days whose data didn't change.

API compatibility

  • POST /api/queue/delete request gains optional force: bool = false; response gains protected_names and failed (additive).
  • retention.sweep() summary gains a failed key (additive).
  • Internal signatures changed: retention.delete_clip / _delete_clip_files now return tuples; _disk_pressure_pass returns a 4-tuple.

Tests

  • Existing delete/lock/retention tests updated for the new response shapes; test doubles that stub _delete_clip_files return the new tuple.
  • New tests/test_retention_failed_unlink.py covers: index row kept on failed unlink, sweep reporting failed with 0 bytes credited, and the no-infinite-loop guards for both the disk-pressure pass and make_room_for.
  • New tests for force delete, protected_names, and the unlock endpoint.
  • 78 tests across the affected files pass; the full suite passes on Linux (a handful of unrelated tests fail on a Windows dev host for platform reasons — POSIX file modes/signals and DirEntry.st_dev — identically on unpatched main).

Verification on a real deployment

Built as a Docker image and deployed on an Unraid server (Intel Arrow Lake iGPU, recordings on a user share): delete round-trips behave honestly, /api/queue/unlock is live, gzip is active on the wire, thumbnails/filmstrips still generate, and QSV hardware encode still passes the runtime probe.

Authorship disclosure

This contribution was researched, written, and tested by an AI — Claude (Anthropic), running as a coding agent — at the direction of, and with every step reviewed and deployed by, the repository user submitting this PR. The audit that motivated it was also AI-performed against 995a965. Happy to adjust anything to fit the project's conventions.

🤖 Generated with Claude Code

Anonymouse6661 and others added 3 commits August 3, 2026 02:18
…ck + force

Archive delete could silently do nothing, or worse, pretend to work:

* A failed unlink (e.g. PUID/share permission mismatch on NAS mounts)
  still removed the clip_index row: the tile vanished, "Deleted N" was
  reported, no space was freed, and the clip returned on the next
  rescan. _delete_clip_files now reports whether the primary .mp4 was
  actually removed; on failure the index AND queue rows are kept and
  the clip is reported as 'failed', never 'deleted'.
* The retention passes get the same treatment plus a wedge guard:
  clips whose unlink fails leave the candidate pool, so the
  disk-pressure loop and make_room_for can no longer spin forever
  re-selecting the same un-unlinkable oldest clip while crediting
  bytes_freed for space that was never freed.
* Locked/RO clips were silently dropped from a delete with no way
  back: /api/queue/lock hardcoded locked=True and no unlock route
  existed. Added POST /api/queue/unlock, plus a force flag on
  /api/queue/delete for an explicit confirm-through delete of
  protected clips; the response now carries protected_names so the
  UI can target exactly the refused clips on the second attempt.
* Day-folder rmdir pruning is batched once per delete batch instead
  of one (almost always failing) syscall per clip.

delete_clips now returns {deleted, skipped, protected,
protected_names, failed}; sweep() summaries gain a 'failed' count.
New regression tests cover kept-rows-on-failed-unlink, the loop
guards, force delete, protected_names, and the unlock endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* _fetch_clip (sqlite connect + isfile on the recordings mount) was
  called directly from three async handlers; a day view issues ~240 of
  them, and each can stall the loop for hundreds of ms on a spun-down
  array — freezing every other request and the progress WebSocket.
  They now run via asyncio.to_thread.
* The thumbs/filmstrip cache-path helpers ran os.makedirs on every
  call — a NAS metadata round-trip per thumbnail request. Memoized
  per process.
* GZipMiddleware: app.js (~200 KB) and the ~80 KB day payloads were
  served uncompressed on every fetch; ~5-8x smaller on the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… re-renders

* The delete toast showed only deleted/skipped — deleting protected
  clips read "Deleted 0, skipped 0" with nothing changing and no
  explanation. It now surfaces protected and failed counts, offers a
  second "delete anyway?" confirm that force-deletes exactly the
  refused clips, and a "Clear read-only" action complements Mark
  read-only in the Actions menu.
* Day cards' clip counts and sizes refresh after delete/skip
  (loadDays) instead of going stale until a manual reload.
* Filmstrip hover-scrub had no dwell and never cancelled: sweeping
  the cursor across a day grid queued one ffmpeg sprite job per tile
  crossed, saturating the browser's ~6 connections per origin and
  blocking every other API call behind sprite generation. Loads now
  require 350 ms of hover and abort on mouseleave.
* queue_changed re-renders: the 300 ms resetting debounce could both
  starve under a steady event stream and, once idle, rebuild every
  open day on every item transition. It is now a coalescing throttle
  (widened to 2 s during an active sync), and renderDayBody skips
  days whose payload is unchanged instead of wiping and rebuilding
  their DOM (and Leaflet maps).
* The CSRF 403 retry recursed with no depth guard — a persistently
  403ing POST looped forever with no surfaced error. One retry, then
  the error propagates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@RobXYZ

RobXYZ commented Aug 5, 2026

Copy link
Copy Markdown
Owner

These all look great, many thanks for contributing!

@RobXYZ
RobXYZ merged commit 91d062a into RobXYZ:main Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants