Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9d33a76
ci: stop fork pull requests running on the self-hosted runners
thcp Aug 31, 2026
5286c05
fix(macos): restore directory deferral lost in the AppleDouble extractor
thcp Aug 31, 2026
19a72b4
fix(settings): stop an interrupted write destroying the user's settings
thcp Aug 31, 2026
261da3b
fix(registry): free a job cancelled in the pop-to-claim window, and b…
thcp Aug 31, 2026
3dbfca5
fix(sse): stop connection slots leaking when a client disconnects early
thcp Aug 31, 2026
017ab25
fix(api): bound request input that could exhaust memory or stall the …
thcp Aug 31, 2026
b20818e
fix(separate): tear the worker down on any failure, and honour cancel…
thcp Aug 31, 2026
b11f22f
fix(pipeline): let cancellation reach the processes it is meant to stop
thcp Aug 31, 2026
53ef956
ci: stop fork pull requests running on the self-hosted runners (#522)
thcp Aug 31, 2026
8a4562a
fix(macos): restore directory deferral lost in the AppleDouble extrac…
thcp Aug 31, 2026
646e9e2
fix(settings): stop an interrupted write destroying the user's settin…
thcp Aug 31, 2026
cdb68fc
fix(registry): free a job cancelled in the pop-to-claim window, and b…
thcp Aug 31, 2026
a404c7f
fix(updater): only install app updates from our own release assets
thcp Aug 31, 2026
d23dc8b
fix(updater): only install app updates from our own release assets (#…
thcp Aug 31, 2026
845b5ac
fix(setup): drain child pipes while the child runs, not after it exits
thcp Aug 31, 2026
ebbdb44
fix(setup): drain child pipes while the child runs, not after it exit…
thcp Aug 31, 2026
1ab8c74
fix(linux): verify the FFmpeg download against a pinned checksum
thcp Aug 31, 2026
aaead5c
fix(linux): verify the FFmpeg download against a pinned checksum (#530)
thcp Aug 31, 2026
00beb40
fix(sse): stop connection slots leaking when a client disconnects ear…
thcp Aug 31, 2026
3a78a8d
fix(api): bound request input that could exhaust memory or stall the …
thcp Aug 31, 2026
d12b291
fix(separate): tear the worker down on any failure, and honour cancel…
thcp Aug 31, 2026
3e89b45
fix(pipeline): let cancellation reach the processes it is meant to st…
thcp Aug 31, 2026
fb23927
fix(player): drive playback through the audio engine, not the silent …
thcp Aug 31, 2026
2835c8f
ci: close four release-integrity gaps (#537)
thcp Aug 31, 2026
31ef8ac
feat(loop): adjust a loop region instead of redrawing it (#539)
thcp Aug 31, 2026
4e950d7
fix(library): stop deleted songs coming back (#540)
thcp Aug 31, 2026
4005ed5
fix(library): keep trashed songs out of the library, and give We Reco…
thcp Sep 1, 2026
e5cdaa0
Footer: scroll a loop bound, collapse everything at once, drop the Al…
thcp Sep 1, 2026
be1c496
test(desktop): stop the port test probing with options the code under…
Sep 1, 2026
b18dbff
fix(friends): stop hiding half of We Recommend behind a scrollbar
Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ jobs:
# would scan its bundled extractor files and flag false-positive
# secrets that ship inside third-party packages like yt-dlp).
- name: trivy fs
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
Expand All @@ -190,7 +190,7 @@ jobs:
skip-dirs: .venv,jobs
# Dedicated Dockerfile + compose static analysis (Trivy's IaC linter).
- name: trivy config
uses: aquasecurity/trivy-action@master
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: config
scan-ref: build/
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/linux-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ jobs:
clamscan --recursive --infected --bell /scan
echo "ClamAV scan completed successfully. No infected files reported."

# macos-release.yml asserts its assets exist before uploading; this did
# not. action-gh-release defaults fail_on_unmatched_files to false, so a
# missing updater asset published a release that looked fine and went
# green, and the in-app updater then 404'd for every installed user.
- name: verify every asset exists
if: github.event_name == 'release'
run: |
for f in \
dist/StemDeck-Linux-x64.tar.gz \
dist/StemDeck-Linux-x64.tar.gz.sha256 \
dist/StemDeck-Linux-x64.NVIDIA.tar.gz \
dist/StemDeck-Linux-x64.NVIDIA.tar.gz.sha256 \
dist/StemDeck-Linux-x64-app.tar.gz \
dist/StemDeck-Linux-x64-app.tar.gz.sha256 \
dist/StemDeck-Linux-x64-runtime-version.json; do
test -f "$f" || { echo "missing release asset: $f" >&2; exit 1; }
done

- name: upload artifacts
# Only attach to a real release; a manual test build has nothing to upload to.
if: github.event_name == 'release'
Expand All @@ -150,6 +168,7 @@ jobs:
# pushing :latest to GHCR, and makes the in-app updater offer a build
# that was never verified.
prerelease: ${{ github.event.release.prerelease }}
fail_on_unmatched_files: true
files: |
dist/StemDeck-Linux-x64.tar.gz
dist/StemDeck-Linux-x64.tar.gz.sha256
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/macos-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ concurrency:

jobs:
check:
# Never run a fork's code on the self-hosted runner. cargo build/clippy/test
# all execute whatever the PR supplies -- build.rs, proc-macro crates, a
# swapped Cargo.toml dependency, the test bodies themselves -- and this
# runner is the same machine that builds, signs and uploads every macOS
# release. Nothing here cleans the workspace, so an implant in ~/.cargo,
# ~/.rustup or the persistent _work tree would survive into the next
# release. permissions: {} limits the token, not code execution.
#
# GitHub's public-repo default only gates *first-time* contributors, so one
# trivial merged PR is enough to unlock this for a later one. Fork PRs get a
# maintainer-triggered workflow_dispatch run instead.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
# Runner must be darwin/arm64 with Xcode CLT and rustup (same requirements
# as macos-release.yml, which this intentionally does not replace -- this
# only builds/checks, never signs, packages, or uploads anything).
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/windows-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ concurrency:

jobs:
check:
# Never run a fork's code on the self-hosted runner -- see the same guard in
# macos-check.yml. cargo build/clippy/test execute whatever the PR supplies
# (build.rs, proc-macro crates, a swapped Cargo.toml dependency, the test
# bodies), nothing here cleans the workspace, and this runner also builds
# the Windows release. Fork PRs get a maintainer-triggered
# workflow_dispatch run instead.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
# Runner must have rustup and the MSVC toolchain (same requirements as
# windows-release.yml, which this intentionally does not replace -- this
# only builds/checks, never packages or uploads anything).
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/windows-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ jobs:
}
Write-Host "ClamAV scan completed successfully. No infected files reported."

# See the same guard in linux-release.yml: action-gh-release silently
# tolerates missing files, so an absent updater asset shipped a green
# release the in-app updater could not use.
- name: verify every asset exists
shell: powershell
run: |
$required = @(
"dist/StemDeck-Windows-x64.NVIDIA.zip",
"dist/StemDeck-Windows-x64.NVIDIA.zip.sha256",
"dist/StemDeck-Windows-x64.zip",
"dist/StemDeck-Windows-x64.zip.sha256",
"dist/StemDeck-Windows-x64-app.zip",
"dist/StemDeck-Windows-x64-app.zip.sha256",
"dist/StemDeck-Windows-x64-runtime-version.json"
)
foreach ($f in $required) {
if (-not (Test-Path $f)) { Write-Error "missing release asset: $f"; exit 1 }
}

- name: upload artifacts
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
Expand All @@ -101,6 +120,7 @@ jobs:
# pushing :latest to GHCR, and makes the in-app updater offer a build
# that was never verified.
prerelease: ${{ github.event.release.prerelease }}
fail_on_unmatched_files: true
files: |
dist/StemDeck-Windows-x64.NVIDIA.zip
dist/StemDeck-Windows-x64.NVIDIA.zip.sha256
Expand Down
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,20 @@ Drop in an MP3, WAV, FLAC, OGG/Opus, MP4, or M4A file, or paste a YouTube URL, a

StemDeck is free and **does not accept any money, sponsorship, or funding** from anyone listed below. I share these makers and artists and communities purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️

| Name | What they do | Link |
|---|---|---|
| Analog4Lyfe | All-analog music gear, no digital shortcuts | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |
| r/bass | My beloved bass community on reddit | [r/Bass](https://www.reddit.com/r/Bass) |
| Beltr | Turns the songs you already own into karaoke gold, right on your own machine, no subscription, no cloud, just you and the mic | [beltr.app](https://beltr.app/) |
| Dlima Guitars | Custom guitars and basses, built one at a time | [@dlimaguitars](https://www.instagram.com/dlimaguitars) |
| Empress Effects | Boutique effects pedals for tone chasers who don't settle | [empresseffects.com](https://empresseffects.com) |
| Joao Gaspar | Producer and film scorer, also plays as a touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
| Kris Luthier | Hand-repairs and restores instruments in Lisbon, one careful fix at a time | [@krisluthier](https://www.instagram.com/krisluthier) |
| Lisbon Guitar Works | Guitars built by hand in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) |
| More Notes Less Talk | Instruments and gear with personality, recorded raw to tape. No hype, no gatekeeping. | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) |
| Seratone | Turns any TV into a studio-grade karaoke stage | [seratone.audio](https://seratone.audio/) |
| slashCAM | German-language camera and video tech: hands-on tests, industry news, and the post-production details most reviews skip | [@slashcam.de](https://www.instagram.com/slashcam.de) |
| Thomann | One of Europe's largest music gear retailers, practically everything a musician could need | [@thomann.music](https://www.instagram.com/thomann.music) |
| Category | Name | What they do | Link |
|---|---|---|---|
| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) |
| Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) |
| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) |
| Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) |
| Music Gear | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |
| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) |
| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) |
| Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) |
| Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) |
| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) |
| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) |


---
Expand Down
43 changes: 41 additions & 2 deletions app/api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@ def release_sse_slot() -> None:
_sse_active -= 1


class SseSlot:
"""One held connection slot, released exactly once.

Claiming has to happen in the handler so that hitting the cap can still be
answered with a 503 -- once the generator is running the response headers
have gone out and there is no status code left to send.

That is what leaked slots: release lives in the stream's `finally`, and an
async generator that is never started never runs its `finally`. If the
client disconnects before the body begins, StreamingResponse raises inside
`stream_response` on its first `send()` -- before `__anext__` is ever
called -- so the generator body never executes and the slot was held
forever. 200 of those and every progress stream 503s with nothing actually
connected, until the process restarts (#513).

The stream releases on its way out as before; `__del__` is the backstop for
the never-started case, where collecting the generator collects the closure
holding this. Release is idempotent so the two cannot double-count.
"""

__slots__ = ("_held",)

def __init__(self) -> None:
# Set first: claim_sse_slot raises at the cap, and __del__ still runs on
# a half-built object. Without this it would raise AttributeError from
# __del__ instead of releasing nothing.
self._held = False
claim_sse_slot() # may raise 503; nothing is held if it does
self._held = True

def release(self) -> None:
if self._held:
self._held = False
release_sse_slot()

