Reported on macOS: deleting a song -- by clearing the trash, or purging the library -- brings it back later.
Reproduced by code inspection. Deletion has two independent halves and both swallow their failures, so several distinct mechanisms produce this symptom.
The delete path
static/js/catalog.js:1961-1970 (the clearBinBtn handler):
markJobsDeleted(toDelete); // persist before purge so reload can't re-import
purgeTrash();
saveState();
render();
for (const id of toDelete) {
fetch(`/api/jobs/${id}`, { method: "DELETE" }).catch(() => {});
}
syncWithServer (catalog.js:2708-2723) re-adds every job from GET /api/jobs that is not in tracks, not in the trash folder, and not in the deleted-ids tombstone.
Four confirmed mechanisms
1. The server DELETE is fire-and-forget and its errors are discarded.
.catch(() => {}). A failed delete leaves the job in the registry, and nothing retries or reports it.
2. The tombstone can silently fail to persist, and it is the only remaining defence.
markJobsDeleted (catalog.js:133-138) calls storeSet(DELETED_JOBS_KEY, ...) without awaiting, and its .catch only logs a warning. If the write fails, or the app closes before it lands, the tombstone is lost. On next launch loadState reads the older list and syncWithServer re-imports every affected job. The inline comment says "persist before purge so reload can't re-import" -- the intent is right, but nothing waits for the persist.
3. A successful server delete can still leave the directory, which is then re-adopted.
_rmtree_job (app/api/jobs.py:113-121) catches every exception and only logs a warning:
try:
shutil.rmtree(job_dir)
except Exception:
logger.warning("failed to remove job dir %s", job_dir, exc_info=True)
delete_job (jobs.py:762-764) then calls registry_remove and registry_persist regardless of whether the files went away. On the next start, restore() (app/core/registry.py:183-190) walks jobs_dir and adopts any JOB_ID_RE-shaped directory not already known, via _recover_done_job. The job is recreated from disk.
4. Some jobs can never be deleted, and the UI never says so.
delete_job returns 409 unless job.status in ("done", "error", "cancelled"). A job stuck in queued -- for instance the phantom queued job in #520 -- 409s forever. Because the fetch is fire-and-forget, the user sees the row disappear and reappear with no error.
Also found
There is no "purge library" code path. markJobsDeleted has exactly one caller (catalog.js:1964). Any other UI route to permanent deletion does not tombstone, so syncWithServer will always re-import those tracks. catalog.js:1631-1634 (dropping a cancelled job's placeholder) is one such path: delete tracks[jobId] with no tombstone and no server delete.
Not established
No platform-gated cause was found -- nothing in these paths is macOS-specific. Mechanism 3 is the most plausible explanation for it being noticed on macOS (Finder/Spotlight touching files mid-delete makes rmtree fail more often there), but that is a hypothesis, not a verified finding.
Fix
- Await the tombstone write before purging, and treat its failure as a failed delete.
- Await the server DELETE; surface failures and retry, rather than
.catch(() => {}).
_rmtree_job should report failure to its caller; delete_job should not remove the registry entry when the files are still on disk, and should return an error the UI shows.
- Allow deleting a job in a non-terminal state, or give the UI a way to report the 409.
- Have
restore()'s orphan adoption respect a server-side deletion record, so a directory that failed to delete is not silently resurrected.
Test
- Delete with
shutil.rmtree raising: the job must not vanish from the registry, and the user must see an error.
- Clear the bin, kill the app before the store write completes, relaunch: the tracks must stay deleted.
- Delete a job in
queued state: it must succeed or report a visible error.
Reported on macOS: deleting a song -- by clearing the trash, or purging the library -- brings it back later.
Reproduced by code inspection. Deletion has two independent halves and both swallow their failures, so several distinct mechanisms produce this symptom.
The delete path
static/js/catalog.js:1961-1970(theclearBinBtnhandler):syncWithServer(catalog.js:2708-2723) re-adds every job fromGET /api/jobsthat is not intracks, not in the trash folder, and not in the deleted-ids tombstone.Four confirmed mechanisms
1. The server DELETE is fire-and-forget and its errors are discarded.
.catch(() => {}). A failed delete leaves the job in the registry, and nothing retries or reports it.2. The tombstone can silently fail to persist, and it is the only remaining defence.
markJobsDeleted(catalog.js:133-138) callsstoreSet(DELETED_JOBS_KEY, ...)without awaiting, and its.catchonly logs a warning. If the write fails, or the app closes before it lands, the tombstone is lost. On next launchloadStatereads the older list andsyncWithServerre-imports every affected job. The inline comment says "persist before purge so reload can't re-import" -- the intent is right, but nothing waits for the persist.3. A successful server delete can still leave the directory, which is then re-adopted.
_rmtree_job(app/api/jobs.py:113-121) catches every exception and only logs a warning:delete_job(jobs.py:762-764) then callsregistry_removeandregistry_persistregardless of whether the files went away. On the next start,restore()(app/core/registry.py:183-190) walksjobs_dirand adopts anyJOB_ID_RE-shaped directory not already known, via_recover_done_job. The job is recreated from disk.4. Some jobs can never be deleted, and the UI never says so.
delete_jobreturns 409 unlessjob.status in ("done", "error", "cancelled"). A job stuck inqueued-- for instance the phantom queued job in #520 -- 409s forever. Because the fetch is fire-and-forget, the user sees the row disappear and reappear with no error.Also found
There is no "purge library" code path.
markJobsDeletedhas exactly one caller (catalog.js:1964). Any other UI route to permanent deletion does not tombstone, sosyncWithServerwill always re-import those tracks.catalog.js:1631-1634(dropping a cancelled job's placeholder) is one such path:delete tracks[jobId]with no tombstone and no server delete.Not established
No platform-gated cause was found -- nothing in these paths is macOS-specific. Mechanism 3 is the most plausible explanation for it being noticed on macOS (Finder/Spotlight touching files mid-delete makes
rmtreefail more often there), but that is a hypothesis, not a verified finding.Fix
.catch(() => {})._rmtree_jobshould report failure to its caller;delete_jobshould not remove the registry entry when the files are still on disk, and should return an error the UI shows.restore()'s orphan adoption respect a server-side deletion record, so a directory that failed to delete is not silently resurrected.Test
shutil.rmtreeraising: the job must not vanish from the registry, and the user must see an error.queuedstate: it must succeed or report a visible error.