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 @@
+
+