Repository navigation
feat(export): name every export after its song - #340
Merged
Merged
Conversation
Stems exported as "bass.wav" or "vocals.wav" are ambiguous the moment they leave the app. Dropping several songs' stems into one project folder makes them indistinguishable and they overwrite each other. Every stem the user receives is now named "<Song>_<stem>.<ext>": - ZIP members, via a new prefix argument to _build_stems_zip - Single-stem downloads, via the Content-Disposition filename - Single-stem region trims, which keep both the song and the _region marker - The MP3 variant of a stem The server carries the name because Content-Disposition wins over an <a download> attribute for same-origin requests, so setting the attribute alone had no effect. The attribute is set too, as the fallback for any response that does not send the header. _safe_title is split into _title_slug, which returns "" for a title that sanitizes to nothing, and _safe_title, which keeps the "stems" fallback for the whole-archive filename. A per-file prefix has to be droppable, otherwise an untitled job yields a leading underscore on every member. The slug is restricted to [A-Za-z0-9_], so it stays safe as a ZIP member name. Also fixes the desktop per-stem download, which routed through open_url and handed the file to the OS handler: the stem opened in a browser or media player and was never saved, so no filename applied at all. It now goes through save_audio_file like every other export. Closes #336
Missed in the previous commit: the trimmed-region branch of the MP3 stem
route still built a bare "{name}_region.mp3", so it was the one stem file
the user could receive without the song prefix.
Its ternary also had an unreachable branch. Only the trimmed case reaches
that line; the untrimmed one returns from the cached-file branch above.
The mixdown endpoint hardcoded filename="mixdown.{ext}", so every song's
mix and every region export downloaded as "mixdown.wav". Exporting a few
songs into one folder produced mixdown.wav, mixdown(1).wav, mixdown(2).wav
-- the same collision #336 reports for stems.
Content-Disposition overrides the <a download> attribute, so the name the
frontend already built was discarded. Only desktop escaped it, because
save_audio_file uses the frontend's name rather than the header.
The video export was already doing this correctly, which left the mixdown
as the only export not named after its song.
Names mirror the frontend's: <Song>_exported_mix.<ext>, and <Song>_region.<ext>
when start/end trim to a loop region.
thcp
marked this pull request as ready for review
August 9, 2026 07:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #336.
Problem
Stems export as
bass.wav,vocals.wav,drums.wav. As the reporter put it, that makes them unusable for file management: drop two songs' stems into one project folder and they are indistinguishable, and identically-named files overwrite each other.Tracing it turned up the same defect in the mix export, which downloaded as
mixdown.wavfor every song.What changed
Every file the user exports is now named after its song:
Covering every path that hands a file to the user:
bass.wav<Song>_bass.wavbass.wav<Song>_bass.wavbass_region.wav<Song>_bass_region.wavbass.mp3<Song>_bass.mp3bass_region.mp3<Song>_bass_region.mp3mixdown.wav<Song>_exported_mix.wavmixdown.wav<Song>_region.wavTwo things worth reviewing
The server has to carry the name. Setting the
<a download>attribute alone did nothing:Content-Dispositionwins over it for same-origin requests, and the routes were already sendingfilename="bass.wav"/filename="mixdown.wav". This was caught in testing, when a correctdownloadattribute still produced abass.wavdownload. The attribute is set as well, as the fallback for any response without the header.This is also why the mix bug survived unnoticed: desktop users never saw it, because
save_audio_fileuses the frontend's filename rather than the header.The desktop per-stem download did not download. Clicking the stem download icon routed through
open_url, which spawns the OS handler, so the stem opened in a browser or media player and was never saved. No filename applied at all, meaning this feature would have done nothing for per-stem downloads on the reporter's platform. It now goes throughsave_audio_filelike every other export, giving a real Save dialog with the prefixed name.Naming
_and lowercase, matching what the app already produced (Song_exported_mix.wav,Song_region.wav,Song_stems.zip). The reporter asked for[TrackName]-Bass; the prefix is the substance of the request and house consistency won on the separator._safe_titleis split in two:_title_slugreturns""for a title that sanitizes to nothing_safe_titlekeeps the"stems"fallback for the whole-archive filenameA per-file prefix must be droppable, otherwise an untitled job produces
_bass.wavon every member.Verification
Nine new tests in
tests/test_stems_api.py:Content-Dispositionnames../../etc/passwdyieldsetc_passwd_vocals.wav, and no member contains a separator or..Manual, against a real job with real audio:
Playwright, driving the real controls in both host modes:
398 passedon the full suite. One pre-existing failure,test_all_stems_zip_ogg, fails identically onmainbecause the local ffmpeg has no libvorbis encoder; it passes in CI.ruff check,ruff format --checkandnode --checkall clean.Scope
app/api/stems.py,static/js/player.js,static/js/main.js,tests/test_stems_api.py. No Rust changes;save_audio_filealready did the right thing and simply was not being called.