Skip to content

Trashed songs come back after a restart when a second job shares the same source URL #542

Description

@thcp

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

  1. The track reappears in the library after a restart, as if the trash action never happened.
  2. The Trash is empty, so there is nothing to re-delete or to inspect.
  3. 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

  1. Extract a song from a URL. Call the resulting job A.
  2. Extract the same URL again. Call the resulting job B. Both jobs now carry the same source_url.
  3. 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.
  4. Move A to the Trash. A is where it should be: in Trash, still on disk.
  5. Restart the app.
  6. 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:

const existingId = findTrackBySource(track.sourceUrl, track.id);
if (existingId) {
  const trash = getTrashFolder();
  const inTrash = 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.
    delete tracks[existingId];
    for (const f of folders) 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.

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

  • 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.
  • Whatever replaces the eviction must not resurrect a failure notification that moveTrackToTrash already dismissed (Notification persistence: only release notifications should survive a reboot #401).
  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions