Repository navigation
Conversation
added 18 commits
October 10, 2026 08:16
…t survives a bad record Cross-site protection: unsafe requests with Sec-Fetch-Site cross-site or an Origin that does not match the Host get 403; with network access off only loopback and the machine's own names are served as Host (rebinding gets 400); JSON routes need a JSON Content-Type. STEMDECK_ALLOWED_HOSTS overrides the list for reverse proxies. The desktop webview, the LAN https listener, the phone UI and Docker keep working. Auto-delete no longer removes favourites, jobs with takes or tracks, or folders that are not jobs. The registry loads records one at a time, keeps a bad record's raw JSON in a sidecar, renames a corrupt file instead of overwriting it, and writes atomically with fsync.
Cross-site guard, safer auto-delete, durable registry.
… Performance The Mode menu no longer lists Streaming (a "Soon" placeholder), and Streamer Mode is named Performance in all ten languages because it records video and audio and exports a file; it does not broadcast. The header now says "Record your performance and export it as a video". A stored mode of "streaming" opens as Practice. Internal names are unchanged (mode value streamer, streamer.* keys, file and folder names, storage keys), so saved scenes and mixes keep working. The exported file is named "<Title> - <Artist> (Performance).mp4".
Streaming placeholder removed; Streamer is now Performance.
…hange, guard Delete Every song reload kept its old audio graph alive (closed AudioContexts 61 to 121, heap 45 MB, detached nodes 600 after 20 changes). The probe context is gone, the stretch worklet node is disposed properly and the player timers are cleared: closed contexts, heap and detached nodes now stay flat. A stem or mode change while playing no longer stops playback and rewinds: one shared helper saves the time, play state and loop, resumes once the playhead holds and ends as soon as the user acts. Delete and Backspace trash a song only from the library list; nothing else (player, mixer, Karaoke, dialogs) can trash the open song. The playhead geometry is measured when the layout changes, not every frame (forced layouts per second 121 to 60; the frame cost is unchanged).
Audio engine leak fixed, stem change resumes playback, Delete guard.
… shows its thumbnails The recording mode forced the take's track muted in its saved mix on every frame, so pressing Play after a take never played it. The take now plays with the backing track (measured +4.5 dB over the backing alone) and its lane has a mute button; the previous take is held silent only while a new take records, through a hold that is never written to any saved mix. The Video lane asked for filmstrip.jpg before the server had built it, got a 404 and never repainted, so it stayed empty. It now builds the filmstrip first, draws it at the take's start, retries once, and says so when there is no video, no camera or the frames failed. After a take is saved the playhead goes back to the take's start.
Take audible after recording; Video lane thumbnails.
The recording view scrolled at many common window sizes (1366x768, 1280x720, 1440x900 and smaller: 16 to 28 controls cut off). The scene preview is now the elastic part, sized from the room the strip and the timeline leave, never under 360 px wide. Short-window tiers tighten the header, the controls strip and the timeline; the Recording & Output panel is compacted (resolution and frame rate side by side, smaller switches) with Export MP4 pinned at the bottom; the drawer is 400 px wide. The layout with Scene Layers under the preview now starts below 680 px of workspace width instead of 1000. Measured: no scrolling at nine of ten window sizes in eight states; only a window under 720 px high scrolls.
Performance view fits the window without scrolling.
A wide dialog (1060 px) with a left navigation column (icon and label per section, a search box, an out-of-sync count on Library and a dot on Network), a title and one-line description per section, settings in cards with the text on the left and the control on the right, and a fixed footer with Done. A new Library section holds Storage, Automatic cleanup, Out of sync tracks and a red Danger zone for Reset app data; Logs and Registry sit under Troubleshooting. Search lists matching settings in place; arrow keys move through the sections; below 820 px the navigation becomes a row of chips and below 560 px the dialog fills the screen. Nothing a setting does, stores or calls has changed. Also fixes the Registry Refresh button, which was wired to Test encoders, and help text that showed literal code tags.
Settings dialog redesign.
…k switch Live Controls belonged to the postponed live-streaming work and only showed "Not available yet". The tabs are now Scene Builder and Templates; an unknown or remembered tab value opens the Scene Builder. The "Include metronome click" switch is gone from Recording & Output and from the saved recording settings; an export never asks for a click lane. Old saved settings that still hold the value load without an error and drop it.
Live Controls tab and the metronome click switch removed.
…m into one layer The scene had three separate layers for the song information: Song Title & Artist, Album Art and Waveform Overlay. They are now one Track Info layer with one frame, one visibility switch and four part switches (Album art, Title and artist, Show artist, Waveform) in Edit properties. The default card draws pixel-identical to before at 16:9, 9:16 and 1:1; other part combinations adapt (art only becomes a square, a title with no waveform is centred, a waveform alone is a centred band). Saved scenes are migrated when loaded: the three old layers merge into one Track Info with the title layer's frame, the earliest z-order and the parts the old layers showed; viewing a scene writes nothing and migrating twice gives the same result. Add Layer offers Track Info; the Guide has entries for the row, its controls and the part switches.
Track Info groups the title, album art and waveform layers.
… Performance leave the rail A second window next to the live preview plays the take's recorded video, muted and in step with the playhead (within about 15 ms when settled), while the engine plays the backing and the take. It holds the first or last frame outside the take and shows a note when stopped, with no take, while a take loads and while a new take records. Layout tiers: live, review and layers side by side on very wide windows; two equal windows when each stays at least 55% of the single-window size; otherwise one window with a Live | Take switch. Scene Layers stays flush right and the view still fits every common window size without scrolling. The Karaoke and Performance buttons are gone from the left rail; the Mode menu is the way in, and the Guide explains both on the menu items. Also fixes a missing audioEngine import that made saving a take throw.
Review window plays the recorded take; rail buttons removed.
| </div> | ||
|
|
||
| <div style="display:flex;align-items:center;gap:20px"> | ||
| <button onClick="{{togglePlay}}" aria-label="{{playAria}}" style="width:44px;height:44px;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(8,10,14,.45);color:#F4F5F7;display:inline-flex;align-items:center;justify-content:center"> |
|
|
||
| <!-- Progress --> | ||
| <div style="padding:0 120px"> | ||
| <div onClick="{{seekBar}}" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="186" aria-valuenow="{{tInt}}" style="position:relative;height:18px;cursor:pointer"> |
| <!-- Sections panel --> | ||
| <aside style="width:330px;flex:none;display:flex;flex-direction:column;gap:12px;border-left:1px solid #161C24;padding-left:12px"> | ||
| <div role="tablist" aria-label="Side panel" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px"> | ||
| <button role="tab" aria-selected="{{queueSel}}" onClick="{{showQueue}}" style="{{queueTabStyle}}">Queue</button> |
| <aside style="width:330px;flex:none;display:flex;flex-direction:column;gap:12px;border-left:1px solid #161C24;padding-left:12px"> | ||
| <div role="tablist" aria-label="Side panel" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px"> | ||
| <button role="tab" aria-selected="{{queueSel}}" onClick="{{showQueue}}" style="{{queueTabStyle}}">Queue</button> | ||
| <button role="tab" aria-selected="{{sectionsSel}}" onClick="{{showSections}}" style="{{sectionsTabStyle}}">Sections</button> |
| <div style="flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding-right:2px"> | ||
| <sc-for list="{{sections}}" as="sec" hint-placeholder-count="7"> | ||
| <div class="secc" style="{{sec.cardStyle}}"> | ||
| <button onClick="{{sec.seek}}" aria-label="{{sec.aria}}" style="flex:1;min-width:0;display:flex;align-items:center;gap:14px;background:none;border:0;padding:0;text-align:left;color:inherit"> |
| <span class="lbl">Key</span> | ||
| <div style="display:flex;align-items:center;gap:10px"><span style="font-size:24px;font-weight:600">{{keyName}}</span><span title="Transpose (semitones)" class="mono" style="min-width:28px;height:28px;padding:0 4px;box-sizing:border-box;border-radius:14px;border:1.5px solid #F5B72A;color:#F5B72A;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center">{{semisLabel}}</span></div> | ||
| <div style="display:flex;gap:8px"> | ||
| <button class="ib" aria-label="Transpose down a semitone" onClick="{{keyDown}}" style="width:44px;height:36px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M5 12h14"></path></svg></button> |
This was referenced Oct 11, 2026
Closed
added 2 commits
October 11, 2026 18:27
…ce and a track-row overflow The full suite had never run against the 0.21.0 work: it loaded zero tests because streamer.spec.mjs set launchOptions inside a describe, which Playwright refuses for the whole run. Those groups moved to streamer-media.spec.mjs. Out-of-date expectations (the Performance rename, the Mode menu as the only way into Karaoke and Performance, the lanes, Track Info, the layout tiers) were updated without dropping any test, specs that leaked state or relied on timing were made robust, and track-header.spec.mjs now measures the side-rail header. Two real bugs found by the run are fixed, each with a spec that fails without the fix: when the song reloaded during a take the recorder sometimes stopped as if it were a pause and lost the "song reloaded" message (capture/takeRecorder.js), and a recording track's card was taller than a short row, so its menu and input picker stuck out (daw.css). Result: 625 passed, 7 skipped (hardware or host gated), 0 failed.
E2E suite up to date and green; take-recorder race and track-row overflow fixed.
#886) Reset app data and the automatic clean-ups now remove only what StemDeck itself put in the jobs folder (the StemData location). Everything else in that folder is left exactly as it was. ## What changes for the user This matters when the jobs folder is shared with other things: an Unraid or Docker volume mapped onto an existing share, or a folder the user already had and picked in Settings. The default folders are StemDeck's own, so most installs see no difference. - **Reset app data** used to empty the jobs folder entry by entry. Photos, documents or another program's folders sitting next to the songs went with them. Now it removes: - song folders StemDeck has a record of, or that hold files it writes into a song (source, stems, takes, metadata); - its quarantined failures in `failed/`; - its own record files (`registry.json` and its temp and set-aside copies, `user-data.json`). Anything else stays, and the response says how many entries were left alone (`"skipped"`). - **Auto-delete** (the hourly sweep of old finished songs) no longer removes an old unregistered folder that merely has a job-id-shaped name. Twelve hex characters is also a Docker container's short id. The folder must hold something StemDeck writes. - **The failed-job sweep** no longer removes folders in `failed/` that are not StemDeck's quarantine. Every quarantine holds the `error.txt` the runner writes. The sweep also no longer follows `failed/` when it is a link. - **No sweep and no reset follows a symlink or a Windows junction** out of the jobs folder, or removes one. This covers the two startup clean-ups (section workspaces, render temp files) too. - A known song whose folder is a link is not deleted by reset. It is recorded as deleted, so it does not come back on the next start (#521). The jobs folder itself is never removed. No visible text changes. The reset dialog still says it deletes every song, which stays true. ## Why The validation of this finding confirmed the deletion with a probe and narrowed its reach to a shared folder mapped as the jobs folder. There, one Reset deletes files that StemDeck never created and cannot restore. ## How it was verified - **Red then green.** The new `tests/test_jobs_folder_scope.py` was run against the code before this change: 10 of its 11 tests at that point failed because foreign files were deleted, or a link or junction was followed out of the folder. All pass now. - The 11th, a TTL sweep link test, passed on the old code too, because Python 3.12's `rmtree` already refuses a top-level link. It was dropped as a test that cannot fail. - Review fixes then added cases for a look-alike `registry.json.bak`, a job-id folder in `failed/` without `error.txt`, and a linked song not coming back after a restart. - **Existing tests updated where they encoded the old behaviour:** - quarantine fixtures now carry `error.txt`, as the runner always writes it; - an unregistered leftover holds `metadata.json`; - the reset tests use their own jobs folder; - `reset_all` returns `(undeleted, skipped)`. - **Real run on a throwaway server** (port 8571, jobs folder under the worktree's git-ignored `data/`). The folder held: - a song; - `My Photos/beach.jpg` and `notes.txt`; - a `0123456789ab/config.v2.json` folder; - a junction `Shared` pointing outside the folder; - `failed/abcdefabcde1/error.txt` and `failed/torrent.iso`. `POST /api/reset` answered `{"ok":true,"undeleted":0,"skipped":5}`: - the song, the registry and the quarantined failure were gone; - every foreign entry and the junction's target were intact; - the library was empty. - **Checks, all run locally on Windows:** - `ruff check` and `ruff format --check` are clean. - Full pytest: 3679 passed. The 2 failures, in `test_cancellation_reach.py`, also fail on `big-release` on this machine, because ffmpeg is not on PATH there. - Browser suite in 3 shards: 821 passed, 7 skipped, 0 failed. Does not touch `uv.lock` or the desktop shell.
…hose version is not a number Both reviewers found the version id pattern still accepted a trailing newline, the case just closed for job, take and track ids. A registry.json with a version that is not a number raised on the comparison and moved the whole file aside; it is now read as the oldest version, so its records still load.
…cord (#888) A library now survives a trip back to 0.20.0 and up again, and one damaged record in it no longer costs the rest of the library or leaves a new `registry.json.bad-*` file on every start. ## What changes for the user - **Back from 0.20.0, a song keeps what 0.20.0 dropped.** 0.20.0 loads a 0.21 library fine, but its first save rewrites `registry.json` without every field it does not know. On the way back up, the song now gets them again from its `metadata.json`: versions and the version that opens, the Lead + Backing request, the separation settings, the lyrics choice, the loudness version, and the stem format (an MP3 song is known as MP3 again, so a later split is stored as MP3 too). - **Recorded takes come back, and each track still plays the take it played.** Before this change the takes stayed invisible, and the load wrote every track's active take as none into `tracks.json`. Takes are rebuilt from their folders whenever the registry has none for a song. - **One bad record costs only itself.** A record with an id that is not a job id, or a second record for the same song, used to be dropped at the next save with no copy kept. Both are now kept in `registry.json.bad-*` like any other bad record. A number too big for a float (it made `GET /api/jobs` fail for every song), NaN or infinity is reset to its default. A take or track entry without its id is reset, and the list is filled again from the take folders and `tracks.json`. - **A damaged song file costs that file, not the library.** A `take.json` with a value of the wrong type or nested too deep, or a `metadata.json` nested too deep or holding a number too long to parse, used to stop the rebuild of every song folder after it, and the save. A version entry with a time too big for a float hid its song. On the way back from 0.20.0, one `metadata.json` nested too deep moved the whole registry aside. - **A registry nested too deep no longer stops the backend booting.** It is moved aside like any other unreadable registry. - **Set aside once.** The registry is rewritten without the bad record straight away, so the next start does not set it aside again. That happens only once the copy is kept: if the `registry.json.bad-*` file cannot be written, the registry is left as it was. - **One log line per bad record**, naming the job id only when it is a well-formed one, so nothing from a damaged file can forge a log line. The job, take, track and version id patterns now end in `\Z` instead of `$`, which also accepted a trailing newline, so a folder named that way is no longer adopted and set aside again on every start. - **A registry whose `version` is not a number** (null, a string) used to move the whole file aside. It is now read as the oldest version, so its records load. Two fields are still lost on a trip to 0.20.0 and back, because nothing else on disk keeps them: the reason of a failed Lead + Backing split (the song then shows the general message) and the state of the last Extract missing stems run. Songs stored as MP3 still do not play in 0.20.0 itself; that is the release-notes line for the downgrade, not a code change. ## Why - `restore()` copied the saved fields into `metadata.json` on the way up (#883) but never read them back into a record that lacked them. - `_sync_tracks` reconciled the tracks against the record's empty take list, so every active take was reset and saved. - The record loop set a bad record aside without marking the registry changed, and skipped a malformed id with no copy and no log line. - `_repair_field_types` checked JSON types but not whether a number fits a float. - `recover_takes` caught a broken JSON file but not a sidecar whose values were the wrong type, and nothing caught `RecursionError` or the int digit limit; the folder loop in `restore()` had no guard per folder. - `versions._finite_time` used `math.isfinite`, which raises `OverflowError` for an int too big for a float. ## How it was verified - **Red then green.** 26 new tests fail on `big-release` (checked again after #885, #886 and #887 merged) and pass with this change: - `tests/test_registry_rebuild_user_fields.py::test_a_downgrade_to_0_20_0_and_back_loses_nothing` cuts each record to the fields 0.20.0 writes (taken from the 0.20.0 fixture, which `make.py` writes with the v0.20.0 code) and checks versions, stem format, takes and `tracks.json` after the restart. - `tests/test_registry_durability.py`: a bad record is set aside once over three starts (before: three files); a path-like, newline, numeric, list or repeated id is kept aside and logged once without the raw id; a huge int, NaN, infinity, a take entry and a track entry without an id are reset and `GET /api/jobs` still lists both songs (before: `OverflowError` for the whole list); a take entry without an id comes back from its folder; five kinds of damaged `take.json` or `metadata.json` leave both song folders rebuilt and saved (before: none); a `metadata.json` nested too deep on the way back from 0.20.0 no longer moves the registry aside; a version with a huge time keeps its song; a registry nested too deep is moved aside instead of raising; an id with a trailing newline is not an id; a registry version of null, `"2"` or `[2]` still loads its records. - **The real round trip with the v0.20.0 code**, scripted on a throwaway library: 0.21 wrote two songs (one with versions, a take and a track; one stored as MP3), the v0.20.0 code loaded both and saved, and 0.21 loaded them again. Before this change: versions, takes and the stem format gone, and `tracks.json` rewritten with no active take. After: everything back, twice in a row, and a favourite set inside 0.20.0 kept. - **A throwaway server** on that library after the trip down and up: `GET /api/jobs` 200 with the versions, the take, the active take and the MP3 format. A second server on a registry with a huge-int record, a junk entry and a repeated id: 200 on both starts, the first record wins, one `registry.json.bad-*` file in total, one log line per record. - One existing test changed: `test_one_bad_record_does_not_cost_the_others` now gives its tombstone a folder. The save that now follows a bad record prunes a tombstone without one, which is what `persist()` has always done. - ruff check and format clean. Full Python suite: 3732 passed; the 2 failures, in `tests/test_cancellation_reach.py`, fail the same way on `big-release` on this machine (a command the test starts is not installed here). - `big-release` came in by a merge commit, not a rebase, so the branch history was not rewritten. Does not touch `uv.lock` or the desktop shell binary.
… is lost A song stopped before it was separated keeps its source file and nothing else, and for an upload that file is the only copy StemDeck has. When registry.json was lost or set aside as corrupt, restore() rebuilt only folders with stems, so such a folder stayed out of the registry and out of reach of Extract, and the auto-delete sweep then removed it with the file in it once it was older than the limit. restore() now brings a folder whose source (the file Extract would run) is an upload type back as an ordinary stopped song, the record every version that knows 'stopped' loads, so Extract can run it. The sweep keeps any unregistered folder that holds a source file, including one restore() does not adopt (a link download cut off part way). Reset still removes it, a song the user deleted still goes, and a folder that is or holds a link is never adopted, so Extract cannot write through one. The upload file types move to config (UPLOAD_EXTS) so the registry and the upload route share one list.
… is lost (#889) ## What changes for the user A song that was stopped before it was separated is no longer lost when `registry.json` is lost or set aside as corrupt. - On the next start it comes back as an ordinary stopped song ("Recovered track abc123"), and Extract runs it. - Auto-delete never removes an unregistered song folder that holds a source file, including one the start cannot bring back (a link download cut off part way). - Reset app data still removes it, and a song the user deleted still goes. ## Why A stopped song keeps its source file and nothing else, and for an upload that file is the only copy StemDeck has. `restore()` rebuilt only folders with stems, so such a folder stayed out of the registry and out of reach of Extract. With auto-delete on, the hourly sweep then removed it, file included, once it was older than the limit. ## How - `app/core/registry.py`: `_stopped_from_source` adopts a folder as `status="stopped"` when the file Extract would run (the first `source.*`, the same choice as `jobqueue._find_local_source`) is an upload type. A folder that is a link, or holds one, is never adopted, so Extract cannot write through it. Tombstoned folders are skipped as before. One warning line names what was brought back. - `app/pipeline/collect.py`: a new keep rule in `_keep_reason` keeps an unregistered folder holding any `source.*` file. The tombstone check still comes first. - `app/core/config.py`: the upload file types move to `UPLOAD_EXTS`, so the registry and the upload route share one list. Behaviour is unchanged. ## Verified - Red then green: `tests/test_unregistered_source.py` (11 tests). On `big-release` 5 fail for the right reasons: the sweep deleted the source file, the restart did not adopt the folder, and Extract returned 404. All pass with the change. They cover a foreign folder, a junction to outside the jobs folder, a link inside a real folder, a tombstoned folder, a registered stopped song, reset and the `.part` case. - Real run on throwaway servers, with auto-delete on, a corrupt `registry.json` and a folder backdated 30 days: - Base code deleted the stopped upload's folder. - With the change, the folder came back as stopped and Extract separated it to `done` with 6 stems. Reset removed it, and the foreign folder and the junction target were untouched. - Downgrade: v0.20.0's `restore()` loads the adopted record as an ordinary `stopped` song. - Full Python suite: 2 failures in `tests/test_cancellation_reach.py`, which fail the same way on `big-release` on this machine. - `ruff check` and `ruff format --check` are clean. No `uv.lock` or desktop shell change. No new visible text, so the Guide and i18n are unchanged. The placeholder title follows the existing "Recovered track" title used for stems without `metadata.json`.
A queue restored after a restart waits, paused, for Start. restore() still counted every restart as a resume, so a second restart without pressing Start turned every waiting job, uploads and links alike, into an error. An error is not saved, so the queue was gone from the Library. The count now moves when the queue worker starts a job, and is saved before the run, since a job that takes the process down never reaches a save of its own. restore() only checks it: a job started twice (the first run and its one resume) and still unfinished is failed as before. A record an older version wrote with 1 after one restart reads as one run and keeps its resume. Extract, a new request from the user, starts the count again.
…890) After a restart the restored queue waits, paused, until Start is pressed. Each restart still counted as the job's one resume after a crash, so restarting twice without pressing Start turned every waiting song, uploads and links alike, into an error. An error is not saved, so the whole queue left the Library on the next save (an upload came back later only as a "Recovered track" with its name lost). ## What changes - `app/pipeline/jobqueue.py`: the queue worker counts a run when it starts a job, and saves the registry before the run. A job that takes the process down never reaches a save of its own, so the count has to be on disk first. - `app/core/registry.py`: `restore()` no longer counts. It only checks: a job started twice (its first run and its one resume) and still unfinished is failed as before. A restored job that was never started stays queued across any number of restarts. - `app/core/registry.py` `requeue_if_capacity`: Extract is a new request from the user, so it starts the count again. Without this, a stopped song that had run before would lose its resume. - `app/core/models.py`: the comment on `resume_attempts` says what it counts now. ## Upgrade and downgrade `resume_attempts` keeps its name and type, so every version reads every record. - Upgrade: 0.20.0 writes 1 after a single restart of a waiting job. The new rule reads that as one run, so the job stays queued and keeps its resume. (The other design considered, counting only resumed runs and failing at 1, would have failed exactly these jobs on the upgrade restart.) - Downgrade: checked by running 0.20.0's `restore()` on a registry this branch wrote. It loads. A job that was waiting is queued. One narrow case: a job that was running when the process crashed (not a clean stop, which resets the count) is failed by 0.20.0 instead of resumed. ## How it was verified - Red then green, pytest: `test_restarts_of_a_queue_never_started_keep_every_job` (queued and a stale "separating" on disk, three restarts), `test_a_job_interrupted_once_is_resumed`, `test_a_job_that_kills_the_process_gets_one_resume` (the real queue worker: start, crash, three idle restarts, Start, crash, then error), `test_extract_gives_the_job_its_resume_back`. All five fail on `big-release` (the first two with `'error' == 'queued'`) and pass here. The existing cap test now uses a count of 2. - A real server from source on a throwaway jobs folder, hard-killed between sessions: two uploads (the first one running), two restarts without Start, then Start. On `big-release` both songs were errors after the second restart and the queue was empty. On this branch both stayed queued through both restarts, and after Start both finished with all their stems. - `ruff check` and `ruff format --check` are clean. Full pytest locally: 3750 passed. The 2 failures are in `test_cancellation_reach.py`, and they fail the same way on `big-release` on this machine. No change to `uv.lock` or the desktop shell. No UI or text change.
…o's assets A failed rename in the middle of the in-app update swap left the install with no backend/, and the next launch's sweep deleted backend.old, the only copy, so the app needed a full re-download. The swap now undoes the renames already done when one fails, the sweep keeps backend.old while there is no backend/app, and the rename retry budget is about 5 s (a missing path fails at once). The update URL check accepted any github.com path, and the checksum and runtime id fetches had no check at all, so the page could point the updater at another repository's release. All three now require https://github.com/stemdeckapp/stemdeck/releases/download/. Release builds no longer take the app root from the working directory, so a portable exe started inside a source checkout runs its own install.
…o's assets (#891) Three fixes to the desktop in-app updater and app-root lookup (Windows and Linux updater; the app-root change applies to every desktop build). All in `desktop/src-tauri/src/main.rs`. ## What changes for the user - **A failed update no longer leaves the app without its backend.** The swap renames `backend/` to `backend.old`, then the new `backend/` into place, then swaps the exe. If any of those renames fails (for example a virus scanner holding a handle), the renames already done are now undone, newest first, so the previous install is left exactly as it was. Before, a failure on the second rename left no `backend/` at all, and the next launch's cleanup deleted `backend.old`, the only copy, so the install needed a full re-download. - **The launch cleanup keeps `backend.old` when there is no `backend/app`.** Then it is the only copy of the app (a swap that stopped between its renames, for example a power cut), so it is left on disk instead of deleted. - **The rename retry budget is about 5 s instead of 1.5 s**, so a slower scan of the freshly extracted backend does not fail the update. A missing path is not retried, because it never clears by waiting and the update command runs on the window's thread. - **Updates are accepted only from this repository's releases.** The update URL check allowed any `github.com` path, so a script in the page could hand the updater a release archive from another repository, together with that repository's own checksum. It now requires `https://github.com/stemdeckapp/stemdeck/releases/download/`. The two small files read before an update (`runtime-version.json` and the `.sha256` that the archive is checked against) now go through the same check; before, they had no check at all. `objects.githubusercontent.com` is no longer accepted as a starting URL: the frontend always passes the releases API's `browser_download_url` (verified against the v0.20.0 and v0.19.1 asset lists), and GitHub's redirect to its asset host is followed by the HTTP client, not by this check. - **A release build no longer takes its app root from the working directory.** A portable `StemDeck.exe` started from a terminal inside a source checkout ran that checkout's `app/` instead of its own install. Only debug builds (`cargo tauri dev`) look at the working directory now. A release build inside the repo's `target/` still finds the checkout through the exe's own path, as before. ## How it was verified - Red then green: five Rust unit tests, each failing on `origin/big-release` for the stated reason and passing with the change: - `the_launch_sweep_keeps_backend_old_when_it_is_the_only_backend` (before: `backend.old/app` deleted) - `a_failed_backend_install_puts_the_previous_backend_back` (before: `backend/` missing after the failed rename) - `only_our_own_release_assets_are_downloadable_as_updates`, extended with another repository, a lookalike repo name, a dot-segment path, a non-release path of this repo and an asset-host URL (before: `https://github.com/someone/stemdeck/releases/download/v1/x.zip` accepted) - `update_checksum_and_runtime_id_fetches_are_held_to_the_same_urls` (before: the small-file fetch went to the network) - `only_debug_builds_take_the_app_root_from_the_working_directory` (before: the checkout was returned with the release flag) - Added after review: `a_failed_exe_install_puts_the_previous_backend_and_exe_back` (a failure at the last rename undoes all four renames). - `cargo fmt --check`, `cargo clippy --tests -- -D warnings` and `cargo test` (205 passed) pass on Windows. The Linux and macOS compile is left to the PR's Rust check jobs (no platform-only block was changed). - Independent Rust and security reviews of the diff. ## Update path - This changes the shell binary. It does not touch `uv.lock`, so the runtime id is unchanged. - The updater code that runs is the one in the installed build, so these fixes protect the updates that start from a build containing this change, not the update to it. ## Known gaps, not changed here - After a failed and rolled-back update the files on disk are intact, but the backend was already stopped and is not restarted, so the window needs an app restart. This was already the case before. - `download_app_update` still takes the expected checksum from the page, so a script in the page could install an older release of this repository. It can no longer point at another repository. ## What to verify by hand on an installed build 1. **Real release URLs still pass (Windows portable, and Linux if available).** Install a portable build made from this change in a user-writable folder, then let it check for an update against a newer published release. Expected: either the in-app update is offered, or the dialog shows the full-download note for changed Python dependencies. Not expected: an error containing `refusing to download an update from`. The DevTools console line `[catalog] in-app update unavailable:` shows the exact reason. 2. **If an in-app update is offered, apply it.** Expected: the app restarts on the new version, and on the launch after that `backend.old` and `StemDeck.exe.old` are gone from the install folder. 3. **Working directory is ignored.** Open a terminal, `cd` into a StemDeck source checkout, and start the installed `StemDeck.exe` by its full path. Expected: it opens the installed app with its own settings and library location, not the checkout. 4. A forced failure of the backend rename (a handle held by a scanner) cannot be set up reliably by hand; the unit test covers the rollback on a temp folder.
…ffmpeg as UTF-8 The cookies file setting took any readable file on the machine and answered "not found" or "saved", an existence oracle for anyone who can reach Settings, and yt-dlp echoes each cookie line it cannot parse into the backend log. The file must now be inside the data folder (a bare name is looked up there), the name is checked before resolve() can open a network share, and every refusal gets one sentence. A path saved earlier keeps working. redact() now replaces the cookie line in yt-dlp's "skipping cookie file entry" warning and in http.cookiejar's "invalid Netscape format cookies file" error, which covers the log viewer, the log zip, the job error and the failure report. A failed upload duration probe answered 422 with the exception text, which names the ffprobe path and the job folder. It now answers a fixed sentence and logs the exception. The ffmpeg and ffprobe calls in takes.py and the encoder probe in take_render.py used text=True without an encoding, so on a cp1252 Windows a folder name with bytes like 0x8D killed the pipe reader: the filmstrip probe failed with AttributeError and the decoder fallback read 0 seconds. They now decode as UTF-8 with replacement, and a source scan keeps every text-mode child in app/ pinned.
After a failed or cancelled job, the quarantine (strip, write error.txt, move to jobs/failed), the stop path's rmtree and remove_job_dir all ran inline on the event loop. A multi-GB job, or a library on a NAS, froze every SSE stream and request for seconds. They now run through asyncio.to_thread, in the runner and in the queue's dropped-job and deferred-delete paths, which become async. A failed job turns "error" after its quarantine, so the first "error" a page records already carries error_detail, and no delete can remove the folder while it moves. The error path also completes a delete answered 202 during that window, as the cancel and done paths already did.
Since #868 a failed upload's source stays in jobs/failed/<id>, but nothing could use it: the Library offered Try again only for links, and /extract accepted only a stopped job, so the file had to be copied out by hand and imported again. /extract now also takes an errored upload. It renames the quarantine folder back to jobs/<id> and re-queues it through the same capacity check a stopped job uses; a full queue puts the folder back. A folder the quarantine left in place is run where it is. reset_for_rerun also clears error_detail, so a rerun does not carry the old failure text. In the Library, a failed upload's Try again calls /extract.
…ision A torch swap commit deleted the backup tree with the manifest still in it, so a delete that stopped part way (a file held open) left a backup that the next launch restored over the verified CUDA install. The manifest is now removed first, and the next snapshot clears a manifest-less leftover before moving anything into it. owned_entries and restore kept RECORD and manifest names that exist() accepts but that are not entries: on Windows "C:" joins to a drive's current directory and "..." or ". " are trimmed to site-packages itself, which discard_installed or restore would then delete. Names are now matched against read_dir instead. The launch check reported a missing torchvision beside +cpu torch as healthy, so a pip run that stopped between uninstall and install left the karaoke split broken for good. An absent version.py now gets the CPU torchvision install; an unreadable one is still left alone.
## What changes for the user
When an import fails or is cancelled, StemDeck cleans up its folder: a
failed job's audio is stripped and the folder moves to
`jobs/failed/<id>`, a cancelled job's folder is deleted, a stopped job
loses its half-written separation. All of that ran on the server's event
loop. On a multi-GB job, or a library on a NAS, every open page froze
for seconds: progress streams stopped, other requests waited.
The cleanup now runs in a worker thread (`asyncio.to_thread`), so the
rest of the app keeps answering while it runs.
## What exactly moved off the loop
- `app/pipeline/runner.py` `_run_async`:
- the failure quarantine (`_quarantine_failed_job`)
- the stop path's removal of the partial separation
- a plain cancel's `remove_job_dir`
- `app/pipeline/jobqueue.py`:
- `_finalise_dropped_job` (a job cancelled between pop and claim) and
its `cleanup_job_dir`
- `finish_deferred_delete` (a job emptied from the Trash while it was
settling) and its `remove_job_dir`
Both jobqueue functions become `async`. Their callers were already
async.
## Ordering decisions
- **Failure: the status turns "error" after the quarantine, not
before.**
- The page records a failure notification from the first "error" it
sees, including `error_detail`. The quarantine computes `error_detail`,
so with the old order a page could see "error" with no detail once the
quarantine stopped blocking the loop.
- While the folder moves, the job still reads as running. A delete
therefore gets 409, or 202 when the job is in the Trash, and cannot
remove the folder mid-move. Reset and the library move refuse too.
- Side effect: the `stage:` line in `error.txt` (served by `GET
/api/jobs/{id}/failure`) now names the stage the job died in, for
example `Separating stems...`. Before, it always read `Error: Processing
failed`, because the status had already changed. The page's report still
prefers the stage it captured itself.
- **Failure: the error path now calls `finish_deferred_delete`**, as the
cancel and done paths already did. A job emptied from the Trash while
its folder moved got a 202, and nothing removed its record. It is called
before the persist, because the persist forgets a deletion record once
the folder is gone.
- **Stop: the separation is still removed before the status changes**,
so Extract never starts on a half-deleted folder.
- **Cancel: the job reads "cancelled" and then its folder goes, in the
thread.**
- The status still settles first, so a trash arriving during the removal
cannot turn a deleted folder into a "stopped" job that should have kept
its files.
- A delete in that window converges with the removal, through
`remove_job_dir`'s retry.
- `test_cancel_while_the_lookups_are_out_frees_the_queue` waited only
for "cancelled" before checking the folder. It now waits for both.
## Known limit, not changed
A server shutdown that lands during these few seconds cancels the await
but not the thread. The cleanup finishes, but the status write after it
can be lost.
- A failed link job is then tried once more on the next start.
- A failed upload ends in "source file missing". Its source is in
`jobs/failed/<id>` until the usual sweep.
Nothing is deleted that would otherwise have been kept.
## How it was verified
- New tests in `tests/test_pipeline_runner.py`:
- They make `shutil.rmtree` take 0.2 s and count how often another
coroutine runs meanwhile. They cover failure, cancel, stop, emptied from
the Trash, and a cancel before the claim.
- All five fail on `big-release` with `assert 0 > 0` ("blocked the event
loop") and pass with this change.
- The failure test also checks that the first "error" a watcher sees
already has `error_detail` and `error.txt`.
-
`test_failed_job_emptied_from_the_trash_during_its_quarantine_is_removed`:
fails on `big-release` (the record stays) and passes now.
- On a throwaway server (port 9200, data under the worktree's `data/`),
with a model name that makes separation fail:
- The upload ended "error" with its `error_detail`.
- `jobs/failed/<id>` held `error.txt` and the source.
- `/failure` answered.
- A cancelled upload ended "cancelled" with its folder removed.
- Full pytest run on Windows: 3755 passed, 12 skipped.
- Two `test_cancellation_reach` ffmpeg tests fail, and they fail on
`big-release` the same way: on this machine the command is not found.
- One identify test needed the wait described above.
- `ruff check` and `ruff format --check` are clean.
No change to `uv.lock` or the desktop shell.
…ffmpeg as UTF-8 (#893) Three small hygiene fixes: two stop error text and logs from carrying paths or file contents, and one stops video takes failing on Windows when their folder name has certain non-ASCII characters. ## What changes for the user - **YouTube cookies setting.** The cookies.txt file now has to be inside the StemDeck data folder, and a bare name such as `cookies.txt` is looked up there. Before, the field took any readable file on the machine and answered "not found" or "saved", so anyone who could reach Settings (the LAN, with network access on) could test whether any path existed, and could point yt-dlp at any file. yt-dlp then echoed every line it could not parse into the backend log. A path outside the data folder, a missing file, an unreadable file and a malformed name now all get the same answer. The name is checked before anything touches the disk, so a `\\host\share` value is refused without Windows opening the share (and sending the user's NTLM hash to it); symlinks out of the folder are refused after resolving. The Settings description and error text say where the file goes, in all ten languages. - **A cookies path saved before this change keeps working** until it is changed. Nothing is migrated or cleared. - **Logs viewer, log export, job error and failure report.** yt-dlp's `skipping cookie file entry` warning, and http.cookiejar's `invalid Netscape format cookies file` error (which yt-dlp prints and fails the job on), keep their first words, and the cookie line itself is replaced by `<cookie-line-redacted>`. All of these already pass through `redact()`. The raw backend.log on disk is unchanged. - **Upload duration error.** When ffprobe cannot read an upload's duration, the 422 now says only `Could not read file duration`. Before, it included the exception text, which carries the ffprobe path and the job's source path. The full error is logged in stemdeck.log. - **Video takes on Windows.** The four ffmpeg and ffprobe calls in `app/api/takes.py` (and the encoder probe in `app/pipeline/take_render.py`) now decode as UTF-8 with replacement, like every other subprocess call in `app/`. Without it, a Windows machine with a cp1252 locale decoded ffmpeg's output with cp1252, and a folder name such as `Tōkyō` (UTF-8 byte 0x8D) killed the reader thread: the probe raised `AttributeError` (a 500 on the filmstrip) and the decoder fallback read the duration as 0. ## How it was verified - Red then green: every new test below fails on `origin/big-release` for the reason named and passes with this change. - `tests/test_cookies_setting.py`: a file outside the data folder is accepted (200) on the base, refused after; an outside file, a missing file and a name with a NUL byte get identical 422 bodies; a bare name resolves in the data folder; `//10.0.0.5/share/cookies.txt` is refused without `Path.resolve()` ever seeing it (on the base, resolving it spent about 30 seconds trying the network). - `tests/test_redact.py`, `tests/test_logs_api.py`: the cookie line content of both messages appears in the redacted text and the `/api/logs/backend` view on the base, and is gone after. - `tests/test_jobs_api.py`: `TimeoutExpired`, `FileNotFoundError` and `RuntimeError` from the probe each put a path in the 422 on the base; after, the body is the fixed sentence and the exception is in the log. - `tests/test_takes_render.py`: with the locale encoding forced to cp1252 and real ffmpeg on a camera file in a `Tōkyō` folder, `_decoded_duration` returned 0 instead of 2 s and `build_filmstrip` on a broken file raised `AttributeError` instead of `RuntimeError`; both pass after. - `tests/test_subprocess_encoding.py`: a source scan finds every `text=True` subprocess call in `app/` without `encoding=`. It listed the five sites on the base and none after. - Live server on a throwaway data folder: an outside cookies file, a missing one and `C:\Windows\win.ini` all got the same 422; `cookies.txt` saved and resolved into the data folder; the logs view showed the redacted line; a broken MP3 upload got `{"detail": "Could not read file duration"}` with the traceback in stemdeck.log. - `tests/e2e/settings-storage.spec.mjs`: the cookie specs now expect the new message, put the good file in the backend's data folder, and add a file outside it that is refused like a missing one. - ruff check and format, the i18n coverage check (clean), the 73 node unit tests, the full pytest suite (3761 passed; the 2 failures in `test_cancellation_reach.py` spawn `sh`, which is not on PATH on this machine, and touch no code in this change), and the browser suite (816 passed; 3 `settings-scroll.spec.mjs` cases failed under load in the parallel run and passed when rerun alone, together with the whole settings-storage spec). ## Open point for Docker and Unraid The image sets no `STEMDECK_DATA_DIR`, so its data folder is `/app`, and the only mounted folder under it is the library at `/app/jobs`. A cookies file there (`jobs/cookies.txt`) is accepted; one mounted anywhere else is not. A cookies path already saved keeps working. ## Upgrade path No change to `uv.lock` or the desktop shell.
) ## What changes for the user A song added from a file whose separation failed now has **Try again** in the Library, like a failed link. Pressing it runs the job again from the file StemDeck kept, so the file no longer has to be found and added by hand. ## Why Since #868, a failed upload's source file is kept in `jobs/failed/<id>/`. Nothing could use it: the Library offered Try again only for links (a new import of the link), and `POST /api/jobs/<id>/extract` accepted only a stopped job. The only way back was to copy the file out of the folder and import it again. ## How - `POST /api/jobs/<id>/extract` also accepts an errored upload (`local:` source). It moves `jobs/failed/<id>` back to `jobs/<id>` with a rename (refused with 409 when the folder cannot be moved, and 409 "the file is no longer kept" when no `source.*` is left), then re-queues through the same `requeue_if_capacity` call a stopped job uses. When the queue is full (503) the folder goes back to `jobs/failed/<id>`, error.txt included. A folder the quarantine left in place (when the quarantine itself failed) is run where it is. A failed link still answers 409. - `reset_for_rerun` now clears `error_detail` as well as `error`, so a rerun does not carry the old failure text. - The quarantine path is built by one helper, `_failed_dir`, shared with `GET /failure`. - Library: `canRetryImport` is true for every error row that has a source. A link is imported again as before; an upload calls `/extract`, and its failure card goes only once the retry is accepted. A refused retry shows the server's reason and keeps the button. No new control: the button is the existing `.cat-extract`, its Guide entry (`help.songExtract`, "after its import was stopped or failed") and both labels already exist in all ten tables. `check_coverage.py` prints clean. ## Limitation (unchanged) The server does not persist an errored job, so after a restart the row's Try again answers "job not found", and the 7 day failed-job sweep still removes the kept file. Keeping both across a restart is a separate decision. ## Verified - Red then green, pytest (`tests/test_jobs_api.py`): a failed upload quarantined by the real `_quarantine_failed_job` is re-queued and its source is back where the queue looks for it; a folder left in place runs; no kept file gives 409; a folder that cannot be moved gives 409 and the file stays; a failed link gives 409; a full queue gives 503 and the folder is back in `jobs/failed/<id>`. Three of these fail on `big-release` (409) and all pass with the change. - Red then green, Playwright (`tests/e2e/import-failure.spec.mjs`): a failed upload row shows Try again, a click POSTs `/extract` (never a new import) and the row is queued. It fails on `big-release` (no button) and passes with the change. A second test covers a refused retry. - On a throwaway server: an upload failed for real (a missing model name), its `source.wav` landed in `jobs/failed/<id>`, `/extract` answered 200 queued, the file moved back to `jobs/<id>`, the job ran again and was quarantined again with its source kept. - ruff check and format, the node unit tests, the affected pytest files, and the full browser suite. Does not touch `uv.lock` or the desktop shell.
…ision (#895) Three small repairs to how the desktop shell swaps torch for a CUDA build and checks torchvision on launch. Builds on #891. ## What changes for the user - **A CUDA install that verified stays put.** After a good CUDA swap, the shell deletes the backup of the bundled CPU torch. If that delete stopped part way (a file held open, for example by an antivirus scan), the half-deleted backup still had its `manifest.txt`, so the next launch treated it as an interrupted swap and restored half a CPU torch over the working CUDA one. The commit now removes `manifest.txt` first, so a delete that stops part way leaves nothing that counts as a backup. The next snapshot clears that manifest-less leftover before it moves anything, so a stale entry cannot block the move or be restored later. - **A damaged RECORD or manifest cannot point outside its folder.** `owned_entries` takes the first path segment of each line in torch's `RECORD` and kept it if `site.join(name).exists()`. On Windows that is true for names that are not entries at all: `C:` (from a line such as `C:\x,,`) joins to that drive's current directory, and `...` or `. ` are trimmed to site-packages itself. `discard_installed` would then delete them. A name is now kept only if `read_dir(site)` lists it, which never returns any of those. `restore` uses the same rule for the names in `manifest.txt`, matched against `read_dir` of the backup, because a manifest written by an older build from such a RECORD could hold them. - **A missing torchvision is put back.** On a CPU torch install, the launch check only repaired a CUDA torchvision; a torchvision that was gone (a pip run that stopped between uninstall and install) was reported as healthy, and the karaoke split failed at import every time. A missing `torchvision/version.py` beside `+cpu` torch now gets the same CPU torchvision install. A `version.py` that exists but cannot be read is still left alone, so a healthy offline machine is not sent to the network on every launch. A CUDA torch is not touched. ## How it was verified - Red then green on Windows, `cargo test`: - `torchswap::tests::a_commit_that_stops_part_way_leaves_no_backup_to_restore` (Windows only: it holds a backup file open with `share_mode(0)`). Before: `assertion failed: !has_backup(&site)`. With only the commit change, the second `snapshot` failed on the leftover. After: passes. - `torchswap::tests::a_record_line_naming_no_entry_in_site_is_not_owned`. Before: owned entries included `"C:"`, `"..."`, `". "` and `".. "`. After: passes. The test only reads, so running it against the old code was safe. - `torchswap::tests::a_manifest_line_that_names_no_entry_is_ignored` (manifest lines `...` and `. `). Before: `restore` removed the test's site-packages and then failed. After: passes, CPU torch and the neighbour package are intact. It works in a temp dir, so the red run could only delete that. - `tests::a_missing_torchvision_beside_cpu_torch_is_repaired` and the extended `tests::the_repair_decision_reads_the_installed_tree`. The helper's signature changed to take `Option<&str>`, so the old code cannot compile these; the old behaviour is visible in the removed `let (Some(torch), Some(vision)) = ... else { return "ok" }`. - The manifest test has no `C:` line on purpose: against the old code it would make `restore` delete from the drive's current directory, so a red run is not safe on any machine. The same `read_dir` rule covers it. - Full `cargo test`: 209 passed, 0 failed, 2 ignored. `cargo fmt --check` and `cargo clippy --tests -- -D warnings` clean. - No Python, frontend or i18n change. No new control, so no Guide entry. ## Update path - This changes the shell binary only. `uv.lock` is unchanged, so the `runtimeId` gate is not affected and existing installs receive it through the in-app update. ## What I have to check by hand On a Windows machine with an NVIDIA GPU, using a throwaway install of the desktop build: 1. Run setup so the CUDA torch installs and verifies. Confirm `setup.log` says `verified` and that `.stemdeck-torch-backup` next to `site-packages` is gone. 2. On a CPU install, delete `site-packages/torchvision` and `torchvision-*.dist-info`, then launch. Confirm `setup.log` says `torchvision is missing beside torch ...; installing torchvision==...+cpu`, and that the karaoke split works afterwards. 3. Launch again and confirm no second repair runs.
registry.persist fsynced registry.json while holding the registry lock, and async routes and pipeline coroutines called it inline. Every save froze the event loop for the length of the write, and every registry.get waited on the disk too: worst on a NAS or a Docker bind mount. The records are now snapshotted under the lock and serialised and written after it is released. A separate write lock covers snapshot and write together, so a later snapshot is never overwritten by an earlier one, and reset_all takes it too, so a save still writing cannot bring a reset library back. Async callers run persist through asyncio.to_thread. The shutdown flush in request_stop, the claim save in the queue worker and takes._commit stay synchronous on purpose. The pipeline thread added stage timings to job.stage_timings in place, without the lock, while a save serialised the same dict, which raised "dictionary changed size during iteration" and failed the request with a 500. runner._lap and the Demucs startup timing now write a new dict.
…hildren with the app A portable Windows package kept every file beside StemDeck.exe except the WebView2 profile, which Tauri put in %LOCALAPPDATA% (#399). With portable.txt next to the exe the main window now uses data/webview. Installer builds, macOS and Linux keep the default location. On Windows only the backend ran in a kill-on-close job, so closing the window during the CUDA torch install or the model warmup left pip or Python running, still writing into python/ or the model cache. Both now start through the same job helper, renamed spawn_in_job, in a shared run_setup_child that also keeps setup_child_pid for the Unix close path.
…ock (#896) ## What changes for the user Every save of the library (`registry.json`) used to freeze the server for as long as the disk took to write and fsync it. A favourite, a new version, a finished or cancelled import: each one stopped every progress stream and every other request until the write was done. Locally that is 16 to 24 ms; on a NAS or a Docker bind mount it can be much longer. The save now runs in a worker thread, so the rest of the app keeps answering while it writes. A rare 500 is also gone: a request that saved the library while an import was running could fail with "dictionary changed size during iteration". ## Why - `registry.persist` held the registry lock while it wrote and fsynced the file, so every `registry.get` (every request and stream) waited on the disk too. - Async routes and the pipeline coroutines called it inline on the event loop. - The pipeline thread added stage timings to `job.stage_timings` in place, without the registry lock, while a save serialised that same dict. ## What exactly changed - `app/core/registry.py` `persist`: the records are snapshotted under the registry lock; the JSON is built and written after it is released. A separate `_write_lock` covers snapshot and write together, so a later snapshot can never be overwritten by an earlier one. The atomic write itself (`_write_atomic`: unique temp name, fsync, `os.replace`) is unchanged. - `reset_all` clears the registry under `_write_lock` too, so a save still writing an older snapshot cannot recreate `registry.json` after Reset app data removed it. - `_prune_deleted` checks the tombstoned folders on disk before it takes the registry lock, not while holding it. - Async callers now use `await asyncio.to_thread(registry_persist, ...)`: the import, playlist, resplit, vocal split, versions, fill-stems, tags and lyrics routes in `app/api/jobs.py` and `app/api/playlist.py`, and the done, error, cancel, dropped-job and deferred-delete paths in `app/pipeline/runner.py` and `app/pipeline/jobqueue.py`. - `runner._lap` and the Demucs startup timing in `separate.py` replace `job.stage_timings` with a new dict instead of changing it in place. Left inline on purpose: - `jobqueue._worker_loop` saves the claimed job before it runs, with a comment that no await may open between the claim and the run. - `jobqueue.request_stop` is the shutdown flush from the app lifespan; it stays synchronous so it finishes before the process exits. - `takes._commit` is synchronous by design (no await inside a track-list read-modify-write) and also writes `tracks.json` on the loop; moving both is a separate change. Sync `def` routes already run in the threadpool and are unchanged. One visible timing change: after a stop or a finish, the job's new status is visible for the length of one registry write before the queue frees its running slot (the save now yields to the loop). A plain cancel already had this window since #892. Two tests that checked the slot the instant the status flipped (`test_queue_remove.py`, `test_trash_inflight.py`) now wait for the slot first, as `test_emptying_the_trash_while_the_stop_settles_leaves_nothing` already did; every assertion is kept. ## How it was verified New tests in `tests/test_registry_resilience.py`, red on `origin/big-release`, green with this change: - `test_the_write_does_not_hold_the_registry_lock`: the lock was held during the write. - `test_a_save_while_the_pipeline_records_a_timing_does_not_raise`: `RuntimeError: dictionary changed size during iteration` on the old code, 5 runs out of 5. - `test_a_timing_never_changes_a_dict_a_save_may_be_reading`. - `test_a_route_saving_the_registry_leaves_the_loop_free` and `test_the_pipeline_saving_the_registry_leaves_the_loop_free` (done, cancelled, dropped, emptied from the Trash): with a write slowed to 0.2 s, no other coroutine ran during it on the old code; now it does. - `test_a_later_save_is_never_overwritten_by_an_earlier_one`: passes on both, and fails if the write lock is removed (checked), so it guards write order. - `test_a_save_still_writing_cannot_undo_a_reset`: fails without the write lock (checked). End to end on a throwaway server: creating a version through the API lands in `registry.json`, with no temp file left behind. Full Python suite after rebasing on the current `big-release`: 3787 passed, 12 skipped. `ruff check` and `ruff format --check` are clean. Does not touch `uv.lock` or the desktop shell.
…hildren with the app (#897) Two Windows fixes in the desktop shell: a portable package now keeps its browser profile in its own `data/` folder, and closing StemDeck during a GPU install or the model download no longer leaves pip or Python running. ## What changes for the user - **Portable package (#399).** The WebView2 profile (the window's local storage and cache) used to go to `%LOCALAPPDATA%\app.stemdeck.desktop\EBWebView`, even though everything else stays beside `StemDeck.exe`. With `portable.txt` next to the exe it now lives in `data\webview\`. Installer builds, macOS and Linux keep the default location. - One-time effect: an existing portable user starts with an empty WebView2 profile, so what the page keeps in `localStorage` resets once: panel layout and collapsed sections, per-song mutes, the chosen input and camera devices, the measured recording latency per device, and a dismissed update notice. Songs, settings and `user-data.json` are not in that profile and are not affected. The old folder in `%LOCALAPPDATA%` is left as it is. - **Closing during setup.** On Windows only the backend ran in a kill-on-close job. pip (the CUDA torch install) and the model warmup did not, so closing the window mid-install left them running, still writing into `python\` or the model cache. Both now start in the same kind of job, which Windows closes when StemDeck exits in any way, and which ends them and anything they started. - The console window at launch is unchanged: it is intended. ## How - `desktop/src-tauri/src/main.rs`: `portable_webview_dir()` decides the profile folder from the existing `portable.txt` check; the main window builder sets it with `data_directory()` only when it returns a folder. - `run_setup_child()` replaces the spawn, record pid, wait, clear pid sequence that `spawn_pip` and `warmup_models` each repeated, and spawns through the job helper. The Unix close path (SIGTERM through `setup_child_pid`) is unchanged. - `desktop/src-tauri/src/backendjob.rs`: `spawn_backend_process` is renamed `spawn_in_job` now that three callers use it, and its log lines say "child process" instead of "backend". - `packaging/windows/README-WINDOWS.txt`: one line saying the browser storage is in `data\webview\`. - Side effect: the warmup child now starts with `CREATE_NO_WINDOW`, like pip and the backend already did. Its output is piped, so nothing visible changes. ## Verified - Red then green, `what_a_setup_child_starts_does_not_outlive_it` (Windows): a setup child starts its own child that writes a file two seconds later. With the old plain spawn the file appears (fails: "a process the setup child started outlived it"); through the job it never appears (passes). - Red then green, `a_portable_package_keeps_its_webview_profile_in_data`: fails while no profile folder is chosen (left `None`), passes with the change. `an_installed_app_keeps_the_default_webview_profile` passes in both. - `cargo fmt --check`, `cargo clippy --tests -- -D warnings` and `cargo test` (212 passed, 2 ignored) on Windows. The Linux and macOS checks run in PR CI only. ## Shell binary and the update path This changes the desktop binary. It does not touch `uv.lock`, so the in-app updater still offers it. ## Needs a check by hand on Windows The tests cannot see a real WebView2 profile or a real close, so on a portable build: 1. Rename `%LOCALAPPDATA%\app.stemdeck.desktop` aside, launch `StemDeck.exe`, and confirm `data\webview\EBWebView` appears and `%LOCALAPPDATA%\app.stemdeck.desktop` is not recreated. 2. In the app, change something kept in browser storage, quit and relaunch, and confirm it stayed. 3. On an NVIDIA machine, close the window while the CUDA torch install runs, and confirm in Task Manager that no `python.exe` or `pip` remains. 4. Close the window during the model download step of setup, and confirm no `python.exe` remains.
…e background
GET /api/jobs/{id} measured the loudness of a song analysed before
LOUDNESS_VERSION 2 inline: a full-song ffmpeg run (3.4 s for a 4 minute
song here, up to TIMEOUT_ANALYZE) under a process-wide lock, then a
registry save. The first open of every old song waited for it behind the
loading overlay.
The route now answers with the stored numbers and queues the song for one
background thread, which measures one song at a time, writes each result
to metadata.json, and saves the registry once when its queue is empty.
The next open shows the new numbers.
The work is bounded so it never competes with separation: at most
LOUDNESS_UPGRADE_QUEUE_MAX (8) songs wait, nothing is queued or started
while a separation runs, and a song left out is measured on a later open.
A failed measurement is still tried once per run.
…n disk MP3 storage renamed the MP3s into place and unlinked the WAVs with no fsync, so a power cut in the writeback window could leave empty MP3s with the WAVs gone. Each temp MP3 is now fsynced after it is verified, and the stems folder after the renames, before any WAV is deleted (F22). original.wav, mix.wav and the lead/backing split were written at their final names, so a kill could leave a truncated file that recovery takes for a whole stem, and a failed split left a lone lead_vocals.wav. ffmpeg now writes `.<name>.<hex>.tmp` and the file is renamed in on success; the split worker writes into `.vocal_split.<hex>.tmp/` and the pair is moved in only when both exist, the first taken back if the second move fails (F28, writer half).
…h click render its own temp A stem fill, a vocal split decode, a stem written beside its final name, an export render and an all-stems zip each write to a temp name first. A kill or a power loss in the middle left that temp on disk for good, and each can be hundreds of MB. The startup sweep now removes them: only in StemDeck's own song folders, never through a link or junction, never a file a song keeps, and in the export caches only by the exact temp names, and only when nothing has written them for ten minutes (a second server may share the cache). The decode and the zip, with its transcode folder, moved out of the system temp folder (beside the stem, and into the export cache) so the sweep never has to delete in a folder other programs share. A cancelled zip request (the server stopping) skipped the cleanup, because CancelledError is not an Exception, and the build thread went on writing. The zip now goes once the request has given it up and the thread has ended. Two exports rendering the same click at once shared one temp name, so one could rename the other's file away and lose its click. Each render now has its own uuid dot name, removed in a finally.
…n disk (#898) Stem audio is now written whole or not at all, and the WAVs are only deleted once their MP3s are safely on the disk. ## What changes for the user Nothing visible on a normal run. After a power cut or a killed app: - An MP3 song no longer comes back with empty MP3 stems and its WAVs already gone. - A song no longer comes back with a truncated `original.wav`, `mix.wav`, `lead_vocals.wav` or `backing_vocals.wav` that looks like a finished stem. - A failed Lead + Backing split no longer leaves a lone `lead_vocals.wav` behind. ## Why - **MP3 storage** (`app/pipeline/stem_storage.py`): the MP3s were renamed into place and the WAVs unlinked with no fsync. The rename can reach the disk before the data does, so a power cut in that window of a few seconds lost the stem. Each temp MP3 is now fsynced after it is verified, and the stems folder is fsynced after the renames, before any WAV is unlinked. Same in `store_split_stems`. `fsync_dir` keeps its existing Windows guard (NTFS journals the rename; Windows cannot open a folder for fsync). - **original.wav and mix.wav** (`app/pipeline/collect.py`): ffmpeg wrote the final name directly. It now writes a dot temp name in the same folder, and `os.replace` moves it in only when ffmpeg succeeded. A failed or cancelled ffmpeg removes the temp file. - **Lead + Backing split** (`app/pipeline/vocal_split.py`): the worker wrote straight into `stems/`. It now writes into a dot temp folder inside `stems/`; the pair is moved in only after both files exist, and if the second move fails the first is removed. The temp folder is removed on every exit path that runs Python code. ## Temp names (for the startup sweep) Every new temp name starts with a dot and ends in `.tmp`, with a 32 character hex uuid before `.tmp`, always directly inside a song's `stems/` folder: | Writer | Temp name | Kind | |---|---|---| | `make_original_track` | `stems/.original.wav.<32 hex>.tmp` | file | | `make_selected_mix` | `stems/.mix.wav.<32 hex>.tmp` | file | | `split_vocals` | `stems/.vocal_split.<32 hex>.tmp/` | folder (holds `lead_vocals.wav`, `backing_vocals.wav`) | One pattern matches all three: `^\.[\w.-]+\.[0-9a-f]{32}\.tmp$` inside `stems/`. The folder needs `rmtree`, the files `unlink`. Only a hard kill (or power cut) leaves one behind; this change does not touch the startup sweep. The MP3 storage temps keep their existing name, `stems/.<stem>.<32 hex>.tmp.mp3`. ## How it was verified Tests written first, red on `big-release`, green with the change: - `tests/test_stem_storage.py::test_mp3s_and_the_folder_reach_the_disk_before_the_wavs_go` and `::test_split_mp3s_and_the_folder_reach_the_disk_before_the_wavs_go`: spy `os.fsync` and `fsync_dir`, assert one fsync per MP3 and one folder fsync, all while every WAV still exists. Before: no fsync at all. - `tests/test_pipeline_collect_mix.py::test_a_kill_before_the_rename_leaves_no_final_file` (mix and original): a crash raised from `os.replace` leaves no `mix.wav` / `original.wav`. Before: the final file already existed. - `tests/test_pipeline_vocal_split.py::test_a_failed_split_leaves_no_lead_vocals`: a worker that writes only the lead vocal and dies leaves `stems/` holding just `vocals.wav`. Before: `lead_vocals.wav` stayed. - `tests/test_pipeline_vocal_split.py::test_a_kill_before_the_rename_leaves_no_split_stem` and `::test_a_pair_that_cannot_be_moved_in_is_removed_whole`. - `tests/test_pipeline_collect_mix.py::test_a_failed_mix_leaves_no_temp_file` and `::test_a_finished_mix_leaves_only_the_final_file` guard the temp cleanup (they pass before and after). The kill tests also assert that the temp names match the pattern above. The mix and storage tests run real ffmpeg; the split tests run the real subprocess machinery against a stub worker. The full Python suite, `ruff check` and `ruff format --check` pass. Not run: a split with the real karaoke model. ## What this does not fix yet This is the first step of the complete fix for a song that comes back incomplete. The writers no longer leave a partial file under a final name. Recovery is still lenient: when `registry.json` is lost, `_rebuild_from_folder` (`app/core/registry.py:790-807`) rebuilds a finished song from any folder that has at least one stem. A kill partway through `collect()`, which moves the stems in one by one, can still come back as a "done" song that is missing stems. The same goes for a kill in the instant between the two split renames, which leaves `lead_vocals.wav` without `backing_vocals.wav`. The complete fix needs a marker, written last, that says which stems the song should have, and recovery has to check it. That changes how the #284 crash window is recovered (a complete folder with no metadata.json), so it belongs in its own PR. This change does not touch `uv.lock` or the desktop shell.
…e background (#899) Opening a song analysed before the loudness fix (LOUDNESS_VERSION 2) no longer waits for a full-song loudness measurement. The song opens with its stored numbers at once, and the new numbers are there on the next open. ## What changes for the user - The first open of an older song used to sit on the loading overlay while ffmpeg measured the whole mix: 3.4 s for a 4 minute song on this machine, up to TIMEOUT_ANALYZE (120 s) on a slow disk. - Now GET /api/jobs/{id} answers in about 30 ms with the stored loudness. The new reading lands in the background, in the song's metadata.json and the registry. ## Why `get_job` called `_upgrade_loudness` inline, which ran `measure_loudness` under a process-wide lock and then saved the whole registry, once per old song. The measurement belongs off the request path. ## How it works - `get_job` queues the song (`_queue_loudness_upgrade`) and returns at once. - One background thread measures one song at a time, writes each result to metadata.json as it comes, and saves the registry once when its queue is empty (the save from #896, safe to run beside other saves). - Bounded so it never competes with separation: - at most `LOUDNESS_UPGRADE_QUEUE_MAX` (8) songs wait; one past that is measured on a later open; - nothing is queued while a separation runs, and the thread re-checks before each song, so a separation that started since is not shared either. - A failed measurement is still tried once per run (`_LOUDNESS_TRIED`), as before. ## Verification - Red then green: `test_opening_an_old_song_does_not_wait_for_the_measurement` blocks the measurement on an event. On `big-release` the GET waited 5.0 s (the event timeout); with this change it returns at once with the stored numbers, and the new numbers land after release. - New tests: no measurement beside a separation (at queue time and after), queued songs share one registry save, a full queue leaves the song for a later open. The existing tests now wait for the background thread. 12 passed, five repeat runs with no flake. - End to end on a throwaway server and library (a 233 s song with mp3 stems, set back to an old reading): GET 1 took 0.031 s with lufs -30.0 (stored); GET 2 had lufs -9.0, loudness_version 2; metadata.json and registry.json both held the new numbers. - ruff check and ruff format clean. Full Python suite: 3789 passed, 2 failed in `test_cancellation_reach.py` on a missing executable on this Windows machine (WinError 2, unrelated code). After the review changes and the rebase, all 51 test files that call the job routes: 1601 passed. - Reviewed by the python and security reviewers. Applied: no queueing while a separation runs, the worker handle set only after the thread started, test cleanup of the queue. Kept as is: a measurement already running when a separation starts finishes (one ffmpeg run, bounded by TIMEOUT_ANALYZE); a crash before the batch save costs one re-measure on the next open, since metadata.json already has the numbers. No change to `uv.lock` or the desktop shell. No new control, so no Guide or i18n change.
…h click render its own temp (#900) ## What changes for the user - **Disk space after a crash comes back on the next start.** A stem fill, a vocal split decode, a stem written beside its final name, an export render and an all-stems zip each write to a temp name first. A force quit, a power loss or an OOM kill in the middle used to leave that temp on disk for good, often hundreds of MB, in a dot-named file nobody could find. The startup sweep now removes them. - **A cancelled zip export no longer leaves its zip behind.** Stopping the server during an all-stems zip skipped the cleanup (`CancelledError` is not an `Exception`), and the build thread went on writing. - **Two exports with the same click no longer lose one click.** They shared one temp name, so the first rename could take the other's file away. ## What the sweep may remove, and what it never touches - Song folders: only those `registry.job_dirs` and `holds_job_files` accept (the rule from #886), and never through a link or junction (`is_real_entry`). - `.fill-<8 hex>/` (stem fill work folder) - in `stems/`: `.decode-*/` (vocal split decode), and dot names ending in `.tmp` or `.tmp.mp3`, file or folder (stem writes) - Export caches (`cache/mixdown`, `cache/click`): only the exact temp names (`.<name>.<32 hex>.tmp[.ext]`, `.stemdeck_zip_*.zip`, `.zipwork-*/`), and only when nothing has written them for 10 minutes, because a second server can share the cache folder. - It runs once, at startup, before the queue starts anything, like the existing section workspace and render temp sweeps. - A song's stems, MP3s, peaks, beats, source, metadata and takes, finished or stopped, never match any of these names. ## Other changes that make the sweep exact - The vocal split decode moved from the system temp folder to a dot folder beside the stem. - The zip, and the folder its stems are transcoded in, moved from the system temp folder to the export cache. - The sweep therefore never deletes in a folder that other programs share. - The zip temp now goes once the request has given it up and the build thread has ended, whichever comes second. On Windows, removing it sooner fails while the thread holds the file. - Each click render gets its own uuid dot name, removed in a `finally`. - `tests/conftest.py` now points the export caches at a per-test folder. Every TestClient lifespan runs the sweep, and it must never reach a developer's real cache. ## How it was verified - New tests in `tests/test_crash_leftovers.py`. All 4 fail on `big-release` and pass with this change: - Startup removes every planted leftover and keeps every file of a finished song and of a stopped song, plus the cache entries, a recent cache temp, a `.DS_Store`, and folders that are not StemDeck's. - The sweep never follows a junction: neither a linked song folder nor a linked `stems/`. - A zip request cancelled mid-build: the file stays while the thread writes, and is gone once the thread ends. It is built in the export cache. - Two renders of one click key: on `big-release` the second one fails with `FileNotFoundError`; with this change both finish. - Full Python suite, `ruff check` and `ruff format --check` pass. - End to end on a throwaway server (Windows, uvicorn, throwaway jobs, data and cache folders): - 8 planted leftovers were gone after startup, and none of the 22 song files was lost. - WAV and MP3 zips downloaded correctly, with nothing left in the cache. - A client that disconnected after the first bytes left nothing either: uvicorn still runs the response's background cleanup. - Merges cleanly with the open atomic stem writes branch, and its `.vocal_split.<hex>.tmp` work folder is covered. ## Update path - Does not touch `uv.lock` or the desktop shell.
restore() rebuilt a "done" song from any folder holding one stem, so a process killed during collect(), before original.wav or mix.wav, or in the middle of MP3 storage came back as a finished song with files missing. A kill between the two vocal-split renames brought back a lead vocal without its backing. The last write of a separation is now stems/.complete, after every stem file and the folder are fsynced, and recovery rebuilds a folder as done only when it has the marker. A folder without it goes back in the queue, or comes back as a stopped upload, and its files are kept. Songs from before the marker get it at startup while the registry holds them as done; one whose record was already lost is still rebuilt by its metadata.json. A rebuilt song counts as split only when both halves of the pair are there.
…ed (#901) ## What changes for the user A song whose separation was cut off part way (the app killed, the PC losing power) no longer comes back after a restart as a finished song with stems, the Original track or the mix missing. - A job that was running comes back in the queue and runs again from the start, as a job with no stems on disk already did. - If registry.json was lost as well, an upload comes back as a stopped song, so Extract runs it again from the file it kept. A link's folder stays on disk, untouched, and is not shown as finished. - A song rebuilt from its folder counts as split into Lead + Backing only when both files are there. A kill between the two renames used to bring back a lead vocal without its backing; the lone file stays on disk and the next split replaces it. ## Why restore() rebuilds a song from its folder when the registry says the job was still running, or has no entry for it. It took any folder holding one stem for a finished song. collect() moves the stems in one at a time, and original.wav, mix.wav and the MP3 storage come after it, so a kill at any of those steps returned an incomplete song as "done". #898 made each file arrive whole; this makes the set of files whole. ## How - The last thing a separation writes is `stems/.complete`, after every stem file and the folder are fsynced (`registry.mark_stems_complete`, called at the end of both pipelines). The marker goes through the existing atomic writer: fsynced temp file, rename, folder fsync. - `_rebuild_from_folder` rebuilds a folder as done only when it has the marker. - Songs made before this change: on startup, every song the registry holds as done gets the marker if its folder has none (only the marker is written, the stems are left alone). A song whose registry record was already gone has metadata.json, which StemDeck writes once a song is done (and now only after the marker), and is rebuilt exactly as before. Nothing is hidden, moved or deleted. Songs made by 0.20.0 after a downgrade get the marker on the way back, and 0.20.0 ignores the file. - If the marker cannot be written (a full disk), the job still finishes and the error is logged: failing the job would delete stems that are whole, and the song then rebuilds by its metadata.json like an older song. - Since the marker is the last write, a kill during the lyrics or section analysis after separation now runs the job again, where the old code kept the stems. That is the cost of "never done unless finished". ## Verified - `tests/test_stem_completeness.py` runs the real pipeline steps that write `stems/` (collect, original.wav, mix.wav, MP3 storage) and kills the process at eight points: one stem moved, before original.wav, before mix.wav, after mix.wav, before stem storage, one MP3 moved in, one WAV deleted, after stem storage. Each is checked with the registry kept (job queued again) and lost (the upload comes back stopped with its source). On the old code every one of these came back "done", and the split case listed a lone lead vocal. The finished run is rebuilt as done both ways, and a song from before the marker gets it from the registry. - A real separation on a throwaway server (MP3 storage on): `stems/.complete` is there, and after deleting registry.json a restart brings the song back done with every stem and the mix. - Marker cost, measured on this machine's SSD: 0.14 s for eight 40 MB stems written just before it, 0.004 s when they are already on disk. Not measured on a hard disk or a network share. - Tests whose folders stand for a finished song made by this version now carry the marker. ruff check and ruff format pass. Full Python suite: 3819 passed, 12 skipped; after the rebase onto #899 and #900 the 247 tests of the affected files pass again. Does not touch `uv.lock` or the desktop shell.
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.
What this is
The whole of 0.21.0 on one branch: 216 commits ahead of
main. There is nonext-releasebranch this time, so every fix PR went intobig-releaseand this pull request goes straight tomain. It stays a draft until I have built and tried it on every desktop platform.Merging it publishes the Docker
:edgeimage, so I will merge it at release time only. Please merge with a merge commit, not a squash, so the per-feature history and theCloseslines in the commit messages survive.Read this first: every desktop install does one full download for 0.21.0. The Python dependencies changed (a security update, and Python 3.11 is now the minimum), so the runtime id changes and the in-app updater will not offer this release. The new download carries a fresh runtime. See "Dependencies" below.
What is in it
Modes
Karaoke
Performance
Studio
Click track
Stem storage: a change in default behaviour
STEMDECK_STEM_FORMATandSTEMDECK_STEM_MP3_KBPS.Imports, library and playback
Desktop
STEMDECK_WEBKIT_DMABUF=on|offoverrides it.Hardening
Dependencies
certifi,pillowandfilelockare updated, together withurllib3,anyio,fsspecandhydra-core, and the matching entries are removed from.trivyignore. The security scan passes without them.requires-pythonis now>=3.11; every shipped build already used 3.12, and Python 3.10 is no longer supported.uv.lock; the lock is what CI tests and the scan reads, and it sets the runtime id.Licence
Fixed since the first version of this description
#771, #772, #773, #774, #775, #778, #780, #781, #783, #785, #788, #789, #791, #793, #794, #797, #798, #799, #803, #810, #812, #814, #824, #827, #804, #832, #833 and #842 are fixed or answered, each checked against its tests. #805 (click file and sounds) is fixed here and closes when this merges. #790 is fixed on the Python side; its Windows part is not run yet (see below). The README is updated; the release notes are written at release time.
How it was checked
5530a0b).pytest3402 passed, 16 skipped (the OGG tests skip where the FFmpeg has no Vorbis encoder). Last full run on Windows, 2026-10-11, on an earlier commit: 3178 passed, 5 skipped, 0 failed.Measured earlier on my machine, not repeated on the current tip:
What is not tested yet
/usr/local/bin.backendjob.rs, part of Cancelling, deleting or quitting during a video export does not always stop it #790).Open before the release
Notes for review
main.rs, newmediaperm.rs,backendjob.rsanddmabuf.rs, the macOSInfo.plistandEntitlements.plist,Cargo.tomlandCargo.lock).uv.lockchanged, so the runtime id changed (py3.12-585d1341908f343b): existing desktop installs are not offered the in-app update and download the full package once.