Skip to content

0.21.0: Karaoke, Performance, stems per mode, GPU export and hardening - #784

Draft
thcp wants to merge 349 commits into
mainfrom
big-release
Draft

thcp wants to merge 349 commits into
mainfrom
big-release

Conversation

@thcp

@thcp thcp commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

The whole of 0.21.0 on one branch: 216 commits ahead of main. There is no next-release branch this time, so every fix PR went into big-release and this pull request goes straight to main. It stays a draft until I have built and tried it on every desktop platform.

Merging it publishes the Docker :edge image, so I will merge it at release time only. Please merge with a merge commit, not a squash, so the per-feature history and the Closes lines 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

  • The Mode menu lists Practice, Advanced, Karaoke and Performance. Advanced, Karaoke and Performance carry an Experimental tag.
  • The Streaming placeholder is gone. Streamer is now called Performance.

Karaoke

  • Lyrics stage with word-by-word highlight, a queue you fill by dragging songs from the library, its own Transport panel, voice and Backing vocals switches, and a Backing track card.

Performance

  • Live camera and microphone or audio-interface input, an "I play" backing track, recording, a review window that plays the take with the song, and a Take Timeline with trim and zoom.
  • Export to MP4 through the server, with Show in folder on the desktop. 720p or 1080p at 24, 30 or 60 fps. No 4K. Upload and streaming are not part of this release.
  • The scene layers, the Templates tab and the settings gear were removed (they did nothing useful yet).
  • GPU video encoder: Auto uses a GPU encoder that passed a test and falls back to the CPU. Settings has a Video encoder choice and a Test encoders table.

Studio

  • Per-lane and master EQ (graphical, Bass, Guitar, Keys), Suggest EQ, a Master drawer with a loudness target and a true-peak limit.
  • Recording tracks, audio and video tracks, takes stored in take folders, and recording over the song.
  • Each mode keeps its own stem choice and saved mix of a song. A Stems popover chooses which stems get a lane, and says why when an Apply does nothing.
  • New Settings layout: sections on the left, a search box, a Library section and a Danger zone.
  • The Add button in Sections is greyed out, with a reason, when there is no free space for a new section. The Bar box in the grid toolbar shows two-digit values in full. At the desktop app's default 1280x820 window the bottom bar no longer scrolls out of sight: whole groups of controls wrap onto a second line instead.
  • The key counter always matches what you hear: it is restored from the song's saved state, including for songs saved by 0.20.

Click track

  • Tick Include click track and choose Export All Stems: the .zip gets a click file next to the stems, with the same length and start, every click on the beat. It follows your beat grid edits, accents and the half or double rate.
  • The click options have a sound selector: Classic (as before), Woodblock, Rimshot, Soft and Cowbell. The live click, the count-in and every export use the sound you pick. Custom samples are not part of this release.
  • Exported clicks are now 3 dB louder than in 0.20, so they match the click you hear in the app (the export used to lower it). If you set the click level by ear against an export, you may want to turn the click volume down a little. A count-in export of an MP3-stored song also comes out in stereo now, where it used to be mono.

Stem storage: a change in default behaviour

  • New songs are now stored as MP3 at 128 kbps by default, about a tenth of the disk space of WAV. Settings > Library > Stem files chooses WAV or MP3, and 128, 192 or 256 kbps. Docker and Unraid get the same choice as STEMDECK_STEM_FORMAT and STEMDECK_STEM_MP3_KBPS.
  • Songs you already have keep their format. Separation and analysis still run on WAV, and the stems are converted at the end, checked, and only then are the WAV files removed. If the conversion fails, the song is kept as WAV.
  • 128 kbps was a deliberate choice. Stems that are summed again carry some stacked coding noise, so 192 or 256 sound closer to the original, and WAV is exact.

Imports, library and playback

  • Versions of a song: extracting a song that is already in the library no longer overwrites it. It separates only the stems the song does not have yet, with the settings it was first separated with, and makes or opens a version. A version is a named choice of stems with its own saved mix, and all versions share one set of stems on disk. The library shows versions as rows under the song, and each can be opened, renamed and deleted. Songs you already have read as one version, and no stem file is rewritten.
  • A Detect lyrics switch, Try again for failed imports, a 12 ms look-ahead compensation on takes.
  • The Library lists songs oldest first, in the order you added them, the same as the Queue. Removing a song from the Queue only stops its extraction: the song stays in the Library with Extract, and nothing goes to the Trash.
  • Queue strings are translated in every language.
  • When Lead + Backing cannot be made, the app now says so, says why in one line, and offers Try again on the same song. The choice is shown as unavailable from the start when a part it needs did not load on that install.
  • Reloading a song no longer leaks audio memory, and changing a stem while playing no longer stops and rewinds.

