feat: make hard deletion of disliked tracks real
The dislike lifecycle promised WARNED -> HIDDEN -> deleted, but nothing ever
removed a file: cleanup.service logged its intent behind
MUZICK_ALLOW_HARD_DELETE and returned, and the two backend delete paths
(hardDeleteTrack, permanentlyDeleteTrack) disagreed about what deletion
meant. The review recommended dropping hard deletion and making HIDDEN
terminal; the owner chose to make deletion real instead.
- cleanup.service performs a true unlink() — no trash directory — for
tracks that have been HIDDEN for a 7-day grace period, then settles the
row. This is the single unlink() call site in the system.
- permanentlyDeleteTrack is the one delete path; hardDeleteTrack is gone.
- a deleted_permanent audit row records what was removed, and
migration 20260730_hard_delete_audit_trail backs it.
MUZICK_ALLOW_HARD_DELETE remains OFF: the docker-compose entry is commented
out, there is no enabling default in code, and the worker's /music bind is
the only writable one. Deletion stays dry-run until the owner opts in
deliberately.
REVIEW-2026-07-30.md open decision: dislike lifecycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -211,8 +211,15 @@ CREATE TABLE IF NOT EXISTS track_genre (
|
||||
CREATE TABLE IF NOT EXISTS dislikes (
|
||||
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
-- When the track entered HIDDEN. The pre-deletion grace period
|
||||
-- (MUZICK_HARD_DELETE_GRACE_DAYS, default 7 days) is measured from here —
|
||||
-- separate from, and much longer than, grace_hours below. Recorded
|
||||
-- explicitly rather than inferred from disliked_at, because an irreversible
|
||||
-- clock must not rest on 'HIDDEN' merely being the state DEFAULT.
|
||||
hidden_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
warned_at TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
-- HIDDEN -> WARNED delay (the ntfy warning), NOT the deletion delay.
|
||||
grace_hours INTEGER DEFAULT 48,
|
||||
state dislike_state DEFAULT 'HIDDEN'
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
// No `unlink` here, deliberately: the backend mounts /music `:ro` and must never
|
||||
// remove a library file. Deletions are queued into pending_file_deletions and
|
||||
// carried out by the worker's gated cleanup sweep.
|
||||
import { readFile } from 'fs/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
@@ -673,6 +676,53 @@ const MIGRATIONS: { id: string; sql: string }[] = [
|
||||
END $mig$;
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Sorts after 20260730_claims_dedup_nulls_not_distinct and
|
||||
// 20260730_feedback_track_id_set_null (append-only registry; 'h' > 'f' > 'c').
|
||||
id: '20260730_hard_delete_audit_trail',
|
||||
sql: `
|
||||
-- Real hard deletion of disliked files (invariant §C). Three pieces:
|
||||
--
|
||||
-- 1. Denormalised identity on the audit row. 20260730_feedback_track_id_set_null
|
||||
-- made feedback.track_id ON DELETE SET NULL so the 'deleted_permanent'
|
||||
-- audit row outlives the track — but the surviving row no longer says
|
||||
-- WHICH track was destroyed. Under real (irreversible) deletion that row
|
||||
-- is the only forensic record, so copy the identifying fields into it.
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_path TEXT;
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_title TEXT;
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_artist TEXT;
|
||||
|
||||
-- 2. An explicit "entered HIDDEN" timestamp. The pre-deletion grace period
|
||||
-- (7 days, MUZICK_HARD_DELETE_GRACE_DAYS) is measured from it. Until now
|
||||
-- the only nearby column was disliked_at, which lines up with HIDDEN
|
||||
-- entry solely because 'HIDDEN' happens to be the state DEFAULT and
|
||||
-- restoreDislike() deletes the row outright. That is far too incidental a
|
||||
-- basis for an irreversible clock, so record it directly.
|
||||
-- Added without a DEFAULT first: a DEFAULT would backfill existing rows
|
||||
-- with now(), resetting every in-flight dislike's clock.
|
||||
ALTER TABLE dislikes ADD COLUMN IF NOT EXISTS hidden_at TIMESTAMP;
|
||||
UPDATE dislikes SET hidden_at = disliked_at WHERE hidden_at IS NULL;
|
||||
ALTER TABLE dislikes ALTER COLUMN hidden_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- 3. A durable marker for files whose DB row is already gone but whose
|
||||
-- bytes are not. The sweep commits the transaction FIRST and unlinks
|
||||
-- afterwards, so this row is what makes the window crash-safe and an
|
||||
-- unlink failure (EROFS, EACCES, EBUSY) recorded rather than swallowed.
|
||||
-- Deliberately no FK to tracks: the track row no longer exists.
|
||||
CREATE TABLE IF NOT EXISTS pending_file_deletions (
|
||||
path TEXT PRIMARY KEY,
|
||||
track_id UUID,
|
||||
track_title TEXT,
|
||||
track_artist TEXT,
|
||||
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TIMESTAMP,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_file_deletions_requested
|
||||
ON pending_file_deletions(requested_at);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export class DbService {
|
||||
@@ -1066,42 +1116,13 @@ export class DbService {
|
||||
return (res.rows[0] as DislikeEntry) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a track: remove from DB (cascades to related tables)
|
||||
* and delete the physical file. Used by the cleanup_sweep worker after the
|
||||
* full lifecycle (grace period + 24h warning) has elapsed.
|
||||
*/
|
||||
async hardDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> {
|
||||
const { unlink } = await import('fs/promises');
|
||||
|
||||
// Log the permanent deletion feedback event first (before the track is gone)
|
||||
await this.pgClient.query(
|
||||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
|
||||
[userId, trackId]
|
||||
);
|
||||
|
||||
// Write evidence: manual_deleted → negative profile (strongest negative
|
||||
// signal, spec §B.3). entity_id has no FK to tracks, so the row survives.
|
||||
await this.recordEvidence({
|
||||
user_id: userId,
|
||||
entity_type: 'track',
|
||||
entity_id: trackId,
|
||||
signal: 'manual_deleted',
|
||||
profile: 'negative',
|
||||
weight: -0.90,
|
||||
});
|
||||
|
||||
// Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
|
||||
// feedback, track_audio_features, track_lyrics)
|
||||
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);
|
||||
|
||||
// Delete physical file from disk
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may already be gone (e.g. missing). Log but don't abort.
|
||||
}
|
||||
}
|
||||
// NOTE: hardDeleteTrack() used to live here as a second, unreferenced deletion
|
||||
// path with the same insert-then-delete shape as permanentlyDeleteTrack(), but
|
||||
// with the audit insert outside any transaction and the unlink after the DELETE
|
||||
// and silently swallowed. It had no callers. Removed rather than fixed twice
|
||||
// over: there is now exactly one deletion path in the backend
|
||||
// (permanentlyDeleteTrack, below) and exactly one unlink site in the whole
|
||||
// system (workers/src/cleanup.service.ts).
|
||||
|
||||
async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> {
|
||||
if (!completed) {
|
||||
@@ -1423,26 +1444,67 @@ export class DbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a disliked track: remove the file from disk, then cascade-
|
||||
* delete the DB record (which also removes the dislikes row via ON DELETE CASCADE).
|
||||
* Logs a 'deleted_permanent' feedback event. Atomic: DB deletion only happens after
|
||||
* the file is gone (or the file was already missing).
|
||||
* Permanently delete a disliked track, skipping the grace period. Cascade-
|
||||
* deletes the DB record (which also removes the dislikes row), writes the
|
||||
* 'deleted_permanent' audit row, and hands the file itself to the worker.
|
||||
*
|
||||
* This method does NOT unlink. The backend mounts /music `:ro` on purpose —
|
||||
* nothing reachable from an HTTP request may write to the library — so the
|
||||
* unlink is queued as a pending_file_deletions row and performed by the
|
||||
* cleanup sweep in the worker, the one process with an rw mount, and only when
|
||||
* MUZICK_ALLOW_HARD_DELETE is on. Until then the row is visible evidence of an
|
||||
* orphaned file rather than a lost one.
|
||||
*
|
||||
* Ordering: everything durable commits in one transaction, and the
|
||||
* irreversible filesystem act happens strictly afterwards. The previous
|
||||
* implementation unlinked first, which on this deployment meant EROFS and a
|
||||
* 500 with the track still present.
|
||||
*
|
||||
* `filePath` is accepted for call-site compatibility but the path is re-read
|
||||
* from the row under the transaction, so the queued deletion can never target
|
||||
* a stale or caller-supplied path.
|
||||
*/
|
||||
async permanentlyDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
async permanentlyDeleteTrack(userId: string, trackId: string, _filePath?: string): Promise<void> {
|
||||
await this.withTransaction(async (client) => {
|
||||
await client.query(
|
||||
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
|
||||
[userId, trackId]
|
||||
const trackRes = await client.query(
|
||||
'SELECT path, title, artist FROM tracks WHERE id = $1 FOR UPDATE',
|
||||
[trackId]
|
||||
);
|
||||
// CASCADE deletes dislikes, play_history, feedback, etc.
|
||||
const track = trackRes.rows[0] as
|
||||
| { path: string; title: string | null; artist: string | null }
|
||||
| undefined;
|
||||
if (!track) return; // Already gone; nothing to audit or unlink.
|
||||
|
||||
// Denormalised identity: track_id is ON DELETE SET NULL, so without these
|
||||
// the surviving audit row would not say which track was destroyed.
|
||||
await client.query(
|
||||
`INSERT INTO feedback (user_id, track_id, action, track_path, track_title, track_artist)
|
||||
VALUES ($1, $2, 'deleted_permanent', $3, $4, $5)`,
|
||||
[userId, trackId, track.path, track.title, track.artist]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
|
||||
[track.path, trackId, track.title, track.artist]
|
||||
);
|
||||
|
||||
// CASCADE deletes dislikes, play_history, track_genre, etc.
|
||||
await client.query('DELETE FROM tracks WHERE id = $1', [trackId]);
|
||||
});
|
||||
|
||||
// Strongest negative signal (spec §B.3). entity_id has no FK to tracks, so
|
||||
// the row survives the deletion. Outside the transaction: a failure here
|
||||
// must not resurrect an already-audited deletion.
|
||||
await this.recordEvidence({
|
||||
user_id: userId,
|
||||
entity_type: 'track',
|
||||
entity_id: trackId,
|
||||
signal: 'manual_deleted',
|
||||
profile: 'negative',
|
||||
weight: -0.90,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1526,29 +1588,36 @@ export class DbService {
|
||||
* remaining files from disk, then hard-delete the rest. All inside a
|
||||
* transaction so a partial failure leaves no orphans.
|
||||
*
|
||||
* File deletion happens BEFORE the transaction (best-effort: we delete the
|
||||
* file, then commit the DB changes; if the DB commit fails the file is
|
||||
* already gone but the track stays orphaned in the DB — a subsequent
|
||||
* integrity sweep will catch it and mark it MISSING).
|
||||
* Like permanentlyDeleteTrack(), this no longer unlinks: the loser files are
|
||||
* queued into pending_file_deletions inside the same transaction and removed
|
||||
* later by the worker's gated cleanup sweep. Previously the unlink ran BEFORE
|
||||
* the transaction and rethrew anything other than ENOENT, so on this
|
||||
* deployment (/music mounted `:ro` in the backend) every dedup merge failed
|
||||
* with EROFS before touching the DB at all.
|
||||
*/
|
||||
async mergeDuplicates(keepId: string, deleteIds: string[]): Promise<void> {
|
||||
// 1. Fetch paths for the tracks being deleted (before they're gone).
|
||||
const { rows: losers } = await this.pgClient.query<{ id: string; path: string }>(
|
||||
`SELECT id, path FROM tracks WHERE id = ANY($1::uuid[])`,
|
||||
// 1. Fetch paths + identity for the tracks being deleted (before they go).
|
||||
const { rows: losers } = await this.pgClient.query<{
|
||||
id: string;
|
||||
path: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
}>(
|
||||
`SELECT id, path, title, artist FROM tracks WHERE id = ANY($1::uuid[])`,
|
||||
[deleteIds]
|
||||
);
|
||||
|
||||
// 2. Delete files from disk (best-effort — tolerate missing files).
|
||||
for (const row of losers) {
|
||||
try {
|
||||
await unlink(row.path);
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DB transaction: re-parent history + delete rows.
|
||||
// 2. DB transaction: queue the file removals, re-parent history, delete rows.
|
||||
await this.withTransaction(async (client) => {
|
||||
for (const row of losers) {
|
||||
await client.query(
|
||||
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
|
||||
[row.path, row.id, row.title, row.artist]
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of deleteIds) {
|
||||
await client.query(
|
||||
`UPDATE play_history SET track_id = $1 WHERE track_id = $2`,
|
||||
|
||||
Reference in New Issue
Block a user