def __del__(self) -> None:
self.release()


@router.get("/jobs/{job_id}/events")
async def job_events(job_id: str) -> StreamingResponse:
"""Server-Sent Events stream of job state updates. Closes when the job
Expand All @@ -47,7 +86,7 @@ async def job_events(job_id: str) -> StreamingResponse:
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
claim_sse_slot()
slot = SseSlot()

async def stream() -> AsyncIterator[str]:
try:
Expand Down Expand Up @@ -82,7 +121,7 @@ async def stream() -> AsyncIterator[str]:
keepalive_at = 0
await asyncio.sleep(0.2)
finally:
release_sse_slot()
slot.release()

return StreamingResponse(
stream(),
Expand Down
51 changes: 43 additions & 8 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from app.core.registry import all_jobs as registry_all_jobs
from app.core.registry import get as registry_get
from app.core.registry import get_proc as registry_get_proc
from app.core.registry import mark_deleted as registry_mark_deleted
from app.core.registry import pending_count as registry_pending_count
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
Expand Down Expand Up @@ -110,14 +111,29 @@ def _copy_to_dest(src_file: object, dest: Path) -> None:
shutil.copyfileobj(src_file, out) # type: ignore[arg-type]


def _rmtree_job(job_id: str) -> None:
def _rmtree_job(job_id: str) -> bool:
"""Remove a job's directory. False means files are still on disk.

