Archive delete: truthful results, unlock & confirm-through; UI/serving performance - #30
Merged
RobXYZ merged 3 commits intoAug 5, 2026
Conversation
…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>
Owner
|
These all look great, many thanks for contributing! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 theprotectedcount: 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 hardcodedTrueand no unlock route existed, so a mis-click was permanent short of editing the SQLite DB by hand.PUID/PGIDlacking write+execute on the recordings share),os.removeraisesPermissionError, 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_freedwas credited for the file's size (read viagetsizebefore the failed unlink), so quota bookkeeping drifted while the disk-pressure loop kept "deleting".What changed
Backend correctness (
web/services/retention.py,web/services/queue.py,web/routers/queue.py)_delete_clip_filesnow returns(bytes_freed, ok), whereokreflects whether the primary .mp4 was actually removed; on failure it reports 0 bytes freed.delete_clipkeeps the index row whenokis false.delete_clipsreports{deleted, skipped, protected, protected_names, failed}. Failed clips keep both their index and queue rows.protected_nameslets the UI target exactly the refused clips on a follow-up.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.POST /api/queue/unlock(the reverse of/api/queue/lock), and an optionalforceflag onPOST /api/queue/deletefor an explicit, confirmed delete of protected clips.rmdirpruning is batched once per delete batch instead of one failing syscall per clip.UI (
web/static/app.js,web/static/index.html)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.loadDays()runs after delete/skip so day-card totals stay correct.Performance
app.js): loading fired on rawmouseenterwith 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 viaAbortControlleronmouseleave.web/routers/archive.py):_fetch_clip(a synchronous sqlite connect +os.path.isfileon the recordings mount) was called directly from threeasync defhandlers; on a spun-down array oneisfilecan take hundreds of ms, and a day view issues ~240 of them. Now wrapped inasyncio.to_thread.makedirsmemoization (web/services/thumbs.py,web/services/filmstrip.py): the cache-path helpers ranos.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.app.js):queue_changedfires 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, doublegetBoundingClientRectFLIP passes). It's now a coalescing throttle (widened to 2 s while a sync is active), andrenderDayBodykeys each render on its payload + render-shaping settings and skips days whose data didn't change.API compatibility
POST /api/queue/deleterequest gains optionalforce: bool = false; response gainsprotected_namesandfailed(additive).retention.sweep()summary gains afailedkey (additive).retention.delete_clip/_delete_clip_filesnow return tuples;_disk_pressure_passreturns a 4-tuple.Tests
_delete_clip_filesreturn the new tuple.tests/test_retention_failed_unlink.pycovers: index row kept on failed unlink, sweep reportingfailedwith 0 bytes credited, and the no-infinite-loop guards for both the disk-pressure pass andmake_room_for.forcedelete,protected_names, and the unlock endpoint.DirEntry.st_dev— identically on unpatchedmain).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/unlockis 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