diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2fe693a..4b905ac9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,11 @@ on: release: types: [published] +# No job writes to the repository, so the token gets nothing. Checkout of this +# public repository and the artifact upload work without it, as in the other +# workflows. +permissions: {} + env: UV_LINK_MODE: copy # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which @@ -25,7 +30,7 @@ jobs: container: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # diffq (a mandatory transitive dependency of audio-separator, #275) has # no prebuilt wheel for Python 3.11+ on Linux -- its last release only # ever shipped cp310 wheels -- so uv sync must compile it from source. @@ -37,13 +42,20 @@ jobs: - run: uv run ruff check app/ tests/ - run: uv run ruff format --check app/ tests/ - run: bash -n run.sh + # The retry wrapper the e2e installs depend on (#838). Here because this + # job runs on every pull request, whatever it changes. + - run: bash tests/ci/test_retry_sh.sh + # The Docker entrypoint's ownership pass (#994). This container runs as + # root, which the mixed-owner and symlink cases need, and sh here is + # dash, the same shell as the image. + - run: bash tests/ci/test_docker_reown_sh.sh test: runs-on: ubuntu-latest container: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # build-essential: see the matching comment in the lint job above. - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg build-essential - run: uv sync --frozen --all-extras @@ -54,49 +66,30 @@ jobs: container: image: node:20-alpine steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: for f in static/js/*.js; do node --check "$f"; done - run: for f in tests/js/*.test.mjs; do node "$f"; done - # Browser tests for static/js (#339). node --check above is a syntax parse; - # this drives the real UI against the real backend, including the Tauri code - # path, which is where #335 hid because it was invisible in a browser. - # Split three ways, each shard on its own runner (#686). The suite runs - # serially inside a shard -- workers: 1 in playwright.config -- because it - # shares one backend and one jobs directory, and several specs change server - # state. A shard is a separate machine with its own backend, so splitting - # here adds parallelism without adding sharing. Files stay whole, since - # fullyParallel is off, so tests that rely on order within a file still get - # it. Free on a public repository; each shard pays its own setup. + # Browser tests for static/js: the steps live in e2e.yml, shared with + # e2e-full.yml. Runs the whole suite, and only for a pull request into main, + # a push to main or a release. A pull request into any other base (such as + # big-release) skips it (#1024): the full run on every push to big-release + # and the nightly run (e2e-full.yml) catch a broken spec on the merge commit. + # Checks show as "frontend-e2e / frontend-e2e (1)" to "(3)" on those events. frontend-e2e: - runs-on: ubuntu-latest - strategy: - # One failing shard must not cancel the others: the report is only - # useful whole. - fail-fast: false - matrix: - shard: [1, 2, 3] - steps: - - uses: actions/checkout@v7.0.1 - - uses: actions/setup-node@v7.0.0 - with: - node-version: "20" - cache: npm - - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - run: uv sync --frozen --all-extras - - run: npm ci - - run: npx playwright install --with-deps chromium - - run: npx playwright test --shard=${{ matrix.shard }}/3 - - if: failure() - uses: actions/upload-artifact@v7.0.1 - with: - # Per shard: a second upload under the same name fails the job. - name: playwright-report-${{ matrix.shard }} - path: | - playwright-report/ - test-results/ - retention-days: 7 + if: github.event_name != 'pull_request' || github.base_ref == 'main' + # A new push to a pull request cancels that pull request's older e2e run. + # Anything else gets a group of its own (the run id), so a push to main or + # a release is never cancelled and never waits behind another run. This + # does not help a runner GitHub never assigns: re-run that job, or + # gh run cancel then gh run rerun --failed. + concurrency: + group: e2e-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + uses: ./.github/workflows/e2e.yml + with: + mode: full + base-ref: ${{ github.base_ref }} # The Linux installer only ever runs on a user's machine, so nothing else # would catch a regression in it. Runs on a real Linux image rather than the @@ -104,7 +97,7 @@ jobs: linux-installer: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: bash -n packaging/linux/install.sh - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils - run: shellcheck -S warning packaging/linux/install.sh @@ -115,7 +108,7 @@ jobs: container: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: uv tool install bandit - run: uv tool run bandit -r app/ -ll # fail on medium+ severity @@ -124,7 +117,7 @@ jobs: container: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: uv tool install pip-audit - run: uv pip compile pyproject.toml -o /tmp/requirements.txt # Ignored CVEs (review when upgrading torch or demucs): @@ -185,7 +178,26 @@ jobs: trivy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Installs Trivy from its GitHub release asset, checked against a pinned + # sha256, instead of letting trivy-action call setup-trivy. setup-trivy's + # install.sh downloads the binary from get.trivy.dev, so the scan failed + # (curl exit 6) as soon as that hostname stopped resolving. Bump the + # version and the sha256 together: the sha256 is the Linux-64bit line of + # trivy__checksums.txt in the same release. + - name: Install Trivy + env: + TRIVY_VERSION: 0.70.0 + TRIVY_SHA256: 8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9 + run: | + tarball="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + curl -fsSL -o "$RUNNER_TEMP/$tarball" \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/$tarball" + echo "$TRIVY_SHA256 $RUNNER_TEMP/$tarball" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/trivy-bin" + tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP/trivy-bin" trivy + echo "$RUNNER_TEMP/trivy-bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/trivy-bin/trivy" --version # Scans the source tree for: known CVEs in deps, leaked secrets, # and Dockerfile / compose misconfigurations. Skips .venv (it can # be left over from earlier steps in the shared workspace; trivy @@ -202,6 +214,7 @@ jobs: ignore-unfixed: true trivyignores: .trivyignore skip-dirs: .venv,jobs + skip-setup-trivy: true # Dedicated Dockerfile + compose static analysis (Trivy's IaC linter). - name: trivy config uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 @@ -210,3 +223,4 @@ jobs: scan-ref: build/ severity: HIGH,CRITICAL exit-code: '1' + skip-setup-trivy: true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 60e5a60b..939a2bdc 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,7 +5,15 @@ name: Docker Publish # (ghcr.io/stemdeckapp/stemdeck). The image keeps the default Linux x86_64 torch # wheel, which is the CUDA build -- so the same image runs on CPU by default and # uses the GPU automatically when started with `--runtime=nvidia` (see the -# Unraid template in templates/unraid/). +# Unraid template in templates/stemdeck.xml). +# +# A second image carries the ROCm build of torch for AMD GPUs (#825), with the +# same tags plus a -rocm suffix (:rocm where the default image gets :latest). +# It is a separate image rather than the default because the ROCm runtime is +# several GB more, which every CPU and NVIDIA user would otherwise pull for +# nothing. Each image is its own matrix leg: its own runner, disk and timeout, +# built in parallel, and fail-fast is off, so a ROCm failure can never stop or +# delay the default image. on: # Every merge to main publishes a rolling :edge image. The version is derived @@ -41,8 +49,28 @@ env: jobs: build-and-push: + name: build-and-push (${{ matrix.image }}) runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: ${{ matrix.timeout }} + strategy: + fail-fast: false + matrix: + include: + - image: default + edge: edge + suffix: "" + latest: latest + torch_index: "" + timeout: 60 + # Not measured yet: the ROCm wheels are about 3.6 GB on top of the + # CUDA stack the project install pulls first (torch<2.7 in + # pyproject.toml), so the leg gets twice the default's time. + - image: rocm + edge: edge-rocm + suffix: "-rocm" + latest: rocm + torch_index: https://download.pytorch.org/whl/rocm6.4 + timeout: 120 permissions: contents: read packages: write @@ -76,14 +104,22 @@ jobs: with: images: ${{ env.IMAGE }} tags: | - type=raw,value=edge,enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version == '') }} - type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version != '') }} - type=raw,value=latest,enable=${{ github.event_name == 'release' && (github.event.action == 'released' || (github.event.action == 'published' && !github.event.release.prerelease)) }} + type=raw,value=${{ matrix.edge }},enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version == '') }} + type=raw,value=${{ steps.ver.outputs.value }}${{ matrix.suffix }},enable=${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version != '') }} + type=raw,value=${{ matrix.latest }},enable=${{ github.event_name == 'release' && (github.event.action == 'released' || (github.event.action == 'published' && !github.event.release.prerelease)) }} + + # The ROCm wheels unpack to several GB on top of the CUDA stack the + # project install pulls first, more than a hosted runner has free. + - name: free disk space for the ROCm build + if: matrix.image == 'rocm' + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -94,11 +130,13 @@ jobs: with: context: . file: build/Dockerfile - # Unraid is x86_64; arm64 has no CUDA torch and is not a target. + # Unraid is x86_64; arm64 has no CUDA or ROCm torch and is not a target. platforms: linux/amd64 push: true + # Empty for the default image, which keeps the CUDA build. build-args: | VERSION=${{ steps.ver.outputs.value }} + TORCH_INDEX_URL=${{ matrix.torch_index }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} provenance: false diff --git a/.github/workflows/e2e-full.yml b/.github/workflows/e2e-full.yml new file mode 100644 index 00000000..c714dc64 --- /dev/null +++ b/.github/workflows/e2e-full.yml @@ -0,0 +1,46 @@ +# The whole Playwright suite on big-release, the integration branch. A pull +# request into big-release does not run Playwright (ci.yml), so this is where a +# broken spec shows up: within minutes of the merge that broke it, on that +# merge's commit. It never blocks a pull request. +# +# Triggers: +# - every push to big-release (each merged pull request); +# - nightly, on big-release; +# - by hand, on any branch: gh workflow run e2e-full.yml --repo stemdeckapp/stemdeck --ref +# (or -f ref= to test another ref with this branch's workflow). +# +# The schedule only fires from the default branch: GitHub reads this file, and +# the e2e.yml it calls, from main. Until this file reaches main there is no +# nightly run; pushes to big-release and manual runs work before that. + +name: e2e-full + +on: + push: + branches: [big-release] + schedule: + # 04:17 UTC, off the top of the hour when hosted runners are busiest. + - cron: "17 4 * * *" + workflow_dispatch: + inputs: + ref: + description: "Branch, tag or SHA to test (empty: the branch this was run on)" + type: string + default: "" + +permissions: {} + +# No concurrency group on purpose: a push to big-release, the nightly and a +# manual run are each a record of one commit, and none may cancel another. + +jobs: + full-suite: + # Forks inherit the schedule; only this repository runs it. + if: github.repository == 'stemdeckapp/stemdeck' + uses: ./.github/workflows/e2e.yml + with: + mode: full + # A scheduled run checks out the default branch (main) unless told + # otherwise, so the nightly names big-release explicitly. A push tests + # its own commit; a manual run tests the ref input, else its own branch. + checkout-ref: ${{ github.event_name == 'schedule' && 'big-release' || inputs.ref || github.sha }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..e4ee72f1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,271 @@ +# Browser tests for static/js (#339), as one reusable workflow. ci.yml calls it +# for pull requests into main, pushes to main and releases; e2e-full.yml calls it for every push to +# big-release, the nightly run and a manual run. The steps live only here. +# +# This drives the real UI against the real backend, including the Tauri code +# path, which is where #335 hid because it was invisible in a browser. +# Split three ways, each shard on its own runner (#686). The suite runs +# serially inside a shard -- workers: 1 in playwright.config -- because it +# shares one backend and one jobs directory, and several specs change server +# state. A shard is a separate machine with its own backend, so splitting +# here adds parallelism without adding sharing. Files stay whole, since +# fullyParallel is off, so tests that rely on order within a file still get +# it. Free on a public repository; each shard pays its own setup. + +name: e2e + +on: + workflow_call: + inputs: + mode: + description: "full runs every spec (every caller uses it today); scoped runs the set scripts/ci/select-e2e.mjs picks for the diff against base-ref" + type: string + required: true + base-ref: + description: "Branch a scoped run diffs against (the pull request's base)" + type: string + default: "" + checkout-ref: + description: "Ref to test; empty means the commit of the triggering event" + type: string + default: "" + +# The caller's token already has nothing; this keeps it that way if a new +# caller grants more. Checkout of this public repository and the artifact +# upload work without it. +permissions: {} + +env: + UV_LINK_MODE: copy + # See the matching comment in ci.yml (#169). + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" + +jobs: + frontend-e2e: + # Pinned, not ubuntu-latest: that label moves to Ubuntu 26 from + # 2026-10-19 (https://github.com/actions/runner-images/issues/14748), and + # the move is gradual, so two shards of one run could land on different + # images. The stalled runs in #838 were already on ubuntu-24.04; the cause + # there was the mirror, and the pin keeps the image from becoming a + # second variable. Revisit once that migration has finished and the suite + # has passed on 26.04; the cache keys below name the image, so moving it + # starts from clean caches. + runs-on: ubuntu-24.04 + # A full shard takes about 10 minutes, setup included. This bounds a hung + # run; it does not start a runner that GitHub never assigns, since queue + # time does not count toward it. The install steps below have their own, + # shorter limits, so a stalled install fails in minutes and names itself. + timeout-minutes: 30 + strategy: + # One failing shard must not cancel the others: the report is only + # useful whole. + fail-fast: false + matrix: + shard: [1, 2, 3] + env: + SHARD: ${{ matrix.shard }} + # ffmpeg for the backend under test: a static build from BtbN's GitHub + # releases, the builder the Windows desktop app already downloads from. + # A month-end autobuild, because BtbN keeps those for about two years + # and daily ones for about two weeks. The SHA256 matches both GitHub's + # asset digest and BtbN's checksums.sha256 for this release. Bump all + # three together. + FFMPEG_RELEASE: autobuild-2026-09-30-13-08 + FFMPEG_ASSET: ffmpeg-n8.1.3-9-g29e619e767-linux64-gpl-8.1.tar.xz + FFMPEG_SHA256: 97ce978979194b5cf7e06a5e68020dbdaa7a4f3294c5452b6a1bc347100dbb79 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref }} + # Full history, so the base branch is there to diff against. + fetch-depth: 0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "20" + cache: npm + # Empty output means everything. The inputs go through env, never into + # the script text. + - name: select specs + env: + MODE: ${{ inputs.mode }} + BASE_REF: ${{ inputs.base-ref }} + run: node scripts/ci/select-e2e.mjs --mode "$MODE" --base "$BASE_REF" > "$RUNNER_TEMP/e2e-specs.txt" + # Each setup step records its own duration for the job summary (#838). + - name: install uv + run: | + bash scripts/ci/setup-timing.sh begin "uv install" + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + bash scripts/ci/setup-timing.sh end "uv install" + - name: uv sync + run: | + bash scripts/ci/setup-timing.sh begin "uv sync" + uv sync --frozen --all-extras + bash scripts/ci/setup-timing.sh end "uv sync" + - name: npm ci + run: | + bash scripts/ci/setup-timing.sh begin "npm ci" + npm ci + bash scripts/ci/setup-timing.sh end "npm ci" + # Cache keys: the Playwright version the lockfile resolves (it decides + # the browser build and the system packages), and the runner image + # version (it decides which .deb versions apt wants). + - name: cache keys + id: keys + run: | + pw="$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version")" + echo "playwright=$pw" >> "$GITHUB_OUTPUT" + echo "image=${ImageOS:-unknown}-${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT" + - name: restore Playwright browsers + id: browsers-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-browsers-ubuntu-24.04-${{ steps.keys.outputs.playwright }} + # The browser download alone, from Playwright's CDN: about 10 s, and + # nothing on a cache hit. The system packages are a separate step, so a + # cached browser is not held up by apt. 3 attempts of at most 150 s plus + # 30 s of pauses fit inside the step's 10 minutes. + - name: Playwright browsers + timeout-minutes: 10 + env: + CACHE: ${{ steps.browsers-cache.outputs.cache-hit == 'true' && 'hit' || 'miss' }} + run: | + bash scripts/ci/setup-timing.sh begin "Playwright browsers" + bash scripts/ci/retry.sh --timeout 150 -- npx playwright install chromium + bash scripts/ci/setup-timing.sh end "Playwright browsers" "$CACHE" + # One shard saves, so the other two do not race it for the same key. + # Saved here rather than at the end of the job, so a failing spec does + # not cost the next run its cache. + - if: matrix.shard == 1 && steps.browsers-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-browsers-ubuntu-24.04-${{ steps.keys.outputs.playwright }} + # The image already has Chromium's libraries. What install-deps still + # fetches is about 26 MB of fonts and package upgrades, and that is + # where #838's 12-minute installs went (29 kB/s from the Azure Ubuntu + # mirror). The .debs are cached, so most runs only fetch the package + # index. An older image's .debs are a fine start: apt uses the ones that + # still match and downloads the rest. + - name: restore apt packages + id: debs-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/e2e-apt-debs + key: e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}-${{ steps.keys.outputs.image }} + restore-keys: | + e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}- + # apt's robustness options go in a config file so they also reach the + # apt-get that Playwright runs: retry a failed file, drop a connection + # silent for 20 s instead of waiting on it, wait for the dpkg lock + # rather than fail, and keep the .debs for the cache. retry.sh runs + # under sudo so its timeout can kill apt itself, and apt resumes partial + # downloads, so a retry does not start from zero. 3 attempts of at most + # 170 s plus 30 s of pauses fit inside the step's 10 minutes. + # The image's apt sources go through /etc/apt/apt-mirrors.txt: Azure's + # mirror first, then archive.ubuntu.com, then security.ubuntu.com. apt + # only falls back on an error, never on a trickle, and #838's own CI saw + # Azure serve the font packages at about 40 kB/s for nine minutes while + # it served the package index at 1.5 MB/s. So a retry drops Azure and + # starts on archive.ubuntu.com. + - name: Playwright system dependencies + timeout-minutes: 10 + env: + CACHE: ${{ steps.debs-cache.outputs.cache-hit == 'true' && 'hit' || (steps.debs-cache.outputs.cache-matched-key != '' && 'partial' || 'miss') }} + run: | + bash scripts/ci/setup-timing.sh begin "Playwright system deps" + printf '%s\n' \ + 'Acquire::Retries "3";' \ + 'Acquire::http::Timeout "20";' \ + 'Acquire::https::Timeout "20";' \ + 'DPkg::Lock::Timeout "60";' \ + 'APT::Keep-Downloaded-Packages "true";' \ + | sudo tee /etc/apt/apt.conf.d/80-e2e-robust > /dev/null + debs="$HOME/.cache/e2e-apt-debs" + mkdir -p "$debs" + find "$debs" -maxdepth 1 -name '*.deb' -exec sudo cp -t /var/cache/apt/archives/ {} + + cat /etc/apt/apt-mirrors.txt 2>/dev/null || true + sudo env "PATH=$PATH" "GITHUB_ACTIONS=${GITHUB_ACTIONS:-}" bash scripts/ci/retry.sh --timeout 170 -- bash -c ' + if [ "$RETRY_ATTEMPT" -gt 1 ] && grep -q "azure\.archive\.ubuntu\.com" /etc/apt/apt-mirrors.txt 2>/dev/null; then + echo "retry: attempt $RETRY_ATTEMPT drops the Azure Ubuntu mirror" + sed -i "/azure\.archive\.ubuntu\.com/d" /etc/apt/apt-mirrors.txt + fi + exec node_modules/.bin/playwright install-deps chromium' + find /var/cache/apt/archives -maxdepth 1 -name '*.deb' -exec cp -t "$debs" {} + + echo "cached .debs: $(find "$debs" -name '*.deb' | wc -l), $(du -sh "$debs" | cut -f1)" + bash scripts/ci/setup-timing.sh end "Playwright system deps" "$CACHE" + - if: matrix.shard == 1 && steps.debs-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/e2e-apt-debs + key: e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}-${{ steps.keys.outputs.image }} + # The backend under test is real, and finishing a recorded take renders + # it with ffmpeg (app/pipeline/take_render.py). The runner image has no + # ffmpeg, so without this the take specs get a 500 and time out. This + # was apt's ffmpeg, 63 MB from the same mirror, the step #838's shards + # were killed in. The archive is checked against its pin on every run, + # cache hit or not, before anything in it is unpacked. + - name: restore ffmpeg + id: ffmpeg-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/e2e-ffmpeg + key: e2e-ffmpeg-${{ env.FFMPEG_SHA256 }} + - name: ffmpeg + timeout-minutes: 10 + env: + CACHE: ${{ steps.ffmpeg-cache.outputs.cache-hit == 'true' && 'hit' || 'miss' }} + run: | + bash scripts/ci/setup-timing.sh begin "ffmpeg" + dir="$HOME/.cache/e2e-ffmpeg" + archive="$dir/$FFMPEG_ASSET" + mkdir -p "$dir" + if ! echo "$FFMPEG_SHA256 $archive" | sha256sum --check --status 2>/dev/null; then + rm -f "$archive" + bash scripts/ci/retry.sh --timeout 150 -- \ + curl -fsSL --connect-timeout 20 -o "$archive" \ + "https://github.com/BtbN/FFmpeg-Builds/releases/download/$FFMPEG_RELEASE/$FFMPEG_ASSET" + if ! echo "$FFMPEG_SHA256 $archive" | sha256sum --check; then + echo "::error::ffmpeg archive $FFMPEG_ASSET does not match its pinned SHA256; not using it" + rm -f "$archive" + exit 1 + fi + fi + bin="$RUNNER_TEMP/ffmpeg" + mkdir -p "$bin" + tar -xJf "$archive" -C "$bin" --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' + echo "$bin" >> "$GITHUB_PATH" + "$bin/ffmpeg" -hide_banner -version | head -1 + # The export specs need all four, and ogg needs Vorbis. Fail here, + # by name, rather than as a 500 in a spec minutes later. + "$bin/ffmpeg" -hide_banner -encoders | grep -E "libvorbis|libmp3lame" || true + for enc in libvorbis libmp3lame aac flac; do + if ! "$bin/ffmpeg" -hide_banner -encoders | grep -qE "^ A[^ ]* +$enc "; then + echo "::error::ffmpeg $FFMPEG_ASSET has no $enc encoder; the export specs need it" + exit 1 + fi + done + bash scripts/ci/setup-timing.sh end "ffmpeg" "$CACHE" + - if: matrix.shard == 1 && steps.ffmpeg-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/e2e-ffmpeg + key: e2e-ffmpeg-${{ env.FFMPEG_SHA256 }} + # Runs whatever happened above, so a stalled or failed install still + # shows on the run page, with how long it had been running. + - name: setup timing summary + if: always() + run: bash scripts/ci/setup-timing.sh summary + # A reduced set can leave a shard with nothing to run, which must pass. + # The spec list is unquoted on purpose: one argument per path. + - run: npx playwright test --shard=${{ matrix.shard }}/3 --pass-with-no-tests $(cat "$RUNNER_TEMP/e2e-specs.txt") + - if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + # Per shard: a second upload under the same name fails the job. + name: playwright-report-${{ matrix.shard }} + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 941cd018..d66a45bc 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -3,24 +3,43 @@ name: Linux Release on: release: types: [published] - # Manual test build: builds and scans both variants on the self-hosted runner - # but does NOT upload (no release to attach to). Lets you validate the runner - # toolchain and the CUDA build without cutting a release tag. + # Release candidate build (#1027): builds the commit this run is started on + # and uploads to an existing draft pre-release with this tag. The guard job + # refuses any tag that is not exactly one draft pre-release targeting that + # commit, so a published release can never be overwritten from here. workflow_dispatch: inputs: - version: - description: "Version for the test build (must be valid PEP 440, e.g. 0.0.0)" - required: false - default: "0.0.0" + tag: + description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1" + required: true + type: string permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # A manual run shares the group of the release it builds into. Only a + # release event cancels what is in progress: publishing a draft cancels a + # candidate build still running for it, while a manual run never cancels a + # release build. The group comes from the raw input and applies before the + # guard, so a manual run typed with a published tag waits behind that + # release's build, and its guard then refuses. On a release event inputs.tag + # is empty and the group is the same as before. + group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'release' }} jobs: + guard: + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/release-candidate-guard.yml + permissions: + contents: write + with: + tag: ${{ inputs.tag }} + build-and-upload: + needs: guard + # A release event skips the guard; a manual run builds only if it passed. + if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }} # Runner must be linux/x64 with rustup, Node.js, Docker, and sudo apt access. runs-on: [self-hosted, linux, x64] timeout-minutes: 90 @@ -77,17 +96,13 @@ jobs: - name: write version files env: - DISPATCH_VERSION: ${{ inputs.version }} # github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME, - # which is only injected by runner >= 2.290 — empty on older - # self-hosted runners. - REF_NAME: ${{ github.ref_name }} + # which is only injected by runner >= 2.290, so it is empty on older + # self-hosted runners. A manual run takes the tag the guard + # validated; if the guard set nothing it is empty and this step stops. + REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${DISPATCH_VERSION#v}" - else - VERSION="${REF_NAME#v}" - fi + VERSION="${REF_NAME#v}" if [ -z "$VERSION" ]; then echo "Could not determine a version" >&2 exit 1 @@ -95,7 +110,6 @@ jobs: printf '{ "version": "%s" }\n' "$VERSION" > static/version.json sed -i "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json - sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "Wrote version $VERSION to all version files" @@ -144,7 +158,6 @@ jobs: # 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 \ @@ -157,9 +170,92 @@ jobs: test -f "$f" || { echo "missing release asset: $f" >&2; exit 1; } done + # The guard ran once, at the start of this run, and "Re-run failed jobs" + # reuses its result. Check again on every attempt, right before the + # upload, that the guarded release is still a draft pre-release with the + # guarded tag, pointing at the commit this run built. Without this, a + # re-run after publishing would overwrite the published assets, and a + # re-run of an older run would overwrite a retargeted draft's newer ones. + - name: check the release is still the guarded draft + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + RELEASE_ID: ${{ needs.guard.outputs.release_id }} + TAG: ${{ needs.guard.outputs.tag }} + RUN_SHA: ${{ github.sha }} + run: | + node - <<'NODE' + // Throw rather than process.exit(): exiting with a fetch socket still + // open aborts Node on Windows. The catch below sets the exit code. + const stop = (why) => { + throw new Error(why); + }; + const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + (async () => { + const { GH_TOKEN, API_URL, REPO, RELEASE_ID, TAG, RUN_SHA } = process.env; + if (!/^[0-9]+$/.test(RELEASE_ID || '') || !TAG || !/^[0-9a-f]{40}$/.test(RUN_SHA || '')) { + stop('the guard set no release id or tag, or the run has no commit SHA.'); + } + if (typeof fetch !== 'function') stop('this check needs Node.js 18 or newer on the runner.'); + const get = async (path) => { + const res = await fetch(`${API_URL}/repos/${REPO}/${path}`, { + headers: { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!res.ok) stop(`${path} could not be read (HTTP ${res.status}).`); + return res.json(); + }; + + const rel = await get(`releases/${RELEASE_ID}`); + if ( + !isObject(rel) || + typeof rel.tag_name !== 'string' || + typeof rel.draft !== 'boolean' || + typeof rel.prerelease !== 'boolean' || + typeof rel.target_commitish !== 'string' + ) { + stop(`release ${RELEASE_ID} did not come back as a release object.`); + } + console.log( + `Release ${RELEASE_ID}: tag=${rel.tag_name} draft=${rel.draft} prerelease=${rel.prerelease} target_commitish=${rel.target_commitish}`, + ); + if (rel.tag_name !== TAG) stop(`release ${RELEASE_ID} now has the tag ${rel.tag_name}, not ${TAG}.`); + if (rel.draft !== true) stop(`release ${TAG} is no longer a draft; its own release build owns its assets.`); + if (rel.prerelease !== true) stop(`release ${TAG} is no longer marked pre-release.`); + + // The guard's commit check, repeated: an existing tag's commit, + // otherwise the draft's target, must be the commit this run built. + const refs = await get(`git/matching-refs/tags/${TAG}`); + if (!Array.isArray(refs)) stop(`the tag lookup for ${TAG} did not come back as a list.`); + let expected; + let source; + if (refs.some((r) => isObject(r) && r.ref === `refs/tags/${TAG}`)) { + const commit = await get(`commits/refs/tags/${TAG}`); + if (!isObject(commit) || typeof commit.sha !== 'string') stop(`the tag ${TAG} did not resolve to a commit.`); + expected = commit.sha; + source = `existing tag ${TAG}`; + } else { + expected = rel.target_commitish; + source = 'target_commitish'; + } + console.log(`Commit this run built: ${RUN_SHA}`); + console.log(`Commit the release points at (${source}): ${expected}`); + if (expected !== RUN_SHA) { + stop(`this run built ${RUN_SHA} but ${TAG} now points at ${expected} (${source}). Start a new run instead of re-running this one.`); + } + console.log(`Decision: UPLOAD to draft ${TAG} (release ${RELEASE_ID}).`); + })().catch((err) => { + console.log(`::error::Refusing to upload: ${err.message}`); + process.exitCode = 1; + }); + NODE + - name: upload artifacts - # Only attach to a real release; a manual test build has nothing to upload to. - if: github.event_name == 'release' uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: # Carry the release's own flag. The action defaults `prerelease` to @@ -167,7 +263,12 @@ jobs: # pre-release to the latest release -- which also fires `released`, # pushing :latest to GHCR, and makes the in-app updater offer a build # that was never verified. - prerelease: ${{ github.event.release.prerelease }} + prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }} + # A manual run uploads to the draft named by the guarded tag. Without + # draft: true the action publishes the draft after uploading. On a + # release event both are empty and the action keeps its defaults. + tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }} + draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} fail_on_unmatched_files: true files: | dist/StemDeck-Linux-x64.tar.gz diff --git a/.github/workflows/macos-check.yml b/.github/workflows/macos-check.yml index 57f62d0e..06ac0132 100644 --- a/.github/workflows/macos-check.yml +++ b/.github/workflows/macos-check.yml @@ -1,7 +1,6 @@ name: macOS Rust Check -# Manual only -- not on every PR/push, to keep the self-hosted runner's load -# and cost unchanged from today. Exists because ci.yml (which does run on +# Manual, plus PRs that touch the Rust (see below). Exists because ci.yml (which does run on # every PR) is 100% ubuntu-latest, and a Linux runner cannot type-check code # behind #[cfg(target_os = "macos")] at all -- that code is stripped before # semantic analysis even starts, not just skipped at test time. This is the @@ -10,8 +9,7 @@ on: workflow_dispatch: # Also on PRs that actually touch the Rust, so a desktop-shell change cannot # merge without a real compiler pass on this platform. Scoped by path so the - # self-hosted runner's load is unchanged for the majority of PRs, which do not - # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every + # majority of PRs, which do not go near src-tauri, skip it. (#421 added ~600 lines of mostly cfg-gated Rust and every # CI check passed without compiling any of it.) pull_request: paths: @@ -26,25 +24,19 @@ 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. + # Fork PRs get a maintainer-triggered workflow_dispatch run instead. This + # runner is GitHub-hosted and disposable, so the guard is no longer needed + # for safety; it is kept so fork PRs do not start compiles on their own. 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). - runs-on: [self-hosted, macOS, ARM64] + # macos-latest is Apple Silicon (arm64) and ships Xcode CLT and rustup. This + # intentionally does not replace macos-release.yml -- this only builds/checks, + # never signs, packages, or uploads anything. + runs-on: macos-latest timeout-minutes: 30 + env: + CARGO_INCREMENTAL: 0 defaults: run: shell: bash @@ -52,6 +44,19 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Compiled dependencies are most of this job's time. Keyed on Cargo.lock, so a + # dependency change starts a fresh cache and the closest older one seeds it. + - name: cache cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + desktop/src-tauri/target + key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/src-tauri/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-${{ runner.arch }}- + - name: cargo fmt --check run: cargo fmt --check diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 3b552720..db73cdfe 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -3,18 +3,48 @@ name: macOS Release on: release: types: [published] + # Release candidate build (#1027): builds the commit this run is started on + # and uploads to an existing draft pre-release with this tag. The guard job + # refuses any tag that is not exactly one draft pre-release targeting that + # commit, so a published release can never be overwritten from here. The + # signing check below still refuses to build without every secret. + workflow_dispatch: + inputs: + tag: + description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1" + required: true + type: string permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # A manual run shares the group of the release it builds into. Only a + # release event cancels what is in progress: publishing a draft cancels a + # candidate build still running for it, while a manual run never cancels a + # release build. The group comes from the raw input and applies before the + # guard, so a manual run typed with a published tag waits behind that + # release's build, and its guard then refuses. On a release event inputs.tag + # is empty and the group is the same as before. + group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'release' }} jobs: + guard: + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/release-candidate-guard.yml + permissions: + contents: write + with: + tag: ${{ inputs.tag }} + build-and-upload: + needs: guard + # A release event skips the guard; a manual run builds only if it passed. + if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }} # Runner must be darwin/arm64 with Rosetta 2, Xcode CLT, Homebrew, rustup, and zstd. runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 120 + # Four notarizations (app and DMG, for arm64 and x64) can each be slow. + timeout-minutes: 360 permissions: contents: write defaults: @@ -29,9 +59,10 @@ jobs: - name: write version files env: # github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME, - # which is only injected by runner >= 2.290 — empty on older - # self-hosted runners. - REF_NAME: ${{ github.ref_name }} + # which is only injected by runner >= 2.290, so it is empty on older + # self-hosted runners. A manual run takes the tag the guard + # validated; if the guard set nothing it is empty and this step stops. + REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }} run: | if [ -z "${REF_NAME:-}" ]; then echo "REF_NAME is not set" >&2 @@ -41,15 +72,44 @@ jobs: printf '{ "version": "%s" }\n' "$VERSION" > static/version.json sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json - sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "Wrote version $VERSION to all version files" + # A release must never ship unsigned (#857): stop here if any signing or + # notarization secret is missing, before spending an hour on the build. + - name: prepare signing credentials + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + missing=0 + for name in APPLE_SIGNING_IDENTITY APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_P8; do + if [ -z "${!name:-}" ]; then + echo "::error::Secret $name is empty. Refusing to build an unsigned macOS release." + missing=1 + fi + done + [ "$missing" -eq 0 ] || exit 1 + identities="$(security find-identity -v -p codesigning)" + if ! grep -qF "$APPLE_SIGNING_IDENTITY" <<<"$identities"; then + echo "::error::The signing identity is not in this runner's keychain." + exit 1 + fi + key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + (umask 077 && printf '%s\n' "$APPLE_API_KEY_P8" > "$key_path") + chmod 600 "$key_path" + echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + - name: build macOS arm64 env: SSL_CERT_FILE: /etc/ssl/cert.pem REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} run: | command -v zstd command -v uv >/dev/null 2>&1 || brew install uv @@ -64,6 +124,9 @@ jobs: env: SSL_CERT_FILE: /etc/ssl/cert.pem REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} run: | command -v zstd if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then @@ -111,11 +174,108 @@ jobs: test -L "$mountpoint/Applications" test -f "$mountpoint/README-macOS.txt" test -f "$mountpoint/THIRD_PARTY_NOTICES.txt" + # Gatekeeper gate (#857): the shipped app must be sealed, notarized + # and stapled, and so must the DMG around it. + codesign --verify --deep --strict --verbose=2 "$mountpoint/StemDeck.app" + assessment="$(spctl -a -vvv -t exec "$mountpoint/StemDeck.app" 2>&1)" || true + echo "$assessment" + if ! grep -q ': accepted' <<<"$assessment" || + ! grep -q 'source=Notarized Developer ID' <<<"$assessment"; then + echo "$arch StemDeck.app is not accepted as a notarized Developer ID app." >&2 + exit 1 + fi + xcrun stapler validate "$mountpoint/StemDeck.app" hdiutil detach "$mountpoint" rmdir "$mountpoint" trap - EXIT + xcrun stapler validate ".build/macos-dist/StemDeck-macOS-$arch.dmg" done + # The guard ran once, at the start of this run, and "Re-run failed jobs" + # reuses its result. Check again on every attempt, right before the + # upload, that the guarded release is still a draft pre-release with the + # guarded tag, pointing at the commit this run built. Without this, a + # re-run after publishing would overwrite the published assets, and a + # re-run of an older run would overwrite a retargeted draft's newer ones. + - name: check the release is still the guarded draft + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + RELEASE_ID: ${{ needs.guard.outputs.release_id }} + TAG: ${{ needs.guard.outputs.tag }} + RUN_SHA: ${{ github.sha }} + run: | + node - <<'NODE' + // Throw rather than process.exit(): exiting with a fetch socket still + // open aborts Node on Windows. The catch below sets the exit code. + const stop = (why) => { + throw new Error(why); + }; + const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + (async () => { + const { GH_TOKEN, API_URL, REPO, RELEASE_ID, TAG, RUN_SHA } = process.env; + if (!/^[0-9]+$/.test(RELEASE_ID || '') || !TAG || !/^[0-9a-f]{40}$/.test(RUN_SHA || '')) { + stop('the guard set no release id or tag, or the run has no commit SHA.'); + } + if (typeof fetch !== 'function') stop('this check needs Node.js 18 or newer on the runner.'); + const get = async (path) => { + const res = await fetch(`${API_URL}/repos/${REPO}/${path}`, { + headers: { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (!res.ok) stop(`${path} could not be read (HTTP ${res.status}).`); + return res.json(); + }; + + const rel = await get(`releases/${RELEASE_ID}`); + if ( + !isObject(rel) || + typeof rel.tag_name !== 'string' || + typeof rel.draft !== 'boolean' || + typeof rel.prerelease !== 'boolean' || + typeof rel.target_commitish !== 'string' + ) { + stop(`release ${RELEASE_ID} did not come back as a release object.`); + } + console.log( + `Release ${RELEASE_ID}: tag=${rel.tag_name} draft=${rel.draft} prerelease=${rel.prerelease} target_commitish=${rel.target_commitish}`, + ); + if (rel.tag_name !== TAG) stop(`release ${RELEASE_ID} now has the tag ${rel.tag_name}, not ${TAG}.`); + if (rel.draft !== true) stop(`release ${TAG} is no longer a draft; its own release build owns its assets.`); + if (rel.prerelease !== true) stop(`release ${TAG} is no longer marked pre-release.`); + + // The guard's commit check, repeated: an existing tag's commit, + // otherwise the draft's target, must be the commit this run built. + const refs = await get(`git/matching-refs/tags/${TAG}`); + if (!Array.isArray(refs)) stop(`the tag lookup for ${TAG} did not come back as a list.`); + let expected; + let source; + if (refs.some((r) => isObject(r) && r.ref === `refs/tags/${TAG}`)) { + const commit = await get(`commits/refs/tags/${TAG}`); + if (!isObject(commit) || typeof commit.sha !== 'string') stop(`the tag ${TAG} did not resolve to a commit.`); + expected = commit.sha; + source = `existing tag ${TAG}`; + } else { + expected = rel.target_commitish; + source = 'target_commitish'; + } + console.log(`Commit this run built: ${RUN_SHA}`); + console.log(`Commit the release points at (${source}): ${expected}`); + if (expected !== RUN_SHA) { + stop(`this run built ${RUN_SHA} but ${TAG} now points at ${expected} (${source}). Start a new run instead of re-running this one.`); + } + console.log(`Decision: UPLOAD to draft ${TAG} (release ${RELEASE_ID}).`); + })().catch((err) => { + console.log(`::error::Refusing to upload: ${err.message}`); + process.exitCode = 1; + }); + NODE + - name: upload artifacts uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: @@ -124,7 +284,12 @@ jobs: # pre-release to the latest release -- which also fires `released`, # pushing :latest to GHCR, and makes the in-app updater offer a build # that was never verified. - prerelease: ${{ github.event.release.prerelease }} + prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }} + # A manual run uploads to the draft named by the guarded tag. Without + # draft: true the action publishes the draft after uploading. On a + # release event both are empty and the action keeps its defaults. + tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }} + draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} files: | .build/macos-dist/StemDeck-macOS-arm64.dmg .build/StemDeck-runtime-macOS-arm64.tar.zst @@ -132,3 +297,11 @@ jobs: .build/macos-dist/StemDeck-macOS-x64.dmg .build/StemDeck-runtime-macOS-x64.tar.zst .build/macos-dist/SHA256SUMS-macOS-x64.txt + + - name: remove signing credentials + if: always() + run: | + if [ -n "${APPLE_API_KEY_PATH:-}" ]; then + rm -f "$APPLE_API_KEY_PATH" + fi + rm -f "$RUNNER_TEMP"/AuthKey_*.p8 diff --git a/.github/workflows/release-candidate-guard.yml b/.github/workflows/release-candidate-guard.yml new file mode 100644 index 00000000..69733996 --- /dev/null +++ b/.github/workflows/release-candidate-guard.yml @@ -0,0 +1,141 @@ +name: Release Candidate Guard + +# Called by the manual (workflow_dispatch) runs of linux-release.yml, +# windows-release.yml and macos-release.yml before they build anything (#1027). +# +# A manual run builds a release candidate and uploads it to an existing draft +# pre-release. This guard stops the run unless all of these hold, so a typo can +# never overwrite the installers of a published release: +# +# 1. The tag is a strict release tag: v + MAJOR.MINOR.PATCH, optionally +# followed by -rc.N, -beta.N or -alpha.N. Nothing else reaches a shell. +# 2. Exactly one release carries that tag. +# 3. That release is a draft. +# 4. That release is marked pre-release. +# 5. The commit this run builds is the commit the release will be tagged at: +# the existing git tag's commit if the tag already exists, otherwise the +# draft's target_commitish, which must then be a full 40-character SHA. +# A branch target is refused: it is compared with the branch tip when +# each platform's guard runs, so two platforms could pass on different +# commits, and publishing would tag whatever the tip is by then. +# +# The guard runs once per run, and "Re-run failed jobs" reuses its result. So +# each build job checks again, on every attempt, right before it uploads, that +# the release with the guarded id is still a draft with the guarded tag. +# +# The job checks out no code and only reads from the API. It still needs +# `contents: write`, because the releases API lists drafts only to callers with +# push access; a read-only token sees no drafts at all. + +on: + workflow_call: + inputs: + tag: + description: "Tag of the draft pre-release to build into, e.g. v0.21.0-rc.1" + required: true + type: string + outputs: + tag: + description: "The validated tag. Set only when every check passed." + value: ${{ jobs.check.outputs.tag }} + release_id: + description: "The draft's numeric release id. Set only when every check passed." + value: ${{ jobs.check.outputs.release_id }} + +permissions: {} + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + outputs: + tag: ${{ steps.check.outputs.tag }} + release_id: ${{ steps.check.outputs.release_id }} + steps: + - name: check the tag names a draft pre-release on this commit + id: check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + refuse() { + echo "::error::Refusing to build: $1" + echo "Decision: REFUSED. $1" + exit 1 + } + + # 1. Strict tag shape. The raw value is never echoed: it is free text + # typed into the run form, and could carry workflow commands. + pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(rc|beta|alpha)\.(0|[1-9][0-9]*))?$' + if ! [[ "$TAG" =~ $pattern ]]; then + refuse "the tag input is not a release tag (expected v1.2.3 or v1.2.3-rc.1, -beta.1, -alpha.1)." + fi + echo "Tag: $TAG" + + # 2. Exactly one release with that tag. Drafts are not returned by + # the get-by-tag endpoint, so list every release and filter. + matches="$(gh api --paginate "repos/$REPO/releases?per_page=100" | + jq -c --arg tag "$TAG" '.[] | select(.tag_name == $tag) + | {id, draft, prerelease, target_commitish}')" + count="$(printf '%s' "$matches" | grep -c . || true)" + if [ "$count" -eq 0 ]; then + refuse "no release has the tag $TAG. Create the draft pre-release first." + fi + if [ "$count" -gt 1 ]; then + refuse "$count releases have the tag $TAG. Delete the extra drafts so exactly one remains." + fi + id="$(jq -r .id <<<"$matches")" + draft="$(jq -r .draft <<<"$matches")" + prerelease="$(jq -r .prerelease <<<"$matches")" + target="$(jq -r '.target_commitish // ""' <<<"$matches")" + echo "Release $id: draft=$draft prerelease=$prerelease target_commitish=$target" + + # 3 and 4. Draft and pre-release. + if [ "$draft" != "true" ]; then + refuse "release $TAG is published, not a draft. A manual run only builds into a draft." + fi + if [ "$prerelease" != "true" ]; then + refuse "draft $TAG is not marked pre-release. Tick 'Set as a pre-release' on the draft." + fi + + # 5. The commit the release will be tagged at must be the commit this + # run builds. An existing tag wins over target_commitish, because + # publishing a draft whose tag already exists keeps that tag. + tag_ref="$(gh api "repos/$REPO/git/matching-refs/tags/$TAG" | + jq -r --arg ref "refs/tags/$TAG" '.[] | select(.ref == $ref) | .ref')" + if [ -n "$tag_ref" ]; then + source="existing tag $TAG" + fix="Run the workflow from the commit the tag points at." + expected="$(gh api "repos/$REPO/commits/$tag_ref" --jq .sha)" || + refuse "the existing tag $TAG could not be resolved to a commit." + else + if ! [[ "$target" =~ ^[0-9a-f]{40}$ ]]; then + refuse "draft $TAG targets '$target', not a full commit SHA. A branch target moves, so set the draft's target to the full 40-character SHA to build: gh release edit $TAG --target " + fi + source="target_commitish $target" + fix="Run the workflow from a branch whose tip is that commit, or change the draft's target." + expected="$(gh api "repos/$REPO/commits/$target" --jq .sha)" || + refuse "the draft's target $target could not be resolved to a commit." + fi + echo "Commit this run builds: $RUN_SHA" + echo "Commit the release points at ($source): $expected" + if [ "$expected" != "$RUN_SHA" ]; then + refuse "this run builds $RUN_SHA but $TAG points at $expected ($source). $fix" + fi + + if ! [[ "$id" =~ ^[0-9]+$ ]]; then + refuse "release $TAG has no numeric id." + fi + + echo "Decision: BUILD. Installers will upload to draft pre-release $TAG (release $id) from $RUN_SHA." + { + echo "tag=$TAG" + echo "release_id=$id" + } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/torch-bundle.yml b/.github/workflows/torch-bundle.yml new file mode 100644 index 00000000..08a0bf0c --- /dev/null +++ b/.github/workflows/torch-bundle.yml @@ -0,0 +1,96 @@ +name: Torch Bundle Check + +# The Linux desktop packages swap torch, torchaudio and torchvision for CPU +# wheels after the project install. 0.20.0 swapped only torch and torchaudio, +# so the CPU package shipped PyPI's default Linux torchvision, the cu124 build, +# beside CPU torch, and the karaoke split could not load it (#778). Nothing in +# CI ran that swap. This job runs it in the order make-portable.sh does, plus +# the gate that now fails the build, so a regression fails here on the PR. +# +# Bounded on purpose. CPU torch goes in before the project install, so the +# resolver keeps it and never downloads CUDA torch and its ~3 GB of nvidia-* +# wheels. torchvision still comes from PyPI exactly as the project install +# resolves it, which is the cu124 build: the pair the 0.20.0 package shipped. +# The job checks that it reproduced that pair and that the gate rejects it +# before it runs the real swap, so a pass means the swap fixed something. +# +# GitHub-hosted and disposable, no secrets, no fork guard needed (see +# linux-check.yml). +on: + workflow_dispatch: + pull_request: + paths: + - "scripts/linux/make-portable.sh" + - "scripts/linux/bake-cpu-torch.sh" + - "scripts/check_torch_bundle.py" + - "packaging/torch-versions.json" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/torch-bundle.yml" + push: + branches: [main] + paths: + - "scripts/linux/make-portable.sh" + - "scripts/linux/bake-cpu-torch.sh" + - "scripts/check_torch_bundle.py" + - "packaging/torch-versions.json" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/torch-bundle.yml" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-cpu-swap: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + UV_LINK_MODE: copy + # Shallow, tagless clone: hatch-vcs cannot derive a version (#169). + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" + VERSIONS: packaging/torch-versions.json + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.6" + enable-cache: "false" + + - name: Create the bundle Python + run: | + command -v readelf # the gate reads NEEDED entries with it + uv venv --python 3.12 "$RUNNER_TEMP/bundle" + echo "PY=$RUNNER_TEMP/bundle/bin/python" >> "$GITHUB_ENV" + # make-portable.sh installs these first too; the swap runs pip. + uv pip install --python "$RUNNER_TEMP/bundle/bin/python" pip setuptools wheel + + - name: Seed CPU torch so the project install never fetches CUDA torch + run: | + TORCH="$("$PY" -I -c 'import json, sys; print(json.load(open(sys.argv[1]))["cpuTorch"])' "$VERSIONS")" + uv pip install --python "$PY" --no-deps \ + "torch==${TORCH}+cpu" "torchaudio==${TORCH}+cpu" \ + --index-url https://download.pytorch.org/whl/cpu + + - name: Install StemDeck the way make-portable.sh does + run: uv pip install --python "$PY" . + + - name: The unfixed pair is reproduced, and the gate rejects it + run: | + VISION="$("$PY" -I -c 'import pathlib, re, sysconfig; p = pathlib.Path(sysconfig.get_paths()["purelib"], "torchvision", "version.py"); print(re.search(r"__version__\s*=\s*.([^\x27\x22]+)", p.read_text()).group(1))')" + echo "torchvision before the swap: $VISION" + case "$VISION" in + *+cu*) ;; + *) echo "::error::expected PyPI's CUDA torchvision before the swap, got $VISION; this job no longer reproduces #778"; exit 1 ;; + esac + if "$PY" scripts/check_torch_bundle.py --versions "$VERSIONS" --expect-cpu; then + echo "::error::the gate passed a CUDA torchvision beside CPU torch" + exit 1 + fi + + - name: Bake CPU torch and run the gate (the step make-portable.sh runs) + run: bash scripts/linux/bake-cpu-torch.sh "$PY" diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml index e072eb80..5519b329 100644 --- a/.github/workflows/windows-check.yml +++ b/.github/workflows/windows-check.yml @@ -1,7 +1,6 @@ name: Windows Rust Check -# Manual only -- not on every PR/push, to keep the self-hosted runner's load -# and cost unchanged from today. Companion to macos-check.yml: ci.yml (which +# Manual, plus PRs that touch the Rust (see below). Companion to macos-check.yml: ci.yml (which # does run on every PR) is 100% ubuntu-latest, so nothing behind # #[cfg(windows)] -- or, as it turned out, code that lost a #[cfg(unix)] gate # and started compiling on Windows by accident -- has ever had a real @@ -11,8 +10,7 @@ on: workflow_dispatch: # Also on PRs that actually touch the Rust, so a desktop-shell change cannot # merge without a real compiler pass on this platform. Scoped by path so the - # self-hosted runner's load is unchanged for the majority of PRs, which do not - # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every + # majority of PRs, which do not go near src-tauri, skip it. (#421 added ~600 lines of mostly cfg-gated Rust and every # CI check passed without compiling any of it.) pull_request: paths: @@ -27,32 +25,41 @@ 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. + # Fork PRs get a maintainer-triggered workflow_dispatch run instead. This + # runner is GitHub-hosted and disposable, so the guard is no longer needed + # for safety; it is kept so fork PRs do not start compiles on their own. 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). - runs-on: [self-hosted, windows, x64] + # windows-latest ships rustup and the MSVC toolchain. This intentionally + # does not replace windows-release.yml -- this only builds/checks, never + # packages or uploads anything. + runs-on: windows-latest timeout-minutes: 30 + env: + CARGO_INCREMENTAL: 0 defaults: run: - # bash on this self-hosted runner mangles the auto-generated temp - # script's Windows-style path (backslashes stripped -- "No such file - # or directory" on a path that plainly exists), the way - # windows-release.yml's own steps already discovered. powershell is - # the proven shell for this runner. + # Kept as powershell: it is the shell this job has always run under + # (bash mangled the temp script's Windows path on the old runner). shell: powershell working-directory: desktop/src-tauri steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Compiled dependencies are most of this job's time. Keyed on Cargo.lock, so a + # dependency change starts a fresh cache and the closest older one seeds it. + - name: cache cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + desktop/src-tauri/target + key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/src-tauri/Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-${{ runner.arch }}- + - name: cargo fmt --check run: cargo fmt --check diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 75c0dc39..2384d900 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -3,15 +3,43 @@ name: Windows Release on: release: types: [published] + # Release candidate build (#1027): builds the commit this run is started on + # and uploads to an existing draft pre-release with this tag. The guard job + # refuses any tag that is not exactly one draft pre-release targeting that + # commit, so a published release can never be overwritten from here. + workflow_dispatch: + inputs: + tag: + description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1" + required: true + type: string permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # A manual run shares the group of the release it builds into. Only a + # release event cancels what is in progress: publishing a draft cancels a + # candidate build still running for it, while a manual run never cancels a + # release build. The group comes from the raw input and applies before the + # guard, so a manual run typed with a published tag waits behind that + # release's build, and its guard then refuses. On a release event inputs.tag + # is empty and the group is the same as before. + group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'release' }} jobs: + guard: + if: github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/release-candidate-guard.yml + permissions: + contents: write + with: + tag: ${{ inputs.tag }} + build-and-upload: + needs: guard + # A release event skips the guard; a manual run builds only if it passed. + if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }} # Runner must be windows/x64 with PowerShell, Docker, and rustup. runs-on: [self-hosted, windows, x64] timeout-minutes: 90 @@ -20,8 +48,10 @@ jobs: # Source the tag from the github context (evaluated by Actions) rather than # $env:GITHUB_REF_NAME, which is only injected by runner >= 2.290. Keeps the # build working on older self-hosted runners. (#212 follow-up) + # A manual run takes the tag the guard validated instead. If the guard set + # nothing it is empty, and the version step stops the build. env: - REF_NAME: ${{ github.ref_name }} + REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }} defaults: run: shell: powershell @@ -43,8 +73,6 @@ jobs: Set-Content "desktop/src-tauri/Cargo.toml" (Get-Content "desktop/src-tauri/tauri.conf.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | Set-Content "desktop/src-tauri/tauri.conf.json" - (Get-Content "pyproject.toml") -replace '^version = ".*"', "version = `"$version`"" | - Set-Content "pyproject.toml" (Get-Content "desktop/package.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | Set-Content "desktop/package.json" Write-Host "Wrote version $version to all version files" @@ -111,6 +139,83 @@ jobs: if (-not (Test-Path $f)) { Write-Error "missing release asset: $f"; exit 1 } } + # The guard ran once, at the start of this run, and "Re-run failed jobs" + # reuses its result. Check again on every attempt, right before the + # upload, that the guarded release is still a draft pre-release with the + # guarded tag, pointing at the commit this run built. Without this, a + # re-run after publishing would overwrite the published assets, and a + # re-run of an older run would overwrite a retargeted draft's newer ones. + - name: check the release is still the guarded draft + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + API_URL: ${{ github.api_url }} + REPO: ${{ github.repository }} + RELEASE_ID: ${{ needs.guard.outputs.release_id }} + TAG: ${{ needs.guard.outputs.tag }} + RUN_SHA: ${{ github.sha }} + run: | + if ($env:RELEASE_ID -notmatch '^[0-9]+$' -or -not $env:TAG -or $env:RUN_SHA -cnotmatch '^[0-9a-f]{40}$') { + throw "Refusing to upload: the guard set no release id or tag, or the run has no commit SHA." + } + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $headers = @{ + "Authorization" = "Bearer $env:GH_TOKEN" + "Accept" = "application/vnd.github+json" + "X-GitHub-Api-Version" = "2022-11-28" + } + function Get-Api([string]$Path) { + Invoke-RestMethod -Method Get -Uri "$env:API_URL/repos/$env:REPO/$Path" -Headers $headers + } + # A JSON object arrives as a PSCustomObject; an array, a string or a + # number does not, and a field of the wrong type is refused as well. + $objectType = [System.Management.Automation.PSCustomObject] + + $release = Get-Api "releases/$env:RELEASE_ID" + if ($release -isnot $objectType -or + $release.tag_name -isnot [string] -or + $release.draft -isnot [bool] -or + $release.prerelease -isnot [bool] -or + $release.target_commitish -isnot [string]) { + throw "Refusing to upload: release $($env:RELEASE_ID) did not come back as a release object." + } + Write-Host "Release $($env:RELEASE_ID): tag=$($release.tag_name) draft=$($release.draft) prerelease=$($release.prerelease) target_commitish=$($release.target_commitish)" + if ($release.tag_name -cne $env:TAG) { + throw "Refusing to upload: release $($env:RELEASE_ID) now has the tag $($release.tag_name), not $($env:TAG)." + } + if ($release.draft -ne $true) { + throw "Refusing to upload: release $($env:TAG) is no longer a draft; its own release build owns its assets." + } + if ($release.prerelease -ne $true) { + throw "Refusing to upload: release $($env:TAG) is no longer marked pre-release." + } + + # The guard's commit check, repeated: an existing tag's commit, + # otherwise the draft's target, must be the commit this run built. + # Windows PowerShell 5.1 returns a JSON array as one Object[] value. + $refs = Get-Api "git/matching-refs/tags/$env:TAG" + if ($null -ne $refs -and $refs -isnot [array]) { + throw "Refusing to upload: the tag lookup for $($env:TAG) did not come back as a list." + } + $exact = @(@($refs) | Where-Object { $_ -is $objectType -and $_.ref -ceq "refs/tags/$($env:TAG)" }) + if ($exact.Count -gt 0) { + $commit = Get-Api "commits/refs/tags/$env:TAG" + if ($commit -isnot $objectType -or $commit.sha -isnot [string]) { + throw "Refusing to upload: the tag $($env:TAG) did not resolve to a commit." + } + $expected = $commit.sha + $source = "existing tag $($env:TAG)" + } else { + $expected = $release.target_commitish + $source = "target_commitish" + } + Write-Host "Commit this run built: $($env:RUN_SHA)" + Write-Host "Commit the release points at ($source): $expected" + if ($expected -cne $env:RUN_SHA) { + throw "Refusing to upload: this run built $($env:RUN_SHA) but $($env:TAG) now points at $expected ($source). Start a new run instead of re-running this one." + } + Write-Host "Decision: UPLOAD to draft $($env:TAG) (release $($env:RELEASE_ID))." + - name: upload artifacts uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: @@ -119,7 +224,12 @@ jobs: # pre-release to the latest release -- which also fires `released`, # pushing :latest to GHCR, and makes the in-app updater offer a build # that was never verified. - prerelease: ${{ github.event.release.prerelease }} + prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }} + # A manual run uploads to the draft named by the guarded tag. Without + # draft: true the action publishes the draft after uploading. On a + # release event both are empty and the action keeps its defaults. + tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }} + draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} fail_on_unmatched_files: true files: | dist/StemDeck-Windows-x64.NVIDIA.zip diff --git a/.trivyignore b/.trivyignore index 81f03eac..31c3f02d 100644 --- a/.trivyignore +++ b/.trivyignore @@ -8,6 +8,18 @@ # the torchaudio/torchcodec story stabilises and the pin can be lifted. CVE-2025-32434 +# CVE-2026-24747: torch below 2.10.0, memory corruption in the weights_only +# unpickler. Exploiting it needs a victim process to torch.load() a crafted +# checkpoint file. StemDeck's app/ has no torch.load() call (checked with +# grep; the only matches are the names of the torch_load_error helpers in +# app/core/config.py). All model weights are fetched by Demucs from its own +# trusted cache, and no checkpoint is ever loaded from user input. +# torch cannot move yet: pyproject.toml pins torch and torchaudio to 2.6.x +# (2.2.x on Intel macOS) because torchaudio 2.7 and later need torchcodec, +# which has ABI problems with newer torch. Tracked in #1016. +# Drop this ignore once the pin moves to torch 2.10 or later. +CVE-2026-24747 + # DS-0002: Dockerfile missing USER instruction. # The image intentionally starts as root so the entrypoint script # (build/docker-entrypoint.sh) can re-chown the bind-mounted /app/jobs @@ -16,39 +28,3 @@ CVE-2025-32434 # executes. Trivy's check does not account for the gosu privilege-drop # pattern. DS-0002 - -# CVE-2026-63374: anyio's TLSStream encodes host names with IDNA 2003, which -# can be made to disagree with IDNA 2008 and spoof a TLS certificate. Fixed in -# anyio 4.14.2; the lock pins 4.13.0. -# -# StemDeck never opens a TLSStream. anyio is here transitively: starlette uses -# it for task groups and thread offloading, not for sockets, and httpx (which -# does use it for TLS) is a dev-only dependency, present for FastAPI's -# TestClient. The server itself speaks plain HTTP on localhost, and the two -# things that do fetch over TLS, yt-dlp and audio-separator, bring their own -# HTTP stacks. -# -# Not fixed in place because the fix is not a one-line bump: regenerating the -# lock for it also rewrites platform markers across a dozen packages, including -# the Intel macOS paths that pyproject gates by hand, and any uv.lock change -# sends every existing desktop install to a full download instead of an in-app -# update. Take the bump the next time the lock moves for another reason, and -# drop this entry then. -CVE-2026-63374 - -# CVE-2026-97687 (traffic interception through an HTTPS proxy's TLS -# configuration) and CVE-2026-97689 (denial of service through unbounded -# memory): urllib3, fixed in 2.8.0; the lock pins 2.7.0. -# -# StemDeck's own requests all go through the standard library's urllib. -# urllib3 is here only through requests, which audio-separator, librosa's -# pooch and Whisper's tiktoken use to download models and data from fixed -# hosts (GitHub, Hugging Face, OpenAI's CDN). The first needs an HTTPS proxy -# configured, the second a hostile server on one of those downloads. -# -# Not bumped now for the same reason as CVE-2026-63374: any uv.lock change -# sends every existing desktop install to a full download instead of an -# in-app update, and 0.19.0 already did that two days before 0.19.1. Take the -# bump the next time the lock moves for another reason, and drop these then. -CVE-2026-97687 -CVE-2026-97689 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 483f1776..9e1b7801 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,8 +85,9 @@ New API endpoints and pipeline stages should come with tests under `tests/`. The ## License -StemDeck is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree that your -contributions are licensed under the same terms. +StemDeck is licensed under the [GNU Affero General Public License v3.0 only](LICENSE) +(AGPL-3.0-only). Contributions are accepted under the same license: by contributing, you agree +that your contributions are licensed under AGPL-3.0-only. ## Questions diff --git a/LICENSE b/LICENSE index 261eeb9e..0ad25db4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,661 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 55980809..b2d97c79 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ The StemDeck studio: a song split into vocals, drums, bass, guitar, piano and other, each on its own lane

-StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano and other, and opens them in a studio where you can mute, solo, slow down, loop, change the key and play along with a click. Drop in an audio file or paste a YouTube or SoundCloud link. Separation runs on your own machine, and StemDeck does not upload your audio. +StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano and other, and opens them in a studio where you can mute, solo, slow down, loop, change the key and play along with a click. Sing along in Karaoke, or film yourself playing along in Performance. Drop in an audio file or paste a YouTube or SoundCloud link. Separation runs on your own machine, and StemDeck does not upload your audio. -> **A separation tool, not a downloader.** StemDeck is for processing audio you have the right to use. What you import, and the stems made from it, are saved in your library folder on the machine that runs StemDeck; StemDeck does not upload or share them. See the [Disclaimer](#disclaimer). +> **A separation tool, not a downloader.** StemDeck is for processing audio you have the right to use. What you import, the stems made from it, and the takes you record, are saved in your library folder on the machine that runs StemDeck; StemDeck does not upload or share them. See the [Disclaimer](#disclaimer). @@ -60,25 +60,28 @@ StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano Each link downloads the latest release. [Release notes](https://github.com/stemdeckapp/stemdeck/releases/latest) · [Every version](https://github.com/stemdeckapp/stemdeck/releases) -| Platform | NVIDIA or Apple Silicon | Everything else | +| Platform | NVIDIA, AMD (Linux) or Apple Silicon | Everything else | |---|---|---| |  **Windows** | [NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Windows-x64.NVIDIA.zip) | [No NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Windows-x64.zip) | |  **macOS** | [Apple Silicon (M1 and later)](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-macOS-arm64.dmg) | [Intel Mac](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-macOS-x64.dmg) | -|  **Linux** | [NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.NVIDIA.tar.gz) | [No NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.tar.gz) | +|  **Linux** | [NVIDIA or AMD GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.NVIDIA.tar.gz) | [No NVIDIA or AMD GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.tar.gz) | -  **Docker / Unraid:** [`ghcr.io/stemdeckapp/stemdeck:latest`](https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck). It runs on the CPU as it is; add `--runtime=nvidia` to use an NVIDIA GPU. On Unraid, search "StemDeck" in Apps. +  **Docker / Unraid:** [`ghcr.io/stemdeckapp/stemdeck:latest`](https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck). It runs on the CPU as it is; add `--runtime=nvidia` to use an NVIDIA GPU. For an AMD GPU (community-tested), use the `:rocm` tag with `--device /dev/kfd --device /dev/dri`. On Unraid, search "StemDeck" in Apps.
-What's new in 0.20.0 +What's new in 0.21.0
-- **Guide.** A new button in the left rail turns the studio into its own manual: point at anything to see what it does, and every number in the song facts says what it measures. -- **The Trash.** A count on its icon, hold a song for a second to bring it back, and pick several at once with Ctrl or Shift. -- **Deleting a song while it extracts stops it,** and keeps its files until you empty the Trash. -- **Imports stay in the background:** the song you have open stays open. -- **We Recommend** welcomes Adrianna Claro, and cards can now link to Spotify and Apple Music. -- **Portable Windows installs** in a folder they cannot write to now say so clearly. +- **Four modes:** Practice, Advanced, Karaoke and Performance, all in one Mode menu. +- **Karaoke.** The lyrics fill the window and light up as the song plays, with the voice off and a queue of songs. +- **Performance.** Film yourself playing along, review the take, trim it and export an MP4. +- **Each mode keeps its own stems and its own saved mix** of a song. +- **Advanced** gains an EQ on every lane, a Master with a loudness target, recording tracks and video tracks. +- **A redesigned Settings,** with a section list and a search box. +- **A GPU video encoder** for Performance exports, with an automatic fall-back to the CPU. +- **Safer by default:** a guard against requests from other websites, auto-delete that skips favorites, songs with takes or versions, and songs still being worked on, and a library that survives one bad record. +- **Imports:** a Detect lyrics switch, and Try again on an import that failed. All of it, with credits to the people who reported each problem, is in the [release notes](https://github.com/stemdeckapp/stemdeck/releases/latest). @@ -89,7 +92,7 @@ All of it, with credits to the people who reported each problem, is in the [rele
-**Windows and Linux.** Unzip, then run `StemDeck.exe` (Windows) or `StemDeck` (Linux). No installer and nothing else to install. Put the folder somewhere you can write to, such as `C:\StemDeck`: a portable StemDeck keeps its runtime, models and settings in a `data` folder beside it, so it cannot run from Program Files. The first launch downloads FFmpeg and the separation model, and on an NVIDIA card sets up GPU support; later launches start in seconds. +**Windows and Linux.** Unzip, then run `StemDeck.exe` (Windows) or `StemDeck` (Linux). No installer and nothing else to install. Put the folder somewhere you can write to, such as `C:\StemDeck`: a portable StemDeck keeps its runtime, models and settings, and on Windows your songs by default, in a `data` folder beside it, so it cannot run from Program Files. The first launch downloads FFmpeg and the separation model, and on an NVIDIA card (or, on Linux, an AMD card ROCm supports, such as the RX 7000 and RX 9000 series) sets up GPU support; later launches start in seconds. **macOS.** Open the DMG and drag StemDeck to Applications. StemDeck is not code-signed yet, so clear the quarantine flag once, or macOS will say the app is damaged: @@ -101,7 +104,7 @@ The first launch downloads the runtime and the model. Apple Silicon separates on **Docker and Unraid.** See [Build from source](docs/build-from-source.md#docker) for the `docker run` line, GPU passthrough and the Unraid volume mapping. -**Updates.** The desktop app tells you when a new version is out and installs it for you. +**Updates.** The desktop app tells you when a new version is out. On Windows and Linux it usually installs the update for you. A version that changes the Python packages StemDeck runs on is a full download instead, and 0.21.0 is one: about 300 MB on Windows and 420 MB on Linux, once. On Windows, unzip it over your current StemDeck folder. The zip has no files inside `data`, so your songs, models and settings there stay as they are. A portable StemDeck no longer opens a library in `Documents\StemDeck\jobs` on its own: if yours is there, choose that folder once in Settings, StemData location. Unzipped into a new folder, StemDeck starts with default settings, without the songs from the old `data` folder, and downloads FFmpeg and the model again. On macOS, download the new DMG.
@@ -109,6 +112,17 @@ The first launch downloads the runtime and the model. Apple Silicon separates on ## What you can do + + +### Modes + +Pick one from **Mode** in the left rail. Each mode keeps its own choice of stems and its own saved mix of a song, so a change in one does not follow you into another. + +- **Practice:** stems, mixer, speed, key, click and loop, without recording, EQ or the master. +- **Advanced:** everything, with recording tracks, take folders, an EQ on every lane, the master and video. +- **Karaoke:** the lyrics fill the window and light up word by word as the song plays. Switch the voice and the backing vocals on or off, and drag songs from the library into a queue. +- **Performance:** film yourself playing along to the song, then export the video. + ### Split any song - **Up to six stems** with Meta's open Demucs model: vocals, drums, bass, guitar, piano and other. @@ -116,6 +130,7 @@ The first launch downloads the runtime and the model. Apple Silicon separates on - **Files or links:** MP3, WAV, FLAC, OGG, Opus, M4A and MP4, or a YouTube or SoundCloud link. - **Search without leaving the app:** YouTube songs and playlists, and SoundCloud, with a preview. - **Pick only what you need** before you extract. +- **Choose the lanes in each mode** with **Stems**: the stems you pick get their own lane, the rest play together in Original. Nothing is separated again. - **Whole YouTube playlists** go into an import queue and run one after another. ### Practice with it @@ -127,11 +142,21 @@ The first launch downloads the runtime and the model. Apple Silicon separates on - **Play with a click** that follows the song, with a count-in, any beats per bar and groupings such as 3+2+2. - **Fix the beat grid** when the click drifts: move, add or remove beats and mark the bar lines. +### Film yourself playing + +In Performance mode: + +- **Your camera and your input:** pick a camera, or none, and your microphone or audio interface input. +- **Say what you play** with **I play**, and that part is taken out of the backing track you hear and out of the video. +- **Review the take** in step with the song, and trim its start and end on the **Take Timeline**. +- **Export to MP4** at 720p or 1080p, at 24, 30 or 60 fps. StemDeck uses your graphics card when its encoder passes a short test on your computer, and the CPU otherwise. **Settings > Export** has a Video encoder choice and a Test encoders button. +- **The video stays on your computer.** Uploading and live streaming are not part of this release. + ### Know the song - **Key, tempo and loudness,** found for you. - **Sections** such as verse, chorus and solo, detected for you (experimental) or marked by hand. -- **Synced lyrics** from LRCLIB, with timing you can fix by tapping along. With an NVIDIA GPU, StemDeck can also write them down from the vocals when none are found. +- **Synced lyrics** from LRCLIB, with timing you can fix by tapping along. With an NVIDIA GPU, or an AMD one on Linux, StemDeck can also write them down from the vocals when none are found. - **Song and artist details** from MusicBrainz, Wikipedia and, optionally, Discogs. ### Keep and share @@ -142,7 +167,9 @@ The first launch downloads the runtime and the model. Apple Silicon separates on ### It explains itself -Click **Guide** in the left rail and point at anything. Every button, lane control and number says what it does, in plain words. While Guide is on, clicks explain instead of acting, so nothing happens by accident. +Click **Guide** in the left rail and point at anything. Every button, lane control and number says what it does, in plain words, and so do the Mode menu and the options in Settings. While Guide is on, clicks explain instead of acting, so nothing happens by accident. + +**Settings** has a list of sections on the left and a search box, and most changes save as soon as you make them. ### On your phone too @@ -247,7 +274,7 @@ StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](ht ## Disclaimer -StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. Audio you import, and the stems made from it, are saved only in the library folder on the machine that runs StemDeck, and StemDeck does not upload, share or redistribute them. To find song details and lyrics it sends the song's name to the services named in its Settings and, only if you add an AcoustID key, an audio fingerprint; it does not send the audio itself. +StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. Audio you import, the stems made from it, and the takes you record, are saved only in the library folder on the machine that runs StemDeck, and StemDeck does not upload, share or redistribute them. To find song details and lyrics it sends the song's name to the services named in its Settings and, only if you add an AcoustID key, an audio fingerprint; it does not send the audio itself. YouTube and SoundCloud link support is provided via [yt-dlp](https://github.com/yt-dlp/yt-dlp) as a convenience. Automated downloading may violate YouTube's Terms of Service. You, the user, are solely responsible for ensuring you have the right to process any audio you submit, complying with the terms of service of any site you download from, and respecting the copyright of the material you work with. @@ -258,9 +285,11 @@ The author(s) of StemDeck provide this software "as is", without warranty of any ## License -StemDeck is [Apache-2.0](LICENSE). +StemDeck is free software under the [GNU Affero General Public License v3.0 only](LICENSE) (AGPL-3.0-only). You may use, study, change and share it. If you change it and let other people use your version over a network, the AGPL asks you to offer them its source code too; the About dialog links to the source for that reason. -Every download ships a `THIRD_PARTY_NOTICES.txt` and a `licenses/` folder. `licenses/INDEX.txt` lists each packaged Python dependency with its version and license, and each one's full license text sits beside it. That inventory is generated from the packaged interpreter at build time, so it describes what actually shipped rather than what was expected to. +Releases up to and including 0.20.0 were published under Apache-2.0, and copies received under those terms keep them. + +Every download ships StemDeck's own `LICENSE.txt`, a `THIRD_PARTY_NOTICES.txt` and a `licenses/` folder. `licenses/INDEX.txt` lists each packaged Python dependency with its version and license, and each one's full license text sits beside it. That inventory is generated from the packaged interpreter at build time, so it describes what actually shipped rather than what was expected to. FFmpeg is a GPL build. StemDeck downloads it and runs it as a separate executable, so it does not change StemDeck's own license, and `THIRD_PARTY_NOTICES.txt` carries the written offer of source for the exact build your platform receives. @@ -281,3 +310,5 @@ Questions, ideas and show-and-tell are all welcome. ## Contributing Issues, feature suggestions and pull requests are welcome. See the [open issues](https://github.com/stemdeckapp/stemdeck/issues) for what is planned, and [Build from source](docs/build-from-source.md) to run it yourself. + +If you would like to support the time I put into StemDeck, a Donate button shows in the app's left rail as soon as donating is set up. Donating is optional: StemDeck stays free, with every feature, for everyone. diff --git a/SECURITY.md b/SECURITY.md index 9b76a6f9..5dd55051 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,6 +37,11 @@ What to expect: StemDeck is local-first and single-user by design: it runs on your own machine, has no authentication, and is same-origin only. Reports that it "has no login" or "no per-user access control" describe intended behavior, not vulnerabilities. +The desktop backend listens on `127.0.0.1` only while network access is off, +so other devices cannot connect, and on every interface once it is on. A +server (Docker, Unraid, `run.sh`) listens on every interface and refuses other +devices itself while network access is off; see +[Who can reach StemDeck](docs/configuration.md#who-can-reach-stemdeck). We are most interested in reports about: diff --git a/app/api/backing.py b/app/api/backing.py new file mode 100644 index 00000000..4227c9f1 --- /dev/null +++ b/app/api/backing.py @@ -0,0 +1,113 @@ +"""A song's backing track without one stem (Streamer Mode's "I play"), and +the rest of a song for any set of stems (a studio mode's Original). + + GET|HEAD /api/jobs/{id}/backing/{stem}.wav the backing, built on first ask + GET|HEAD /api/jobs/{id}/rest/{set}.wav the sum of `set`, built on first ask + +`set` is base stems joined by "-", sorted, each once (app/pipeline/backing.py +parse_rest_set): one set has one spelling, so one cached file. + +`stem` is one of the six Demucs stems. The file is mixed from the stems on +disk the first time it is asked for and kept in the data cache +(app/pipeline/backing.py); later requests are served from it, Range requests +included (the audio engine reads WAVs in windows). Errors say what is wrong, +never where on disk. +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +from app.core.config import JOB_ID_RE, JOBS_DIR, TIMEOUT_BACKING +from app.core.registry import get as registry_get +from app.pipeline.backing import ( + REMOVABLE, + BackingError, + ensure_backing, + ensure_rest, + in_cache, + parse_rest_set, +) + +logger = logging.getLogger("stemdeck.api") + +router = APIRouter(tags=["jobs"]) + + +@router.api_route("/{job_id}/backing/{stem}.wav", methods=["GET", "HEAD"], response_model=None) +async def get_backing(job_id: str, stem: str) -> FileResponse: + # Validation order (security skill): the job id, then the stem against + # the whitelist, then the resolved job folder inside the jobs folder, then + # I/O; the built file is served only from inside the backing cache. + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + if stem not in REMOVABLE: + raise HTTPException(status_code=422, detail="unknown stem") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + root = JOBS_DIR.resolve() + job_dir = (JOBS_DIR / job_id).resolve() + if not job_dir.is_relative_to(root) or not job_dir.is_dir(): + raise HTTPException(status_code=404, detail="job not found") + try: + path, built = await asyncio.wait_for( + asyncio.to_thread(ensure_backing, job_id, job_dir, stem), timeout=TIMEOUT_BACKING + ) + except BackingError: + logger.warning("[%s] backing without %s not made", job_id, stem, exc_info=True) + raise HTTPException(status_code=404, detail="no stems to make a backing from") from None + except TimeoutError: + logger.warning("[%s] backing without %s timed out", job_id, stem) + raise HTTPException(status_code=500, detail="the backing took too long") from None + except OSError: + logger.warning("[%s] backing without %s failed", job_id, stem, exc_info=True) + raise HTTPException(status_code=500, detail="the backing could not be made") from None + if built: + logger.info("[%s] backing without %s built", job_id, stem) + # Not a file: the song was deleted since (forget_job). + if not path.is_file() or not in_cache(path): + raise HTTPException(status_code=404, detail="job not found") + return FileResponse(path, media_type="audio/wav") + + +@router.api_route("/{job_id}/rest/{stem_set}.wav", methods=["GET", "HEAD"], response_model=None) +async def get_rest(job_id: str, stem_set: str) -> FileResponse: + """The sum of the stems in `stem_set`: a studio mode's Original when its + lanes differ from the song's own selection. Same validation order and + errors as the backing.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + try: + names = parse_rest_set(stem_set) + except ValueError: + raise HTTPException(status_code=422, detail="unknown stem set") from None + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + root = JOBS_DIR.resolve() + job_dir = (JOBS_DIR / job_id).resolve() + if not job_dir.is_relative_to(root) or not job_dir.is_dir(): + raise HTTPException(status_code=404, detail="job not found") + try: + path, built = await asyncio.wait_for( + asyncio.to_thread(ensure_rest, job_id, job_dir, names), timeout=TIMEOUT_BACKING + ) + except BackingError: + logger.warning("[%s] rest %s not made", job_id, stem_set, exc_info=True) + raise HTTPException(status_code=404, detail="the stems for this set are missing") from None + except TimeoutError: + logger.warning("[%s] rest %s timed out", job_id, stem_set) + raise HTTPException(status_code=500, detail="the mix took too long") from None + except OSError: + logger.warning("[%s] rest %s failed", job_id, stem_set, exc_info=True) + raise HTTPException(status_code=500, detail="the mix could not be made") from None + if built: + logger.info("[%s] rest %s built", job_id, stem_set) + if not path.is_file() or not in_cache(path): + raise HTTPException(status_code=404, detail="job not found") + return FileResponse(path, media_type="audio/wav") diff --git a/app/api/discogs.py b/app/api/discogs.py index 9593602b..d9e29404 100644 --- a/app/api/discogs.py +++ b/app/api/discogs.py @@ -46,7 +46,7 @@ def cancelled() -> bool: return await asyncio.wait_for( asyncio.to_thread(func, *args, cancelled=cancelled), DISCOGS_SEARCH_BUDGET_SEC + 1 ) - except asyncio.TimeoutError: + except TimeoutError: logger.info("Discogs artist request timed out") return None diff --git a/app/api/jobs.py b/app/api/jobs.py index af132f5b..107b231a 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -7,10 +7,12 @@ import os import re import shutil +import stat import subprocess import threading import time import uuid +from collections import deque from pathlib import Path from typing import Literal @@ -18,10 +20,13 @@ from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field, StrictBool, field_validator +from app.api.takes import cancel_job_renders, forget_job_locks from app.core.config import ( DISCOGS_LOOKUP_BUDGET_SEC, JOB_ID_RE, JOBS_DIR, + LIBRARY_RACY_WINDOW_NS, + LOUDNESS_UPGRADE_QUEUE_MAX, LYRICS_LOOKUP_BUDGET_SEC, LYRICS_OFFSET_MAX_SEC, LYRICS_TIMED_MAX_CHARS, @@ -33,14 +38,24 @@ TIMEOUT_IDENTIFY_BACKFILL, TIMEOUT_LYRICS_LOOKUP, TIMEOUT_WORK_BACKFILL, + UPLOAD_EXTS, ffprobe_executable, ) -from app.core.models import FINISHED_STATUSES, Job, JobCancelled, _set, settle_cancelled +from app.core.models import ( + FINISHED_STATUSES, + LOUDNESS_VERSION, + Job, + JobBusy, + JobCancelled, + _set, + settle_cancelled, +) 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 is_upload +from app.core.registry import is_upload, keep_added_time, metadata_lock, saved_fields from app.core.registry import mark_deleted as registry_mark_deleted +from app.core.registry import operation as registry_operation 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 @@ -50,14 +65,31 @@ from app.core.registry import set_trashed as registry_set_trashed from app.core.settings import ( get_acoustid_api_key, + get_auto_lyrics, get_auto_sections, get_discogs_token, get_max_duration_sec, ) +from app.core.stem_files import stem_path, stem_url from app.core.stems_location import is_relocating -from app.pipeline import discogs, jobqueue +from app.core.versions import ( + MAX_VERSIONS, + VERSION_ID_RE, + VERSION_NAME_MAX_CHARS, + active_of, + clean_name, + clean_stems, + make_version, + versions_of, +) +from app.core.vocal_split_outcome import CANCELLED as SPLIT_CANCELLED +from app.core.vocal_split_outcome import WORKER_FAILED as SPLIT_WORKER_FAILED +from app.core.vocal_split_outcome import message_for as split_message_for +from app.pipeline import discogs, jobqueue, vocal_split_status +from app.pipeline.analyze import dynamic_range_db, measure_loudness from app.pipeline.artist_lookup import tagged_artist_name from app.pipeline.audio_tags import probe_tags +from app.pipeline.backing import forget_job as forget_backing from app.pipeline.collect import merge_stem_peaks, presence_for_split, remove_job_dir from app.pipeline.download import InvalidYouTubeURL, fetch_audio_tags, validate_youtube_url from app.pipeline.errors import classify_failure @@ -93,13 +125,14 @@ ) from app.pipeline.lyrics_retime import saved_view as saved_retime_view from app.pipeline.runner import _pipeline_lock -from app.pipeline.vocal_split import split_vocals +from app.pipeline.stem_fill import FillRefused, cancel_fill, fill_stems, missing_stems +from app.pipeline.stem_storage import store_split_stems +from app.pipeline.vocal_split import VocalSplitError, split_vocals from app.pipeline.work_lookup import find_work, might_have_work router = APIRouter(tags=["jobs"]) logger = logging.getLogger("stemdeck.api") -_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus")) _MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB _WS_RE = re.compile(r"\s+") @@ -175,36 +208,162 @@ def _rmtree_job(job_id: str) -> bool: return remove_job_dir(JOBS_DIR / job_id) -def _job_files_missing(job: Job) -> bool: +# GET /api/jobs reports every finished song's files as present or missing, on +# every call (#976). Asking the disk afresh each time cost two path resolves, a +# stat and a directory listing per song, which is one network round trip after +# another on an SMB or NAS library. The answer is now kept per song, beside the +# identity and modification time of the stems folder it was read from, and used +# only while one lstat of that folder still returns exactly those values. The +# song folder itself is checked from one listing of the jobs folder per request +# (_LibraryView), not one call per song. +# +# What invalidates it: deleting, renaming or replacing the song folder or its +# stems folder (the folder is gone from the listing, the lstat fails, or the +# identity changes), adding or removing a file directly in the stems folder +# (its modification time changes), either folder turned into a link or junction +# (no longer a plain folder, so the full check runs and refuses a target +# outside the library, as the serving routes do). Nothing expires on a timer, +# because nothing needs to: every change that alters the answer alters the key. +# The jobs folder is not part of the key: JOBS_DIR is fixed for the life of the +# process (a library move restarts it), and any other path to the same folder +# goes through a link, which the plain-folder checks already refuse to trust. +# +# What it cannot see: a stems folder put back with its exact identity and +# modification time, which only a timestamp-preserving copy onto the same file +# ID does. A change made within LIBRARY_RACY_WINDOW_NS of a listing could leave +# the time unchanged on a filesystem that stores it coarsely (FAT keeps two +# seconds), so a folder that recent is never trusted; a file server whose +# clock runs behind this machine's narrows that guard by the difference. +_DirKey = tuple[int, int, int] # st_dev, st_ino, st_mtime_ns +_stems_answers: dict[str, tuple[_DirKey, bool]] = {} +_no_source_seen: dict[str, _DirKey] = {} + + +def _dir_key(st: os.stat_result) -> _DirKey: + return (st.st_dev, st.st_ino, st.st_mtime_ns) + + +class _LibraryView: + """One request's look at the jobs folder: JOBS_DIR resolved once, and, + for a request about many songs, one listing of it that says which song + folders exist and are plain folders, without a call per song.""" + + def __init__(self, *, listed: bool) -> None: + self.root = JOBS_DIR.resolve() + self._entries: dict[str, os.DirEntry] | None = None + if listed: + try: + with os.scandir(JOBS_DIR) as it: + self._entries = {entry.name: entry for entry in it} + except OSError: + self._entries = None + + def song_dir_is_plain(self, job_id: str) -> bool: + """Whether the song folder is a plain folder, not a file, link or + junction. Raises FileNotFoundError when it is not there. + + Only the kind is read from the listing, never the time: NTFS updates + the copy of a folder's times kept in its parent's index lazily, so a + listing can show a song folder's old modification time after + something was added to it.""" + if self._entries is None: + return _is_plain_dir(os.lstat(JOBS_DIR / job_id)) + entry = self._entries.get(job_id) + if entry is None: + raise FileNotFoundError(job_id) + if not entry.is_dir(follow_symlinks=False): + return False + if os.name == "nt": + # Carried by the listing on Windows, so this costs no call. A + # junction reports itself as a folder and is told apart here. + return _is_plain_dir(entry.stat(follow_symlinks=False)) + return True + + +def _is_plain_dir(st: os.stat_result) -> bool: + """A real folder: not a file, and not a link or junction (lstat follows + neither, and a junction still reports itself as a folder).""" + return stat.S_ISDIR(st.st_mode) and not ( + getattr(st, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT + ) + + +def _settled(st: os.stat_result) -> bool: + """Changed long enough ago for its modification time to be trusted.""" + return time.time_ns() - st.st_mtime_ns >= LIBRARY_RACY_WINDOW_NS + + +def _stems_missing_on_disk(job_id: str, jobs_root: Path) -> bool: + """The full check: resolves the stems folder, refuses one that leads out + of the library (the serving routes refuse it too), and lists it.""" + stems_dir = (JOBS_DIR / job_id / "stems").resolve() + if not stems_dir.is_relative_to(jobs_root): + return True + return not stems_dir.is_dir() or not any(stems_dir.iterdir()) + + +def _job_files_missing(job: Job, view: _LibraryView | None = None) -> bool: """True when a "done" job's stem files are gone from disk: the folder was deleted or moved outside the app, not just an in-flight relocation (#354), which is a known, temporary absence and must not flap the library.""" if is_relocating(): return False - stems_dir = (JOBS_DIR / job.id / "stems").resolve() - if not stems_dir.is_relative_to(JOBS_DIR.resolve()): + view = view or _LibraryView(listed=False) + try: + song_dir_plain = view.song_dir_is_plain(job.id) + stems = os.lstat(JOBS_DIR / job.id / "stems") + except FileNotFoundError: return True - return not stems_dir.is_dir() or not any(stems_dir.iterdir()) + except OSError: + return _stems_missing_on_disk(job.id, view.root) + if not song_dir_plain or not _is_plain_dir(stems) or not _settled(stems): + return _stems_missing_on_disk(job.id, view.root) + key = _dir_key(stems) + kept = _stems_answers.get(job.id) + if kept is not None and kept[0] == key: + return kept[1] + missing = _stems_missing_on_disk(job.id, view.root) + _stems_answers[job.id] = (key, missing) + return missing + + +def _fill_source_format(job: Job) -> None: + """Uploads made before the format was recorded still have it on disk: the + kept source is source., the extension it was validated against. Kept + on the job once found, and persisted with it next time. Uploads finished + before sources were kept have nothing to find and stay None, which the + client shows as the plain note; for those the empty answer is kept beside + the song folder's identity and modification time, so the folder is + searched again only once an entry in it has been added, removed or + renamed, or the folder itself replaced.""" + try: + # The folder's own lstat, not the listing's copy (see song_dir_is_plain). + song_dir = os.lstat(JOBS_DIR / job.id) + except OSError: + song_dir = None + key = None + if song_dir is not None and _is_plain_dir(song_dir) and _settled(song_dir): + key = _dir_key(song_dir) + if _no_source_seen.get(job.id) == key: + return + source = _retained_source(job.id) + if source is not None: + job.source_format = source.suffix.lower().removeprefix(".") + _no_source_seen.pop(job.id, None) + elif key is not None: + _no_source_seen[job.id] = key -def _job_state(job: Job) -> dict: +def _job_state(job: Job, view: _LibraryView | None = None) -> dict: """job.to_state() with "done" downgraded to "unavailable" when the stem files are missing from disk - ground truth for the client, replacing the old approach of the frontend guessing from a 404 or a disappearance from the job list, neither of which caught a job whose registry entry survived but whose stems folder did not.""" if job.source_format is None and (job.source_url or "").startswith("local:"): - # Uploads made before the format was recorded still have it on disk: - # the kept source is source., the extension it was validated - # against. Kept on the job once found, so the disk is asked once per - # job rather than on every state, and persisted with it next time. - # Uploads finished before sources were kept have nothing to find and - # stay None, which the client shows as the plain note. - source = _retained_source(job.id) - if source is not None: - job.source_format = source.suffix.lower().removeprefix(".") + _fill_source_format(job) state = job.to_state() - if job.status == "done" and _job_files_missing(job): + if job.status == "done" and _job_files_missing(job, view): state["status"] = "unavailable" return state @@ -217,19 +376,22 @@ class JobRequest(BaseModel): # rejected, so a future model with extra stems doesn't break older # clients pinning the old set. stems: list[str] | None = None + # Lead + Backing (#848): split the vocals once the job is done. Ignored + # when the selection has no vocals. + vocal_split: bool = False + + +def _note_split_request(job: Job) -> None: + """Say in the log that Lead + Backing came with the import, so the log + tells a split that was never asked for from one that ran.""" + if job.vocal_split_wanted: + logger.info("[%s] vocal split requested with the import", job.id) @router.post("") async def create_job(request: Request) -> dict[str, str]: """Submit a YouTube URL (JSON body) or upload an audio file (multipart/form-data) to start a stem-separation job. Returns the new job ID.""" - if is_relocating(): - # The stems folder just moved. This process still writes to the old one, - # so anything accepted now would be orphaned by the restart. - raise HTTPException( - status_code=409, - detail="Restart StemDeck to finish moving your stems folder before importing", - ) ct = request.headers.get("content-type", "") if "multipart/form-data" in ct: return await _create_local_job(request) @@ -263,11 +425,14 @@ async def _create_youtube_job(request: Request) -> dict[str, str]: # last thing the pipeline does, and the toggle clears itself as soon # as the user opens another song. auto_sections=get_auto_sections(), + auto_lyrics=get_auto_lyrics(), + vocal_split_wanted=payload.vocal_split and "vocals" in selected, ) if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS): raise HTTPException(status_code=503, detail=_URL_QUEUE_FULL_DETAIL) jobqueue.enqueue(job.id) - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) + _note_split_request(job) return {"job_id": job.id} @@ -298,7 +463,7 @@ async def _create_local_job(request: Request) -> dict[str, str]: filename: str = getattr(upload, "filename", "") or "" ext = Path(filename).suffix.lower() - if ext not in _ALLOWED_EXTS: + if ext not in UPLOAD_EXTS: raise HTTPException( status_code=422, detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, .m4a, .ogg, and .opus", @@ -335,7 +500,9 @@ async def _create_local_job(request: Request) -> dict[str, str]: try: duration = await asyncio.to_thread(_probe_duration, source_path) except Exception as e: - raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e + # The error names ffprobe's path and the job folder: log it, do not send it. + logger.warning("[%s] could not read the upload's duration", job_id, exc_info=True) + raise HTTPException(status_code=422, detail="Could not read file duration") from e max_duration = get_max_duration_sec() if duration > max_duration: @@ -362,12 +529,16 @@ async def _create_local_job(request: Request) -> dict[str, str]: source_format=ext.removeprefix("."), audio_tags=audio_tags, auto_sections=get_auto_sections(), + auto_lyrics=get_auto_lyrics(), + vocal_split_wanted=form.get("vocal_split") == "true" and "vocals" in selected, ) + await asyncio.to_thread(keep_added_time, job, job_dir) if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS): shutil.rmtree(job_dir, ignore_errors=True) raise HTTPException(status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL) jobqueue.enqueue(job.id) - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) + _note_split_request(job) return {"job_id": job.id} @@ -380,9 +551,15 @@ def list_jobs(trashed: Literal["exclude", "include", "only"] = "exclude") -> lis Trash moved server-side it happily listed tracks the user had deleted on their desktop hours earlier. """ - jobs = sorted(registry_all_jobs().values(), key=lambda j: j.created_at) + everything = registry_all_jobs() + # Answers kept for songs no longer in the registry would only grow. + for kept in (_stems_answers, _no_source_seen): + for job_id in [job_id for job_id in list(kept) if job_id not in everything]: + kept.pop(job_id, None) + jobs = sorted(everything.values(), key=lambda j: j.created_at) + view = _LibraryView(listed=True) return [ - _job_state(job) + _job_state(job, view) for job in jobs if job.status == "done" and (trashed == "include" or (job.trashed_at is not None) == (trashed == "only")) @@ -410,6 +587,7 @@ def trash_job(job_id: str) -> dict: job.stop_requested = True _cancel_pipeline(job) registry_persist(JOBS_DIR) + _keep_saved_fields(job) return {"job_id": job.id, "trashed_at": job.trashed_at} @@ -422,12 +600,25 @@ def restore_job(job_id: str) -> dict: if job is None: raise HTTPException(status_code=404, detail="job not found") registry_persist(JOBS_DIR) + _keep_saved_fields(job) return {"job_id": job.id, "trashed_at": job.trashed_at} +def _failed_dir(job_id: str) -> Path: + """jobs/failed/, the quarantine _quarantine_failed_job moves a failed + job to. JOB_ID_RE rejects "failed", so the quarantine dir can never be + addressed as a job id; join it explicitly and re-verify the result stays + inside.""" + failed_dir = (JOBS_DIR / "failed" / job_id).resolve() + if not failed_dir.is_relative_to((JOBS_DIR / "failed").resolve()): + raise HTTPException(status_code=404, detail="job not found") + return failed_dir + + @router.post("/{job_id}/extract") def extract_stopped_job(job_id: str) -> dict: - """Run a stopped job again from the start, from the source it kept (#748). + """Run a stopped job, or a failed upload, again from the start, from the + source it kept (#748). The only way a job stopped by the Trash goes back in the queue. Restore does not do it: taking a song out of the Trash is not a request to spend @@ -439,16 +630,46 @@ def extract_stopped_job(job_id: str) -> dict: job = registry_get(job_id) if job is None: raise HTTPException(status_code=404, detail="job not found") - if job.status != "stopped": - raise HTTPException(status_code=409, detail="only a stopped job can be extracted again") + # A failed upload too: its file is the one copy, kept by the quarantine + # (#868). A failed link is tried again as a new import of the link. + failed_upload = job.status == "error" and is_upload(job) + if job.status != "stopped" and not failed_upload: + raise HTTPException( + status_code=409, detail="only a stopped job or a failed upload can be extracted again" + ) if job.trashed_at is not None: raise HTTPException(status_code=409, detail="restore it from the Trash first") + job_dir = JOBS_DIR / job_id + failed_dir = _failed_dir(job_id) + # Not when the quarantine failed and left the folder where it was. + moved = failed_upload and not any(job_dir.glob("source.*")) + if moved: + if not any(failed_dir.glob("source.*")): + raise HTTPException(status_code=409, detail="the file is no longer kept") + try: + failed_dir.rename(job_dir) + except OSError as exc: + logger.warning("could not move %s back for extract", failed_dir, exc_info=True) + raise HTTPException( + status_code=409, detail="the kept file could not be moved back" + ) from exc limit = MAX_PENDING_UPLOAD_JOBS if is_upload(job) else MAX_PENDING_URL_JOBS - if not registry_requeue_if_capacity(job, JOBS_DIR / job_id, limit): + if not registry_requeue_if_capacity(job, job_dir, limit): + if moved: + try: + job_dir.rename(failed_dir) + except OSError: + # Not lost: the next press runs it from jobs/. + logger.warning("could not put %s back after a full queue", job_dir, exc_info=True) raise HTTPException( status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL if is_upload(job) else _URL_QUEUE_FULL_DETAIL, ) + try: + (job_dir / "error.txt").unlink(missing_ok=True) + except OSError: + # The job is queued already; a stale error.txt only costs a few KB. + logger.warning("could not delete the old error.txt of %s", job_id, exc_info=True) registry_persist(JOBS_DIR) jobqueue.enqueue(job_id) return job.to_state() @@ -470,15 +691,120 @@ def set_favorite(job_id: str, body: FavoriteBody) -> dict: if job is None: raise HTTPException(status_code=404, detail="job not found") registry_persist(JOBS_DIR) + _keep_saved_fields(job) return {"job_id": job.id, "favorite": job.favorite} +# Songs whose loudness was measured again this run, queued for it, or tried and +# failed: one attempt each, so a song with nothing to measure does not cost an +# ffmpeg run on every request. A restart tries the failures once more. +_LOUDNESS_TRIED: set[str] = set() +# The songs waiting for the one background thread that measures them, oldest +# first. _LOUDNESS_LOCK covers the set, this queue and _LOUDNESS_WORKER. +_LOUDNESS_PENDING: deque[str] = deque() +_LOUDNESS_LOCK = threading.Lock() +_LOUDNESS_WORKER: threading.Thread | None = None + + +def _loudness_sources(job_id: str) -> tuple[str, list[Path]]: + """What to measure for a finished song, and which of LOUDNESS_BASES that + is: its kept upload if it has one ("source"), else the sum of its stems + ("stems", the mix; the link's source is deleted once separated).""" + source = _retained_source(job_id) + if source is not None: + return "source", [source] + stems_dir = (JOBS_DIR / job_id / "stems").resolve() + if not stems_dir.is_relative_to(JOBS_DIR.resolve()): + return "stems", [] + return "stems", [p for p in (stem_path(stems_dir, n) for n in STEM_NAMES) if p.is_file()] + + +def _queue_loudness_upgrade(job: Job) -> None: + """Queue a finished song whose stored loudness comes from an older + measurement (see LOUDNESS_VERSION) to be measured again in the background + (F11): the request answers with the stored numbers, and the next one has + the new. A full queue, or a separation running, leaves the song for a + later open.""" + global _LOUDNESS_WORKER + if job.status != "done" or job.loudness_version == LOUDNESS_VERSION: + return + if jobqueue.running_id() is not None: + return + with _LOUDNESS_LOCK: + if job.id in _LOUDNESS_TRIED or len(_LOUDNESS_PENDING) >= LOUDNESS_UPGRADE_QUEUE_MAX: + return + _LOUDNESS_TRIED.add(job.id) + _LOUDNESS_PENDING.append(job.id) + if _LOUDNESS_WORKER is None: + worker = threading.Thread( + target=_upgrade_queued_loudness, name="loudness-upgrade", daemon=True + ) + worker.start() + _LOUDNESS_WORKER = worker + + +def _upgrade_queued_loudness() -> None: + """The background thread: one song at a time, then one registry save for + all of them. metadata.json gets each song's numbers as they come.""" + global _LOUDNESS_WORKER + measured = False + while True: + with _LOUDNESS_LOCK: + if not _LOUDNESS_PENDING: + _LOUDNESS_WORKER = None + break + job_id = _LOUDNESS_PENDING.popleft() + try: + measured = _upgrade_loudness(job_id) or measured + except Exception: + logger.exception("[%s] loudness measurement failed", job_id) + if measured: + registry_persist(JOBS_DIR) + + +def _upgrade_loudness(job_id: str) -> bool: + """Measure one queued song, about 2 s, up to TIMEOUT_ANALYZE. Best effort: + on failure the old numbers stay and the song is not retried until a + restart. True when the song has new numbers.""" + job = registry_get(job_id) + if job is None or job.status != "done": + return False + if jobqueue.running_id() is not None: + # Never beside a separation (checked when queued, and again here for + # one that started since): this song is measured on a later open. + with _LOUDNESS_LOCK: + _LOUDNESS_TRIED.discard(job_id) + return False + basis, sources = _loudness_sources(job_id) + if not sources or is_relocating(): + return False + lufs, peak_db = measure_loudness(sources) + if lufs is None or peak_db is None: + return False + dr = dynamic_range_db(lufs, peak_db) + # _set writes in this order, and a GET can read the job between two + # writes. The basis goes first and the version last, so no reader sees + # the current version without the basis that says the numbers moved + # (#923): the page would take the song as never measured again. + fields = { + "loudness_remeasured": basis, + "lufs": lufs, + "peak_db": peak_db, + "dynamic_range": dr, + "loudness_version": LOUDNESS_VERSION, + } + _set(job, **fields) + _write_metadata_fields(job_id, fields) + return True + + @router.get("/{job_id}") def get_job(job_id: str) -> dict: """Get the current state of a job by ID.""" job = registry_get(job_id) if job is None: raise HTTPException(status_code=404, detail="job not found") + _queue_loudness_upgrade(job) return _job_state(job) @@ -489,21 +815,33 @@ def cancel_job(job_id: str) -> dict: if job is None: raise HTTPException(status_code=404, detail="job not found") if job.status in FINISHED_STATUSES: + # Extract missing stems first: a split or a lyrics timing waiting + # behind it would otherwise take the cancel and terminate the fill's + # worker under this id. Its stages read a stand-in's flag, which + # cancel_fill sets once the fill holds the pipeline lock; before that, + # the song's flag stops it from starting, and the process under this + # id is someone else's, so it is left alone. + if job.stem_fill == "running": + job.cancel_requested = True + if cancel_fill(job_id): + proc = registry_get_proc(job_id, "fill") + if proc is not None and proc.poll() is None: + proc.terminate() # 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": + elif job.vocal_split == "running": job.cancel_requested = True - proc = registry_get_proc(job_id) + proc = registry_get_proc(job_id, "split") if proc is not None and proc.poll() is None: proc.terminate() # A line-by-line timing of its lyrics (POST .../lyrics/retime) stops # the same way: the run sees its own flag, never the job's, and # leaves the lyrics as they were. elif cancel_run(job_id): - proc = registry_get_proc(job_id) + proc = registry_get_proc(job_id, "retime") if proc is not None and proc.poll() is None: proc.terminate() return job.to_state() @@ -511,6 +849,29 @@ def cancel_job(job_id: str) -> dict: return job.to_state() +@router.post("/{job_id}/stop") +def stop_job(job_id: str) -> dict: + """Take a job out of the Queue: stop its extraction and keep the song (#772). + + What the Queue's remove button sends. It used to send cancel, which deletes + the job's folder, so the song left the Library as well and never reached + the Trash. A stop settles as "stopped" with its files kept, the state a + trashed import ends in (#748) but out of the Trash, so the Library shows it + with Extract. A link that had not downloaded yet keeps its link, and + Extract fetches it again. A job already finished is left as it is. + """ + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status not in FINISHED_STATUSES: + job.stop_requested = True + _cancel_pipeline(job) + registry_persist(JOBS_DIR) + return job.to_state() + + def _cancel_pipeline(job: Job) -> None: """Stop a job that is waiting or running. Shared by cancel and trash (#748). @@ -558,6 +919,7 @@ class ResplitBody(BaseModel): trusted, and an empty result falls back to every stem.""" stems: list[str] = Field(default_factory=list, max_length=len(STEM_NAMES)) + vocal_split: bool = False def _retained_source(job_id: str) -> Path | None: @@ -575,7 +937,7 @@ def _retained_source(job_id: str) -> Path | None: resolved = candidate.resolve() if ( resolved.is_file() - and resolved.suffix.lower() in _ALLOWED_EXTS + and resolved.suffix.lower() in UPLOAD_EXTS and resolved.is_relative_to(JOBS_DIR.resolve()) ): return resolved @@ -675,12 +1037,18 @@ async def resplit_job(job_id: str, body: ResplitBody) -> dict: work=job.work, has_lyrics=has_lyrics, auto_sections=get_auto_sections(), + # The song's lyrics came along with it: keep looking for them only if + # Detect lyrics is on. + auto_lyrics=get_auto_lyrics(), + vocal_split_wanted=body.vocal_split and "vocals" in selected, ) + await asyncio.to_thread(keep_added_time, new_job, new_dir) if not registry_register_if_capacity(new_job, MAX_PENDING_UPLOAD_JOBS): shutil.rmtree(new_dir, ignore_errors=True) raise HTTPException(status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL) jobqueue.enqueue(new_job.id) - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) + _note_split_request(new_job) # source_url comes back because the client cannot derive it: it is # deliberately not the one it asked with, and the library keys its # dedup on exactly this string. @@ -703,45 +1071,284 @@ async def start_vocal_split(job_id: str) -> Response: raise HTTPException(status_code=409, detail="vocal split already running") if job.vocal_split == "done": return JSONResponse(_job_state(job), status_code=202) + # Refused, not queued behind it: starting it here cleared the cancel sent + # to the fill. No await from here to "running", and the fill re-checks + # after its own, so the two never both start. + if job.stem_fill == "running": + raise JobBusy() stems_dir = (JOBS_DIR / job_id / "stems").resolve() if not stems_dir.is_relative_to(JOBS_DIR.resolve()): raise HTTPException(status_code=404, detail="job not found") + # A cancel is for the run it was sent to. One left over from an earlier + # run would otherwise read as this one being cancelled. + job.cancel_requested = False job.vocal_split = "running" + job.vocal_split_reason = None _set(job, stage="Splitting lead/backing vocals...") + # Saved before it starts: a restart during the split then reads it as + # interrupted, so a page that finds the job asking for Lead + Backing + # (vocal_split_wanted) never starts it a second time. + await asyncio.to_thread(registry_persist, JOBS_DIR) try: async with _pipeline_lock: - new_names = await asyncio.to_thread(split_vocals, job, stems_dir) + # Cancelled while it waited behind an import: never start. + if job.cancel_requested: + raise VocalSplitError( + "vocal split cancelled before it started", code=SPLIT_CANCELLED + ) + logger.info("[%s] vocal split started", job_id) + started = time.monotonic() + with registry_operation("split"): + new_names = await asyncio.to_thread(split_vocals, job, stems_dir) + # The peaks are read from the new WAVs first; then the two are + # kept the way the song keeps its stems (#783). Neither raises. + rms_values = await asyncio.to_thread( + merge_stem_peaks, stems_dir, ["vocals", *new_names] + ) + await asyncio.to_thread(store_split_stems, job, stems_dir, new_names) except Exception as e: cause = classify_failure("\n".join([*(getattr(e, "tail", None) or []), str(e)])) logger.warning("[%s] vocal split failed: %s", job_id, e, exc_info=True) _write_vocal_split_error(stems_dir, cause, getattr(e, "tail", None) or [str(e)]) + # The song stays done with its Vocals stem, and now says why the pair + # is missing (#832). Only the code leaves the server: the exception's + # words stay in the log and vocal_split_error.txt. + code = getattr(e, "code", None) or ( + SPLIT_CANCELLED if job.cancel_requested else SPLIT_WORKER_FAILED + ) + vocal_split_status.record(False, code, source="split") + job.cancel_requested = False job.vocal_split = "error" + job.vocal_split_reason = code _set(job, stage="Done") - registry_persist(JOBS_DIR) - raise HTTPException(status_code=500, detail="vocal split failed") from e + await asyncio.to_thread(registry_persist, JOBS_DIR) + return JSONResponse( + { + "detail": "vocal split failed", + "vocal_split_reason": code, + "vocal_split_message": split_message_for(code), + }, + status_code=500, + ) + vocal_split_status.record(True, source="split") + logger.info( + "[%s] vocal split done in %.1fs: %s", + job_id, + time.monotonic() - started, + ", ".join(new_names), + ) existing = {s["name"] for s in job.stems} for name in new_names: if name not in existing: - job.stems.append({"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"}) - # "vocals" rides along so presence_for_split can recover the scale the base - # stems were normalised against; without it the two new cards would have no - # percentage to show. - rms_values = merge_stem_peaks(stems_dir, ["vocals", *new_names]) + job.stems.append({"name": name, "url": stem_url(job_id, stems_dir, name)}) + # "vocals" rode along in merge_stem_peaks above so presence_for_split can + # recover the scale the base stems were normalised against; without it the + # two new cards would have no percentage to show. extra_presence = presence_for_split(rms_values, job.stem_presence) if extra_presence: job.stem_presence = {**(job.stem_presence or {}), **extra_presence} + # A cancel that arrived after the worker finished lost the race. Left set, + # it would stop the next run on this song that reads the flag. + job.cancel_requested = False job.vocal_split = "done" _set(job, stage="Done") - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) return JSONResponse(_job_state(job)) +# -- Versions (#804) +# +# A version is a named choice of stems over the song's one set of stem files +# (app/core/versions.py). These endpoints only ever change the song's record: +# none of them reads, writes or deletes a stem file. Each runs on the event +# loop with no await between reading the versions and writing them back, so +# two requests cannot interleave a read-modify-write. + + +class VersionCreateBody(BaseModel): + name: str | None = Field(default=None, max_length=VERSION_NAME_MAX_CHARS * 4) + stems: list[str] = Field(min_length=1, max_length=len(STEM_NAMES)) + # Make it the version that opens with the song. + select: StrictBool = False + + +class VersionRenameBody(BaseModel): + name: str = Field(max_length=VERSION_NAME_MAX_CHARS * 4) + + +def _versions_payload(job: Job) -> dict: + versions = versions_of(job) + return {"job_id": job.id, "versions": versions, "active_version": active_of(job, versions)} + + +def _version_job(job_id: str, *, ready: bool = True) -> Job: + """The job whose versions a request changes, after the id checks. + 404 for anything that is not a known job; 409 while it is not done.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if ready and job.status != "done": + raise HTTPException(status_code=409, detail="the song is not ready") + return job + + +def _check_version_id(version_id: str) -> None: + if not VERSION_ID_RE.match(version_id): + raise HTTPException(status_code=404, detail="version not found") + + +@router.get("/{job_id}/versions") +async def list_versions(job_id: str) -> dict: + """The versions of a song and the one that opens with it.""" + return _versions_payload(_version_job(job_id, ready=False)) + + +@router.post("/{job_id}/versions", status_code=201) +async def create_version(job_id: str, body: VersionCreateBody) -> dict: + """A new version of the song showing `stems`. Nothing is separated or + copied on disk: it is settings only.""" + job = _version_job(job_id) + stems = clean_stems(body.stems) + if not stems: + raise HTTPException(status_code=422, detail="choose at least one known stem") + versions = versions_of(job) + if len(versions) >= MAX_VERSIONS: + raise HTTPException(status_code=409, detail="this song has too many versions") + version = make_version(versions, body.name, stems) + versions.append(version) + active = version["id"] if body.select else active_of(job, versions) + _set(job, versions=versions, active_version=active) + await asyncio.to_thread(registry_persist, JOBS_DIR) + await asyncio.to_thread(_keep_saved_fields, job) + return {"version": version, **_versions_payload(job)} + + +@router.patch("/{job_id}/versions/{version_id}") +async def rename_version(job_id: str, version_id: str, body: VersionRenameBody) -> dict: + job = _version_job(job_id) + _check_version_id(version_id) + name = clean_name(body.name) + if name is None: + raise HTTPException(status_code=422, detail="a version needs a name") + versions = versions_of(job) + target = next((v for v in versions if v["id"] == version_id), None) + if target is None: + raise HTTPException(status_code=404, detail="version not found") + target["name"] = name + _set(job, versions=versions, active_version=active_of(job, versions)) + await asyncio.to_thread(registry_persist, JOBS_DIR) + await asyncio.to_thread(_keep_saved_fields, job) + return _versions_payload(job) + + +@router.delete("/{job_id}/versions/{version_id}") +async def delete_version(job_id: str, version_id: str) -> dict: + """Forget a version. Its stems stay: every version shares the song's one + set of stem files, and no stem file is ever deleted here. The last + version cannot be deleted: a song always has one.""" + job = _version_job(job_id) + _check_version_id(version_id) + versions = versions_of(job) + if not any(v["id"] == version_id for v in versions): + raise HTTPException(status_code=404, detail="version not found") + if len(versions) == 1: + raise HTTPException(status_code=409, detail="a song keeps at least one version") + remaining = [v for v in versions if v["id"] != version_id] + # The deleted one was opening with the song: the first left does now. + _set(job, versions=remaining, active_version=active_of(job, remaining)) + await asyncio.to_thread(registry_persist, JOBS_DIR) + await asyncio.to_thread(_keep_saved_fields, job) + return _versions_payload(job) + + +@router.post("/{job_id}/versions/{version_id}/select") +async def select_version(job_id: str, version_id: str) -> dict: + """Make a version the one that opens with the song.""" + job = _version_job(job_id) + _check_version_id(version_id) + versions = versions_of(job) + if not any(v["id"] == version_id for v in versions): + raise HTTPException(status_code=404, detail="version not found") + _set(job, versions=versions, active_version=version_id) + await asyncio.to_thread(registry_persist, JOBS_DIR) + await asyncio.to_thread(_keep_saved_fields, job) + return _versions_payload(job) + + +class FillStemsBody(BaseModel): + stems: list[str] = Field(min_length=1, max_length=len(STEM_NAMES)) + + +_FILL_REFUSALS = { + "no_source": "no source kept for this track", + "model": "this song was separated with another model", +} + + +@router.post("/{job_id}/fill-stems") +async def fill_missing_stems(job_id: str, body: FillStemsBody) -> JSONResponse: + """Extract on a song already in the library (#804): separate only the + stems of `stems` it has no file for, with the model and quality it was + separated with. The stems it has are never replaced. Answers at once, + with nothing separated, when none is missing; otherwise when the run is + over, with the song's state.""" + job = _version_job(job_id) + wanted = clean_stems(body.stems) + if not wanted: + raise HTTPException(status_code=422, detail="choose at least one known stem") + job_dir = (JOBS_DIR / job_id).resolve() + stems_dir = (job_dir / "stems").resolve() + if not stems_dir.is_dir() or not stems_dir.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="job not found") + if job.post_hoc_running(): + raise HTTPException(status_code=409, detail="the song is busy") + missing = await asyncio.to_thread(missing_stems, stems_dir, wanted) + if not missing: + return JSONResponse({"filled": [], "state": _job_state(job)}) + source = _retained_source(job_id) if (job.source_url or "").startswith("local:") else None + if source is None and (job.source_url or "local:").startswith("local:"): + raise HTTPException(status_code=409, detail=_FILL_REFUSALS["no_source"]) + # Checked again here, after the await above: the first of two requests + # to get this far is the one that runs, and a split started meanwhile + # keeps the song. + if job.post_hoc_running(): + raise HTTPException(status_code=409, detail="the song is busy") + # A cancel is for the run it was sent to, as for the vocal split. + job.cancel_requested = False + job.stem_fill = "running" + _set(job, stage="Extracting missing stems...") + try: + async with _pipeline_lock: + with registry_operation("fill"): + filled = await asyncio.to_thread(fill_stems, job, job_dir, wanted, source) + except FillRefused as e: + job.stem_fill = "error" + _set(job, stage="Done") + await asyncio.to_thread(registry_persist, JOBS_DIR) + raise HTTPException(status_code=409, detail=_FILL_REFUSALS[e.code]) from None + except Exception: + if not job.cancel_requested: + logger.exception("[%s] filling missing stems failed", job_id) + job.stem_fill = "error" + _set(job, stage="Done") + await asyncio.to_thread(registry_persist, JOBS_DIR) + # Stopped by POST .../cancel: every stem the song had is still there. + if job.cancel_requested: + return JSONResponse({"detail": "the extraction was cancelled"}, status_code=409) + return JSONResponse({"detail": "could not extract the missing stems"}, status_code=500) + job.stem_fill = "done" + _set(job, stage="Done") + await asyncio.to_thread(registry_persist, JOBS_DIR) + return JSONResponse({"filled": filled, "state": _job_state(job)}) + + _SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") _COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") -_SECTIONS_WRITE_LOCK = threading.Lock() def _write_json_atomic(path: Path, data: dict) -> None: @@ -831,7 +1438,7 @@ def update_sections(job_id: str, body: SectionsBody) -> dict: raise HTTPException(status_code=404, detail="job not found") meta_path = job_dir / "metadata.json" - with _SECTIONS_WRITE_LOCK: + with metadata_lock: meta: dict = {} try: if meta_path.is_file(): @@ -882,7 +1489,7 @@ def _write_metadata_fields(job_id: str, fields: dict[str, object]) -> None: if not meta_path.is_relative_to(JOBS_DIR.resolve()): return # The same lock as the sections editor, the other read-modify-write here. - with _SECTIONS_WRITE_LOCK: + with metadata_lock: try: if not meta_path.is_file(): return @@ -892,7 +1499,15 @@ def _write_metadata_fields(job_id: str, fields: dict[str, object]) -> None: meta.update(fields) _write_json_atomic(meta_path, meta) except (OSError, ValueError): - logger.warning("could not record audio tags in metadata for %s", job_id, exc_info=True) + logger.warning("could not update metadata.json for %s", job_id, exc_info=True) + + +def _keep_saved_fields(job: Job) -> None: + """Copy what the user set on a song (favourite, Trash, versions) into its + metadata.json, so a registry rebuilt from the folders keeps it. Read under + the lock, so of two quick changes the later one is what stays.""" + with metadata_lock: + _write_metadata_fields(job.id, saved_fields(job)) @router.post("/{job_id}/audio-tags") @@ -934,7 +1549,7 @@ async def refresh_audio_tags(job_id: str) -> dict: if ( job.audio_tags and (job.artist or not tagged_artist_name(job.audio_tags.get("artist"))) - and lyrics_settled(job_dir) + and (lyrics_settled(job_dir) or job.auto_lyrics is False) and not _wants_identity(job, job.audio_tags) and not _wants_work(job, job.identity, job.audio_tags) ): @@ -958,7 +1573,7 @@ async def refresh_audio_tags(job_id: str) -> dict: tags = await asyncio.wait_for( asyncio.to_thread(_lookup_audio_tags, job), timeout=TIMEOUT_FETCH_TAGS ) - except asyncio.TimeoutError: + except TimeoutError: logger.warning( "[%s] audio tag lookup timed out after %ds", job_id, TIMEOUT_FETCH_TAGS ) @@ -994,7 +1609,7 @@ async def refresh_audio_tags(job_id: str) -> dict: ), timeout=TIMEOUT_IDENTIFY_BACKFILL, ) - except asyncio.TimeoutError: + except TimeoutError: logger.info("[%s] identification timed out", job_id) work = None # The work behind a soundtrack, once the identity says which recording @@ -1011,12 +1626,15 @@ async def refresh_audio_tags(job_id: str) -> dict: ), timeout=TIMEOUT_WORK_BACKFILL, ) - except asyncio.TimeoutError: + except TimeoutError: logger.info("[%s] work lookup timed out", job_id) lyrics = None query = build_query(job, audio_tags=tags, band=artist, identity=identity) - # Not asked again while LRCLIB's last "nothing" is recent. - if query is not None and not lyrics_settled(job_dir): + # Not asked again while LRCLIB's last "nothing" is recent, and never + # for a track imported with Detect lyrics off: its Lyrics tab offers + # the lookup instead (POST .../lyrics/lookup), so opening the track + # does not fetch what the user turned off. + if job.auto_lyrics is not False and query is not None and not lyrics_settled(job_dir): # find_lyrics never raises, and starts no request past its budget. try: lyrics = await asyncio.wait_for( @@ -1028,7 +1646,7 @@ async def refresh_audio_tags(job_id: str) -> dict: ), timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1, ) - except asyncio.TimeoutError: + except TimeoutError: logger.info("[%s] lyrics lookup timed out", job_id) finally: _TAG_LOOKUPS.discard(job_id) @@ -1047,7 +1665,7 @@ async def refresh_audio_tags(job_id: str) -> dict: and not lyrics_path(job_dir).is_file() and await asyncio.to_thread(keep_answer, job, job_dir, lyrics) ): - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) found: dict[str, object] = {} if tags and not job.audio_tags: found["audio_tags"] = tags @@ -1059,7 +1677,7 @@ async def refresh_audio_tags(job_id: str) -> dict: found["work"] = work if found: _set(job, **found) - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) await asyncio.to_thread(_write_metadata_fields, job_id, found) return { "audio_tags": job.audio_tags, @@ -1100,7 +1718,7 @@ def _fingerprint_audio(job: Job) -> list[Path]: stems_root = (JOBS_DIR / job.id / "stems").resolve() stems = [] for name in STEM_NAMES: - path = (stems_root / f"{name}.wav").resolve() + path = stem_path(stems_root, name).resolve() if path.is_file() and path.is_relative_to(JOBS_DIR.resolve()): stems.append(path) return stems @@ -1165,7 +1783,7 @@ def cancelled() -> bool: try: # Shielded, so one caller giving up does not cancel another's answer. answer = await asyncio.wait_for(asyncio.shield(task), DISCOGS_LOOKUP_BUDGET_SEC + 1) - except asyncio.TimeoutError: + except TimeoutError: logger.info("[%s] Discogs lookup timed out", job_id) answer = None except Exception: @@ -1292,7 +1910,7 @@ async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None) ), timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1, ) - except asyncio.TimeoutError: + except TimeoutError: logger.info("[%s] lyrics lookup timed out", job_id) raise HTTPException(status_code=502, detail="lyrics service unreachable") from None except Exception: @@ -1307,7 +1925,7 @@ async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None) and not lyrics_path(job_dir).is_file() and await asyncio.to_thread(keep_answer, job, job_dir, answer) ): - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) try: return await asyncio.to_thread(get_lyrics, job_id) except HTTPException: @@ -1465,7 +2083,7 @@ def _retime_ready(job_dir: Path, lyrics: dict | None) -> None: raise HTTPException(status_code=404, detail="no lyrics") if not lyric_pairs(lyrics): raise HTTPException(status_code=409, detail="lyrics have no lines") - vocals = (job_dir / "stems" / "vocals.wav").resolve() + vocals = stem_path(job_dir / "stems", "vocals").resolve() if not vocals.is_relative_to(JOBS_DIR.resolve()) or not vocals.is_file(): raise HTTPException(status_code=409, detail="no vocals stem") @@ -1485,9 +2103,10 @@ def report(share: float) -> None: run.started = True if cancelled(): raise JobCancelled() - outcome = await asyncio.to_thread( - retime_job, job, job_dir, cancelled=cancelled, report=report, lyrics=lyrics - ) + with registry_operation("retime"): + outcome = await asyncio.to_thread( + retime_job, job, job_dir, cancelled=cancelled, report=report, lyrics=lyrics + ) run.outcome = outcome run.state = outcome.state except JobCancelled: @@ -1669,12 +2288,7 @@ def get_failure(job_id: str) -> dict: if not JOB_ID_RE.match(job_id): raise HTTPException(status_code=404, detail="job not found") - # JOB_ID_RE rejects "failed", so the quarantine dir can never be addressed - # as a job id; join it explicitly and re-verify the result stays inside. - failed_dir = (JOBS_DIR / "failed" / job_id).resolve() - if not failed_dir.is_relative_to((JOBS_DIR / "failed").resolve()): - raise HTTPException(status_code=404, detail="job not found") - path = failed_dir / "error.txt" + path = _failed_dir(job_id) / "error.txt" if not path.is_file(): raise HTTPException(status_code=404, detail="no failure evidence for this job") @@ -1836,25 +2450,42 @@ def reset_beats(job_id: str) -> dict: @router.delete("/{job_id}") -def delete_job(job_id: str) -> dict[str, str]: - """Delete a completed or failed job and remove its stem files from disk.""" +def delete_job(job_id: str, response: Response) -> dict[str, str]: + """Delete a completed or failed job and remove its stem files from disk. + + A job emptied from the Trash while the stop its trash sent is still + settling answers 202: the page has already let it go, so a refusal left + its folder and record on disk with nothing showing them (#748). The id is + marked deleted first, and the runner removes the job once it settles + (jobqueue.finish_deferred_delete).""" if not JOB_ID_RE.match(job_id): raise HTTPException(status_code=404, detail="job not found") job = registry_get(job_id) if job is None: raise HTTPException(status_code=404, detail="job not found") if job.status not in FINISHED_STATUSES: - raise HTTPException(status_code=409, detail="job is still running") + if job.trashed_at is None: + raise HTTPException(status_code=409, detail="job is still running") + registry_mark_deleted(job_id) + registry_persist(JOBS_DIR) + # Read after the mark: settled meanwhile, it is deleted below instead. + if job.status not in FINISHED_STATUSES: + response.status_code = 202 + return {"job_id": job_id, "status": "deleting"} + if job.post_hoc_running(): + raise JobBusy() # A timing of its lyrics in flight stops rather than write into a # directory being removed (it also checks the job is still registered). - if cancel_run(job_id) and job.vocal_split != "running": - proc = registry_get_proc(job_id) + if cancel_run(job_id): + proc = registry_get_proc(job_id, "retime") if proc is not None and proc.poll() is None: proc.terminate() # The worker may still have the vocals open while it stops. if not wait_run_ended(job_id, RETIME_DELETE_WAIT_SEC): logger.warning("[%s] lyrics timing still stopping; removing files anyway", job_id) forget_run(job_id) + # A video render of one of its takes writes into the folder about to go. + cancel_job_renders(job_id) # A fingerprint of its audio may still have a file open (Windows). release_source(job_id) removed = _rmtree_job(job_id) @@ -1863,6 +2494,10 @@ def delete_job(job_id: str) -> dict[str, str]: # re-adopted by restore() on the next start and the track reappears. registry_mark_deleted(job_id) registry_remove(job_id) + # Its cached backing and rest sums, and the locks it and its takes made: + # after the registry, so no new request for it gets that far. + forget_backing(job_id) + forget_job_locks(job_id) registry_persist(JOBS_DIR) if not removed: raise HTTPException( diff --git a/app/api/playlist.py b/app/api/playlist.py index 6a2fd7cd..f09ed725 100644 --- a/app/api/playlist.py +++ b/app/api/playlist.py @@ -23,8 +23,12 @@ 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 -from app.core.settings import get_auto_sections, get_max_duration_sec, get_playlist_max_items -from app.core.stems_location import is_relocating +from app.core.settings import ( + get_auto_lyrics, + get_auto_sections, + get_max_duration_sec, + get_playlist_max_items, +) from app.pipeline import jobqueue from app.pipeline.download import InvalidPlaylistURL, expand_playlist @@ -122,12 +126,6 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]: except Exception as e: raise HTTPException(status_code=422, detail=str(e)) from e - if is_relocating(): - raise HTTPException( - status_code=409, - detail="Restart StemDeck to finish moving your stems folder before importing", - ) - selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES) if not selected: selected = list(STEM_NAMES) @@ -142,6 +140,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]: # Read once for the batch, so every job in one playlist import agrees, and # captured now rather than when each job reaches its sections stage. auto_sections = get_auto_sections() + auto_lyrics = get_auto_lyrics() created: list[dict[str, Any]] = [] for item in items: job = Job( @@ -153,13 +152,14 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]: title=item["title"] or None, thumbnail=item.get("thumbnail"), auto_sections=auto_sections, + auto_lyrics=auto_lyrics, ) if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS): break # queue filled up mid-loop; report what did land jobqueue.enqueue(job.id) created.append({"job_id": job.id, "title": job.title or "", "source_url": item["url"]}) - registry_persist(JOBS_DIR) + await asyncio.to_thread(registry_persist, JOBS_DIR) return { "playlist_title": result["playlist_title"], "jobs": created, diff --git a/app/api/router.py b/app/api/router.py index f44d7639..e8e53fe5 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter +from app.api.backing import router as backing_router from app.api.config import router as config_router from app.api.discogs import router as discogs_router from app.api.events import router as events_router @@ -11,14 +12,21 @@ from app.api.queue import router as queue_router from app.api.search import router as search_router from app.api.stems import router as stems_router +from app.api.suggest import router as suggest_router +from app.api.takes import router as takes_router +from app.api.video_encoders import router as video_encoders_router router = APIRouter() router.include_router(config_router, tags=["config"]) router.include_router(jobs_router, prefix="/jobs", tags=["jobs"]) +router.include_router(takes_router, prefix="/jobs", tags=["takes"]) +router.include_router(backing_router, prefix="/jobs", tags=["jobs"]) router.include_router(events_router, tags=["events"]) router.include_router(stems_router, tags=["stems"]) +router.include_router(suggest_router, tags=["suggest"]) router.include_router(qr_router, tags=["qr"]) router.include_router(queue_router, prefix="/queue", tags=["queue"]) router.include_router(playlist_router, prefix="/playlist", tags=["playlist"]) router.include_router(search_router, prefix="/search", tags=["search"]) router.include_router(discogs_router, prefix="/discogs", tags=["discogs"]) +router.include_router(video_encoders_router, tags=["settings"]) diff --git a/app/api/stems.py b/app/api/stems.py index b031ee8f..1ed09526 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import hashlib import json import logging @@ -8,30 +9,79 @@ import re import subprocess import tempfile +import threading import uuid import zipfile from collections import deque +from collections.abc import Callable +from dataclasses import asdict from pathlib import Path from typing import NamedTuple +import soundfile as sf from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, Response, StreamingResponse from starlette.background import BackgroundTask from app.core.config import ( CACHE_DIR, + CLICK_STEM_NAME, EXTRA_STEM_NAMES, JOB_ID_RE, JOBS_DIR, STEM_NAMES, + TAKE_ID_RE, TIMEOUT_FFMPEG, + TRACK_ID_RE, + WAV_PLAIN_HEADER_ARGS, ffmpeg_executable, ) from app.core.registry import get as registry_get from app.core.settings import get_export_sample_rate +from app.core.stem_files import stem_ext, stem_media_type, stem_path +from app.core.takes import take_dir +from app.pipeline.audio_encoders import transpose_export, unavailable_detail from app.pipeline.audio_stats import vocal_envelope +from app.pipeline.click_render import ( + CLICK_TO_STEREO, + count_in_beats, + render_click_wav, + render_count_in_wav, +) from app.pipeline.click_render import cache_key as click_cache_key -from app.pipeline.click_render import count_in_beats, render_click_wav, render_count_in_wav +from app.pipeline.click_sounds import CLICK_SOUND_IDS, DEFAULT_CLICK_SOUND +from app.pipeline.collect import fill_missing_peaks +from app.pipeline.fx_filters import ( + FX_PARAM_MAX_CHARS, + FxError, + FxRequest, + check_section_budget, + parse_fx_param, + slot_filter, + slot_section_count, +) +from app.pipeline.mastering import ( + MASTER_PARAM_MAX_CHARS, + Loudness, + MasterError, + MasterPlan, + MasterSettings, + MeasureError, + comp_ratio, + effective_ceiling, + measure_file, + parse_master_param, + plan_chain, + plan_graph, + run_plan, + trim_filter, +) +from app.pipeline.take_render import ( + AAC_ENCODE_ARGS, + AAC_ENCODE_ARGS_ALT, + EXPORT_LIMITER_LOSSY, + export_limiter, +) logger = logging.getLogger("stemdeck.api") @@ -50,6 +100,10 @@ # endpoint replaces. Gains are linear; the studio caps a lane at 2.0, so this # generous bound just rejects abusive values. _MIXDOWN_NAMES = frozenset(STEM_NAMES) | frozenset(EXTRA_STEM_NAMES) | {"original"} +# A recorded take is a lane too, named by its id (^t[0-9a-f]{8}$, never a stem +# name) and resolved to takes//audio.wav, which is already in the +# stems' format, aligned to song 0 and the song's length: export applies no +# offset and no resample to it. _MIXDOWN_MAX_GAIN = 4.0 # lead_vocals/backing_vocals are a decomposition of vocals, not an independent # signal -- summing vocals alongside either would double-count the vocal @@ -60,12 +114,15 @@ # stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps, # OGG is Vorbis VBR q6 (~192 kbps) — the quality tier matching the MP3 setting. _ENCODE_ARGS = { - "wav": ["-c:a", "pcm_s16le"], + "wav": ["-c:a", "pcm_s16le", *WAV_PLAIN_HEADER_ARGS], "mp3": ["-q:a", "2"], "flac": ["-c:a", "flac"], "ogg": ["-c:a", "libvorbis", "-q:a", "6"], } MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()} +# Mixdowns whose encoder adds peak of its own, so they end in the true-peak +# limiter (EXPORT_LIMITER_LOSSY) rather than the bit-transparent one. +_LOSSY_MIXDOWN_EXTS = frozenset({"mp3", "ogg"}) # Containers whose muxer finishes the file by seeking back to a header it wrote # earlier, which is impossible on a pipe (#458): @@ -96,6 +153,31 @@ # grow unboundedly on a long-running server. _MIXDOWN_CACHE_DIR = CACHE_DIR / "mixdown" _CLICK_CACHE_DIR = CACHE_DIR / "click" + +# The stems zip's click entry (#805) shares the zip's namespace with the stems +# and the takes. Takes are named "