The outcome used to be swallowed, so delete_job dropped the registry entry
whether or not anything was actually deleted -- and restore() then adopted
the surviving directory on the next start, which is how deleted songs came
back (#521).

Retried once: on macOS the common failure is Finder or Spotlight creating
a .DS_Store between rmtree's scan and its final rmdir, which leaves
"Directory not empty" on a directory that is about to be empty again."""
job_dir = JOBS_DIR / job_id
if not job_dir.is_dir():
return
try:
shutil.rmtree(job_dir)
except Exception:
logger.warning("failed to remove job dir %s", job_dir, exc_info=True)
for attempt in (1, 2):
if not job_dir.is_dir():
return True
try:
shutil.rmtree(job_dir)
return True
except Exception:
logger.warning(
"failed to remove job dir %s (attempt %d)", job_dir, attempt, exc_info=True
)
return not job_dir.is_dir()


def _job_files_missing(job: Job) -> bool:
Expand Down Expand Up @@ -326,6 +342,16 @@ def cancel_job(job_id: str) -> dict:
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status in ("done", "error", "cancelled"):
# A vocal split only ever runs on a done job, so this early return made
# it uncancellable by construction: the flag was never even set, while
# the split held _pipeline_lock and stalled the whole import queue for
# its full duration (#519). Terminating the worker is enough -- the
# split's own error path marks it failed and releases the lock.
if job.vocal_split == "running":
job.cancel_requested = True
proc = registry_get_proc(job_id)
if proc is not None and proc.poll() is None:
proc.terminate()
return job.to_state()
job.cancel_requested = True

Expand Down Expand Up @@ -759,7 +785,16 @@ def delete_job(job_id: str) -> dict[str, str]:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in ("done", "error", "cancelled"):
raise HTTPException(status_code=409, detail="job is still running")
_rmtree_job(job_id)
removed = _rmtree_job(job_id)
# Recorded whether or not the files went away. The user asked for this job
# to be gone; without the record, a directory that outlived the delete is
# re-adopted by restore() on the next start and the track reappears.
registry_mark_deleted(job_id)
registry_remove(job_id)
registry_persist(JOBS_DIR)
if not removed:
raise HTTPException(
status_code=500,
detail="Removed from the library, but its files could not be deleted.",
)
return {"job_id": job_id, "status": "deleted"}
6 changes: 3 additions & 3 deletions app/api/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from app.api.events import _MAX_SSE_SECONDS, claim_sse_slot, release_sse_slot
from app.api.events import _MAX_SSE_SECONDS, SseSlot
from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_UPLOAD_JOBS, MAX_PENDING_URL_JOBS
from app.core.registry import get as registry_get
from app.core.registry import pending_count as registry_pending_count
Expand Down Expand Up @@ -130,7 +130,7 @@ async def queue_events() -> StreamingResponse:
outlives any individual job and is expected to stay open for the session,
so only the 4 h ceiling ends it.
"""
claim_sse_slot()
slot = SseSlot()

async def stream() -> AsyncIterator[str]:
try:
Expand All @@ -156,7 +156,7 @@ async def stream() -> AsyncIterator[str]:
keepalive_at = 0
await asyncio.sleep(0.25)
finally:
release_sse_slot()
slot.release()

return StreamingResponse(
stream(),
Expand Down
Loading
Loading