Upload flow: POST creates a track row with `status=processing` and
writes the file at `file_path`. If the uploader process dies (OOM,
SIGKILL during deploy, disk wipe) between row-create and status-update,
the row stays in `processing` forever with a `file_path` that doesn't
exist. The library UI shows a ghost track the user can never play,
never reach, and only partially delete.
New worker:
* `jobs/cleanup_orphan_tracks.go` — `CleanupOrphanTracks` queries
tracks with `status=processing AND created_at < NOW()-1h`, stats
the `file_path`, and flips the row to `status=failed` with
`status_message = "orphan cleanup: file missing on disk after >1h
in processing"`. Never deletes; never touches present files or
rows already in another state. Safe to run repeatedly.
* `ScheduleOrphanTracksCleanup(db, logger)` runs once at boot and
then every hour thereafter. Wired in `cmd/api/main.go` right after
route setup so restarts trigger an immediate scan.
* Threshold exported as `OrphanTrackAgeThreshold` constant so tests
and future tuning don't need to edit the worker.
Tests: 5 cases in `cleanup_orphan_tracks_test.go`:
- `_FlipsStuckMissingFile` happy path
- `_LeavesFilePresent` (slow uploads must not be failed)
- `_LeavesRecent` (below threshold)
- `_IgnoresAlreadyFailed` (idempotent)
- `_NilDatabaseIsNoop` (safety)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>