From 0b8e5683b1cd490deb4512b4f941214070f48d29 Mon Sep 17 00:00:00 2001
From: Thales Pereira <31625914+thcp@users.noreply.github.com>
Date: Mon, 21 Sep 2026 08:59:27 +0100
Subject: [PATCH 1/2] fix(jobs): let an imported audio file be split again
Pressing Split stems on a track imported from a file threw an error and
left the button reading "Process" for the rest of the session. The same
press on a YouTube track worked. Three separate faults, all reported
together in #635.
Opening a track writes its source back into the composer. For a link that
is a URL the button can submit. For an upload the source is the synthetic
"local:my song.mp3", and stripping the prefix put the bare filename in the
box: pressing the button POSTed "my song.mp3" as though it were a link.
Being non-empty is also what carried it past the browser's own required
check. The composer now holds only something that can actually be
imported.
setSubmitProcessing restored t("job.process") -- "Process" -- while
index.html starts the button as t("process.splitStems"). The first submit
of a session therefore renamed the button for good, on a successful import
as much as on a failed one, and nothing put it back short of a reload.
The third fault is the one behind the other two: the button acted on the
composer, not on the track. For an upload there was nothing it could act
on at all, because the source file is deleted once separation finishes.
That deletion is the bulk of disk reclaim per job, but only a job that can
fetch its source again may give it up: a link costs a re-download, an
upload costs the recording. Uploads now keep theirs, and POST
/api/jobs/{id}/resplit separates one again from it. The button aims at the
open track whenever the composer has nothing to submit, and a pill in the
composer names the track so it is clear what will be split. A staged file
or a typed URL is still a new import and wins.
The re-split produces a new job, the way re-importing a link does. Its
source is deliberately not the one it was asked with: the library replaces
any existing track sharing a sourceUrl, so reusing it deleted the very
track the re-split came from and left that job on disk with nothing
referencing it, for syncWithServer to re-adopt as a duplicate. The marker
goes before the extension, since deriveQuality reads the suffix to tell a
lossless WAV from a compressed MP3.
Costs a decision worth stating: every uploaded track now retains its
source, 100-300 MB each, where before only the stems survived.
The button's label does not change. What it does is the same thing it
always did.
---
app/api/jobs.py | 127 +++++++++++++++++++++++
app/pipeline/collect.py | 11 +-
app/pipeline/runner.py | 16 ++-
static/css/daw.css | 9 ++
static/index.html | 12 +++
static/js/catalog.js | 67 +++++++++++--
static/js/job.js | 47 +++++++--
static/js/utils.js | 15 +++
tests/e2e/composer-arming.spec.mjs | 156 +++++++++++++++++++++++++++++
tests/js/source-arming.test.mjs | 49 +++++++++
tests/test_jobs_api.py | 141 +++++++++++++++++++++++++-
tests/test_pipeline_runner.py | 44 ++++++++
12 files changed, 672 insertions(+), 22 deletions(-)
create mode 100644 tests/e2e/composer-arming.spec.mjs
create mode 100644 tests/js/source-arming.test.mjs
diff --git a/app/api/jobs.py b/app/api/jobs.py
index c27775fd..ef852a66 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -419,6 +419,133 @@ def _write_vocal_split_error(stems_dir: Path, cause: str, tail: list[str]) -> No
logger.warning("could not write vocal_split_error.txt in %s", stems_dir, exc_info=True)
+class ResplitBody(BaseModel):
+ """Which stems the new run should mix down to. Same shape the importer
+ takes, and bounded the same way: unknown names are dropped rather than
+ trusted, and an empty result falls back to every stem."""
+
+ stems: list[str] = Field(default_factory=list, max_length=len(STEM_NAMES))
+
+
+def _retained_source(job_id: str) -> Path | None:
+ """The upload kept beside a finished job, if there is one.
+
+ Only uploads have this. A link's source is deleted once its stems exist,
+ because it can be fetched again; an upload's cannot, so the runner keeps
+ it (see cleanup_source). Jobs finished before that change kept nothing,
+ which is why absence is an ordinary answer here rather than an error.
+ """
+ job_dir = (JOBS_DIR / job_id).resolve()
+ if not job_dir.is_dir() or not job_dir.is_relative_to(JOBS_DIR.resolve()):
+ return None
+ for candidate in sorted(job_dir.glob("source.*")):
+ resolved = candidate.resolve()
+ if (
+ resolved.is_file()
+ and resolved.suffix.lower() in _ALLOWED_EXTS
+ and resolved.is_relative_to(JOBS_DIR.resolve())
+ ):
+ return resolved
+ return None
+
+
+def _resplit_source_url(source_url: str | None, new_id: str) -> str:
+ """A source that names the same file without colliding with it.
+
+ The library replaces any existing track that shares a sourceUrl -- that is
+ how re-importing a link supersedes its old entry -- so a re-split reusing
+ the original's source deleted the very track it came from. Worse than
+ losing the row: the job directory stayed on disk with nothing referencing
+ it, and syncWithServer re-adopted it on the next launch as a duplicate.
+
+ Reusing the row is not an option either. Two jobs that share a source are
+ collapsed again on every launch, so the pair has to differ on disk, not
+ just in this session.
+
+ The marker goes before the extension, never after: deriveQuality reads the
+ suffix to tell a lossless WAV from a compressed MP3.
+ """
+ base = source_url or "local:track"
+ head, dot, ext = base.rpartition(".")
+ marker = new_id[:6]
+ return f"{head} ({marker}){dot}{ext}" if dot else f"{base} ({marker})"
+
+
+def _link_or_copy(src: Path, dest: Path) -> None:
+ """Hard-link the source into the new job, copying only if that fails.
+
+ A re-split of a 300 MB upload should not cost another 300 MB. Both paths
+ are inside JOBS_DIR so a link is normally available; os.link raises
+ EXDEV across filesystems and EPERM on some mounts, and a copy is correct
+ in every case, just larger.
+ """
+ try:
+ os.link(src, dest)
+ except OSError:
+ shutil.copy2(src, dest)
+
+
+@router.post("/{job_id}/resplit")
+async def resplit_job(job_id: str, body: ResplitBody) -> dict:
+ """Separate a finished upload again, from the source kept beside it.
+
+ A link-sourced track has never needed this: re-importing its URL does the
+ same thing. An upload had no equivalent -- the file was handed over once,
+ and the composer has nothing to submit for it -- so changing which stems
+ you wanted meant finding the original file and uploading it a second time
+ (#635).
+
+ Produces a new job rather than mutating this one, which is exactly what
+ re-importing a link already does. The old track keeps its stems, its
+ sections and its beat grid until the person who asked for this decides
+ otherwise.
+ """
+ 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 or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not found")
+
+ source = _retained_source(job_id)
+ if source is None:
+ # Not a failure of this request: the track simply has nothing to
+ # re-separate from. The UI offers re-import for anything with a URL.
+ raise HTTPException(status_code=409, detail="no source kept for this track")
+
+ selected = [s for s in body.stems if s in STEM_NAMES] or list(STEM_NAMES)
+
+ new_id = uuid.uuid4().hex[:12]
+ new_dir = JOBS_DIR / new_id
+ new_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ await asyncio.to_thread(_link_or_copy, source, new_dir / f"source{source.suffix}")
+ except OSError:
+ shutil.rmtree(new_dir, ignore_errors=True)
+ logger.exception("[%s] resplit could not stage the source", job_id)
+ raise HTTPException(status_code=500, detail="could not start re-split") from None
+
+ new_job = Job(
+ id=new_id,
+ selected_stems=selected,
+ title=job.title,
+ duration_sec=job.duration_sec,
+ # Still a local: source, so this job keeps its own copy and can be
+ # re-split in turn -- but distinct, so the library shows it beside the
+ # track it came from instead of replacing it.
+ source_url=_resplit_source_url(job.source_url, new_id),
+ auto_sections=get_auto_sections(),
+ )
+ 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)
+ # 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.
+ return {"job_id": new_job.id, "source_url": new_job.source_url}
+
+
@router.post("/{job_id}/vocal-split")
async def start_vocal_split(job_id: str) -> Response:
"""Trigger the on-demand lead/backing vocal split (#275) for a completed
diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py
index 8cd251bd..0b6fd1f1 100644
--- a/app/pipeline/collect.py
+++ b/app/pipeline/collect.py
@@ -74,8 +74,9 @@ def _run_ffmpeg(job: Job, cmd: list[str]) -> bool:
def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]:
"""Move Demucs-emitted stems into the job's stems/ dir and clean up
the demucs intermediate dir. Does NOT delete the source download --
- cleanup_source() is called by the runner after any post-processing
- that needs to re-encode the source (e.g. building original.wav)."""
+ cleanup_source() is called by the runner, for the jobs whose source
+ can be fetched again, after any post-processing that needs to
+ re-encode it (e.g. building original.wav)."""
target_dir = job_dir / "stems"
target_dir.mkdir(exist_ok=True)
found: list[str] = []
@@ -94,7 +95,11 @@ def cleanup_source(job_dir: Path) -> None:
"""Delete the source audio file. Called after collect AND after any
post-processing that re-encodes the source (make_original_track).
The source is 100-300 MB, so getting rid of it is the bulk of disk
- reclaim per job; only the stems remain."""
+ reclaim per job; only the stems remain.
+
+ Not called for uploads. The runner decides: a link can be fetched
+ again, an upload cannot, and deleting an upload's source destroys
+ the only copy StemDeck has of it."""
for f in job_dir.glob("source.*"):
f.unlink(missing_ok=True)
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index f4b3c6a0..d9832d3d 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -13,8 +13,8 @@
from app.core.config import DEMUCS_MODEL, TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled, _set
from app.core.redact import redact
+from app.core.registry import is_upload, set_proc
from app.core.registry import persist as persist_registry
-from app.core.registry import set_proc
from app.pipeline.analyze import analyze
from app.pipeline.beatgrid import compute_beat_grid
from app.pipeline.collect import (
@@ -197,10 +197,16 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
mark = _lap(job, "separate", mark)
found = collect(job, stems_root, job_dir)
stems_dir = job_dir / "stems"
- # Source (100-300 MB or the local upload) is no longer needed after
- # collect; delete it before the ffmpeg amix steps in case scratch space
- # is tight.
- cleanup_source(job_dir)
+ # Deleting the source is the bulk of disk reclaim per job, and it happens
+ # before the ffmpeg amix steps below in case scratch space is tight. But
+ # only a job that can fetch its source again is allowed to give it up.
+ #
+ # An upload is the one copy StemDeck will ever have. Throwing it away means
+ # the track can never be separated again -- not with a different model, not
+ # at all -- and the person who imported it may no longer have the file
+ # either. A link costs a re-download; an upload costs the recording.
+ if not is_upload(job):
+ cleanup_source(job_dir)
job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found]
_check_cancel(job)
_set(job, stage="Mixing tracks...")
diff --git a/static/css/daw.css b/static/css/daw.css
index efb50bd5..5c491e68 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -163,6 +163,15 @@ input, textarea { font-family: inherit; }
overflow: hidden;
}
.file-pill.hidden { display: none !important; }
+/* The loaded-track pill reports state rather than staging input, so it reads a
+ step quieter than the file pill it borrows its shape from. */
+.track-pill { opacity: 0.82; }
+/* A staged file is what the button will act on next, so it speaks for itself
+ and the loaded track stands down. Keyed off the same has-file class the file
+ pill uses, so clearing the file brings this back without any JS agreeing to
+ it. */
+.url-wrap.has-file .track-pill { display: none !important; }
+.track-pill .file-name { max-width: 220px; }
.file-name { overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.file-size { color: var(--muted); flex-shrink: 0; }
.file-clear {
diff --git a/static/index.html b/static/index.html
index 2ea9c7a5..d5d25858 100644
--- a/static/index.html
+++ b/static/index.html
@@ -64,6 +64,18 @@
+
+