Desktop

  • FFmpeg on macOS: the app no longer fails first launch when the FFmpeg it downloads has no Vorbis encoder, and it no longer installs an Intel-only FFmpeg where that cannot run. It uses a good FFmpeg you already have (Homebrew on Apple Silicon or Intel, MacPorts, your PATH), never overwrites one you placed in the data folder, and reports a clear next step if none works. OGG export is shown as unavailable when the FFmpeg has no Vorbis encoder; everything else works. This is a product change.
  • Linux CPU package: it shipped the CUDA build of torchvision, which broke the karaoke vocal split. The build is fixed and gated in CI, and existing 0.20 installs repair themselves on their next launch (one internet connection needed).
  • Linux on Wayland with an NVIDIA GPU: the app no longer closes at launch. It turns off WebKit's DMA-BUF renderer by itself where it needs to, falls back after a crash, and STEMDECK_WEBKIT_DMABUF=on|off overrides it.

Hardening

  • A cross-site guard on the server, safer auto-delete (favourites, songs with takes and non-song folders are skipped), a registry that survives a bad record.
  • Moving the library into a folder that already holds another library is refused instead of losing favourites and tags.
  • Settings endpoints answer a clear error, not a 500, when the body is not an object.
  • Delete and Backspace move a song to the Trash only when the song list has the focus.

Dependencies

  • certifi, pillow and filelock are updated, together with urllib3, anyio, fsspec and hydra-core, and the matching entries are removed from .trivyignore. The security scan passes without them. requires-python is now >=3.11; every shipped build already used 3.12, and Python 3.10 is no longer supported.
  • The desktop packages install from PyPI at build time rather than from uv.lock; the lock is what CI tests and the scan reads, and it sets the runtime id.

Licence

  • StemDeck is now AGPL-3.0-only. The licence text ships in every package and the image, and About names it.

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

  • CI on this branch runs lint, the Python tests, the browser suite in 3 shards, CodeQL, the security scan and the Rust checks on all three platforms, plus a Linux packaging check. All 19 checks pass on the current tip (5530a0b).
  • Latest full local run on macOS: pytest 3402 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.
  • MP3 stems decode to exactly the same length as the source in ffmpeg, libsndfile and Chromium (0 samples of lag). A real import on macOS finished as MP3 with the stems at 4 MB instead of 36 MB.
  • The click file has the same length as every other stem, and its 62 clicks land within a millisecond of the 62 beats on a real export.

Measured earlier on my machine, not repeated on the current tip:

  • Closed audio contexts per song reload went from 121 to 0, and the heap from 45 to 8.7 MB.
  • Recorded frame rate at 1080p30 went from 19.8 to 28.6 fps, and at 1080p60 from 22.3 to 47.2 fps (a fake camera).

What is not tested yet

  • Desktop builds on Windows, macOS and Linux with this code (save dialog, Show in folder, camera and microphone prompts, the updater, the Rust shell changes).
  • Apple Silicon without Rosetta and without an FFmpeg: the first launch and its error message. An Intel Mac with FFmpeg in /usr/local/bin.
  • A real Wayland and NVIDIA launch, and a broken 0.20 Linux CPU install repairing itself.
  • MP3 stems and the live click sounds in the Tauri WebKit builds (macOS and Linux) and Safari, and the Docker environment variables in a real container.
  • How each click sound sounds by ear.
  • The Windows kill-on-close job object that makes a quit stop a running export (backendjob.rs, part of Cancelling, deleting or quitting during a video export does not always stop it #790).
  • A real camera. Every sync and performance number uses a fake camera.
  • GPU encoding on Intel, AMD and VAAPI (GPU video encoding is untested on Intel, AMD, Linux and macOS hardware #792). VideoToolbox passes on one Mac.
  • The new Guide entries were checked by the help-mode spec only, not by hovering each control.

Open before the release

Notes for review

  • The Rust shell changed since v0.20.0 (main.rs, new mediaperm.rs, backendjob.rs and dmabuf.rs, the macOS Info.plist and Entitlements.plist, Cargo.toml and Cargo.lock). uv.lock changed, so the runtime id changed (py3.12-585d1341908f343b): existing desktop installs are not offered the in-app update and download the full package once.
  • Translations exist in all ten tables for the strings that shipped. They are not reviewed by native speakers.

Thales 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.
…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>
Comment thread scripts/encoder_matrix.py Fixed
Comment thread app/pipeline/video_encoder.py Fixed
Comment thread app/api/suggest.py Fixed
Comment thread app/api/suggest.py Fixed
Comment thread app/api/stems.py Fixed
Comment thread app/core/csrf.py Fixed
Thales 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.
thcp and others added 30 commits November 3, 2026 02:35
#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.
Brings in #885, #886 and #887. registry.py conflicted with #885: the record loop keeps its step that finishes a delete interrupted while the song was stopping, after the id check this branch adds.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants