fix(library): a track delete that cannot remove its file deletes nothing — #3918
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -49,3 +49,37 @@ describe('errMessage', () => {
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errMessage detail codes (#3918)', () => {
|
||||
const detail =
|
||||
'Minstrel runs as uid 1000, gid 1000 and cannot delete from /music/A (read-only file system). ' +
|
||||
'The library mount must be writable by that user. Nothing was deleted.';
|
||||
|
||||
test('library_not_writable appends the server message to the copy', () => {
|
||||
expect(errMessage({ code: 'library_not_writable', message: detail })).toBe(
|
||||
`${ERROR_COPY.library_not_writable} ${detail}`
|
||||
);
|
||||
});
|
||||
|
||||
test('file_delete_failed appends the server message to the copy', () => {
|
||||
const msg = 'Could not delete /music/A/01.mp3 (input/output error). Nothing was deleted.';
|
||||
expect(errMessage({ code: 'file_delete_failed', message: msg })).toBe(
|
||||
`${ERROR_COPY.file_delete_failed} ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('a detail code with no message shows the copy alone', () => {
|
||||
expect(errMessage({ code: 'library_not_writable' })).toBe(ERROR_COPY.library_not_writable);
|
||||
expect(errMessage({ code: 'library_not_writable', message: ' ' })).toBe(
|
||||
ERROR_COPY.library_not_writable
|
||||
);
|
||||
});
|
||||
|
||||
// Server messages are usually internal detail. Appending them for every code
|
||||
// would leak things like driver errors into toasts; this pins the scope.
|
||||
test('other codes never carry the server message', () => {
|
||||
expect(errMessage({ code: 'track_not_found', message: 'pgx: no rows in result set' })).toBe(
|
||||
ERROR_COPY.track_not_found
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,25 @@ export function errCode(err: unknown): string {
|
||||
return (err as { code?: string })?.code ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in order to
|
||||
* act — which directory, which uid — that fixed copy cannot say. For these the
|
||||
* message follows the copy (#3918). Kept to a named list on purpose: most
|
||||
* server messages are internal detail and must never reach a toast. Mirrored
|
||||
* in Android's ErrorCopy.
|
||||
*/
|
||||
const DETAIL_CODES: ReadonlySet<string> = new Set(['library_not_writable', 'file_delete_failed']);
|
||||
|
||||
/**
|
||||
* Returns user-facing copy for an unknown error value. Looks up the
|
||||
* error's code in the error-copy map; falls back to the supplied
|
||||
* fallback (default: "Something went wrong.") when the code is unknown.
|
||||
* For a DETAIL_CODES code, the server's message is appended.
|
||||
*/
|
||||
export function errMessage(err: unknown, fallback = 'Something went wrong.'): string {
|
||||
return copyForCode(errCode(err)) ?? fallback;
|
||||
const code = errCode(err);
|
||||
const copy = copyForCode(code) ?? fallback;
|
||||
if (!DETAIL_CODES.has(code)) return copy;
|
||||
const detail = (err as { message?: unknown })?.message;
|
||||
return typeof detail === 'string' && detail.trim() !== '' ? `${copy} ${detail}` : copy;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"request_not_pending": "This request is no longer pending.",
|
||||
"request_not_found": "That request no longer exists.",
|
||||
"track_not_found": "That track no longer exists.",
|
||||
"library_not_writable": "The music library isn't writable by the server.",
|
||||
"file_delete_failed": "The file couldn't be deleted.",
|
||||
"album_not_found": "That album no longer exists.",
|
||||
"artist_not_found": "That artist no longer exists.",
|
||||
"playlist_not_found": "That playlist no longer exists.",
|
||||
|
||||
Reference in New Issue
Block a user