You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Trashing a song does not stick. Restart the app and the song is back in the library, with the Trash empty. Nothing was clicked to restore it.
Reproduced live on a Windows 0.16.1 build. Present in 0.16.0.
What the user sees
The track reappears in the library after a restart, as if the trash action never happened.
The Trash is empty, so there is nothing to re-delete or to inspect.
The track is a different library entry than the one that was trashed: it comes back under the other job's id, with that job's files.
What triggers it
Two jobs in the registry that share a source_url, with one of them in the Trash. The easy way to get there is to process the same YouTube link twice, which most people do sooner or later: extract a song, trash it, extract it again later because they forgot, or because the first run gave a bad separation.
It is not the trash action itself that loses the track. It is the next import of a job that shares the source URL, which on a cold start is syncWithServer.
Reproduction
Extract a song from a URL. Call the resulting job A.
Extract the same URL again. Call the resulting job B. Both jobs now carry the same source_url.
Remove B from the library so only A is in the catalog. Any route that drops the catalog entry without deleting the job will do, including a store that was written before B existed.
Move A to the Trash. A is where it should be: in Trash, still on disk.
Restart the app.
Trash is empty and B is in the library.
Why it happens
addTrackToLibrary (static/js/catalog.js:305-322) deduplicates by source URL. When the match is in the Trash it evicts it:
constexistingId=findTrackBySource(track.sourceUrl,track.id);if(existingId){consttrash=getTrashFolder();constinTrash=trash?.items.includes(existingId);if(inTrash){// Old track was trashed - delete it silently so the new import lands// in the library instead of inheriting the trash placement.deletetracks[existingId];for(constfoffolders)f.items=f.items.filter((id)=>id!==existingId);}else{replaceTrackId(existingId,track.id);}
...
The eviction is not needed for the stated goal. The incoming track is in no folder yet, so the placement further down addTrackToLibrary already lands it in the library. Removing the old entry was never what put the new one there.
What the eviction does do is drop the catalog entry for a job that still exists. No DELETE /api/jobs/{id}, no tombstone in stemdeck.deleted_jobs. The job directory and its registry record outlive their only reference.
Why a restart brings it back
syncWithServer (static/js/catalog.js:2746) adopts any job from GET /api/jobs that is not in tracks, not in the Trash folder, and not in the deleted-ids tombstone. After the eviction, job A satisfies all three: it has no track, it is no longer in Trash because the eviction stripped it from every folder, and it was never tombstoned because nothing deleted it.
So the sequence across two launches is:
Launch 1: syncWithServer correctly skips A, because A is in the Trash. It then imports B, and that import is what evicts A.
Launch 2: A is now an orphan. syncWithServer adopts it into the library.
The soft delete is undone by the app itself, one launch later, with no user action and nothing in the UI to explain it.
Second-order damage
The user's only handle on the job is gone for one whole session. Between the eviction and the next launch, job A is in no folder, not in the Trash, and not listed anywhere in the UI, while its stems still occupy disk. Emptying the Trash in that window does not touch it, because the Trash no longer knows about it.
#521 is the hard delete path: emptying the Trash, where the server-side deletion, the tombstone write and the orphan-recovery walk each fail in their own way. It is partly server side.
This one is the soft delete path, the Trash itself, and is entirely client side. It needs no failed request and no failed store write. Every write succeeds, and the track is still lost, because the client deliberately removes a trash entry for a job it does not delete. Fixing #521 does not fix this, and this reproduces on a machine where every delete and every store write works perfectly.
Where it came from
Introduced in #402 (commit 306f2ce, 2026-08-21), in the change that made a re-import replace an existing entry rather than sitting next to it. The non-trash branch of that change is fine. The trash branch takes a placement problem and answers it by deleting user data.
Constraints for a fix
A track in the Trash is a distinct job with its own files. Whether those files go is the user's decision, not an import's.
The new import must still land in the library, not inherit the trashed entry's placement. That is the requirement the eviction was reaching for, and it is already satisfied without it.
The regression is only visible across a restart, so a test has to cover the sync path, not just the import call.
Trashing a song does not stick. Restart the app and the song is back in the library, with the Trash empty. Nothing was clicked to restore it.
Reproduced live on a Windows 0.16.1 build. Present in 0.16.0.
What the user sees
What triggers it
Two jobs in the registry that share a
source_url, with one of them in the Trash. The easy way to get there is to process the same YouTube link twice, which most people do sooner or later: extract a song, trash it, extract it again later because they forgot, or because the first run gave a bad separation.It is not the trash action itself that loses the track. It is the next import of a job that shares the source URL, which on a cold start is
syncWithServer.Reproduction
source_url.Why it happens
addTrackToLibrary(static/js/catalog.js:305-322) deduplicates by source URL. When the match is in the Trash it evicts it:The eviction is not needed for the stated goal. The incoming track is in no folder yet, so the placement further down
addTrackToLibraryalready lands it in the library. Removing the old entry was never what put the new one there.What the eviction does do is drop the catalog entry for a job that still exists. No
DELETE /api/jobs/{id}, no tombstone instemdeck.deleted_jobs. The job directory and its registry record outlive their only reference.Why a restart brings it back
syncWithServer(static/js/catalog.js:2746) adopts any job fromGET /api/jobsthat is not intracks, not in the Trash folder, and not in the deleted-ids tombstone. After the eviction, job A satisfies all three: it has no track, it is no longer in Trash because the eviction stripped it from every folder, and it was never tombstoned because nothing deleted it.So the sequence across two launches is:
syncWithServercorrectly skips A, because A is in the Trash. It then imports B, and that import is what evicts A.syncWithServeradopts it into the library.The soft delete is undone by the app itself, one launch later, with no user action and nothing in the UI to explain it.
Second-order damage
The user's only handle on the job is gone for one whole session. Between the eviction and the next launch, job A is in no folder, not in the Trash, and not listed anywhere in the UI, while its stems still occupy disk. Emptying the Trash in that window does not touch it, because the Trash no longer knows about it.
Not the same bug as #521
#521 is the hard delete path: emptying the Trash, where the server-side deletion, the tombstone write and the orphan-recovery walk each fail in their own way. It is partly server side.
This one is the soft delete path, the Trash itself, and is entirely client side. It needs no failed request and no failed store write. Every write succeeds, and the track is still lost, because the client deliberately removes a trash entry for a job it does not delete. Fixing #521 does not fix this, and this reproduces on a machine where every delete and every store write works perfectly.
Where it came from
Introduced in #402 (commit 306f2ce, 2026-08-21), in the change that made a re-import replace an existing entry rather than sitting next to it. The non-trash branch of that change is fine. The trash branch takes a placement problem and answers it by deleting user data.
Constraints for a fix
moveTrackToTrashalready dismissed (Notification persistence: only release notifications should survive a reboot #401).