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:
@@ -15,10 +15,14 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -42,6 +46,12 @@ fun AdminQuarantineScreen(
|
|||||||
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = ShellContentWindowInsets,
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -53,6 +63,7 @@ fun AdminQuarantineScreen(
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
|||||||
+14
-1
@@ -10,10 +10,13 @@ import com.fabledsword.minstrel.events.EventsStream
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -34,6 +37,15 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||||
|
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||||
|
* file refused by a read-only library it hides the one thing the
|
||||||
|
* operator can fix (#3918).
|
||||||
|
*/
|
||||||
|
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||||
|
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -86,8 +98,9 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
action(trackId)
|
action(trackId)
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
|
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,35 @@ object ErrorCopy {
|
|||||||
* as connection failures.
|
* as connection failures.
|
||||||
*/
|
*/
|
||||||
fun fromThrowable(t: Throwable): String = when (t) {
|
fun fromThrowable(t: Throwable): String = when (t) {
|
||||||
is HttpException -> messageFor(codeFromHttp(t))
|
is HttpException -> fromHttp(t)
|
||||||
is IOException -> messageFor("connection_refused")
|
is IOException -> messageFor("connection_refused")
|
||||||
else -> TABLE.getValue("unknown")
|
else -> TABLE.getValue("unknown")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun codeFromHttp(e: HttpException): String {
|
/**
|
||||||
|
* 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 set on
|
||||||
|
* purpose: most server messages are internal detail. Mirrors web's
|
||||||
|
* errors.ts.
|
||||||
|
*/
|
||||||
|
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
|
||||||
|
|
||||||
|
private fun fromHttp(e: HttpException): String {
|
||||||
|
val body = bodyFromHttp(e)
|
||||||
|
val copy = messageFor(body.code.ifEmpty { "unknown" })
|
||||||
|
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
|
||||||
|
"$copy ${body.message}"
|
||||||
|
} else {
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bodyFromHttp(e: HttpException): Body {
|
||||||
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
||||||
?: return "unknown"
|
?: return Body()
|
||||||
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
|
return runCatching { json.decodeFromString<Envelope>(raw).error }
|
||||||
.getOrNull()
|
.getOrNull() ?: Body()
|
||||||
.orEmpty()
|
|
||||||
return code.ifEmpty { "unknown" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val TABLE: Map<String, String> = mapOf(
|
private val TABLE: Map<String, String> = mapOf(
|
||||||
@@ -99,6 +116,8 @@ object ErrorCopy {
|
|||||||
"request_not_pending" to "This request is no longer pending.",
|
"request_not_pending" to "This request is no longer pending.",
|
||||||
"request_not_found" to "That request no longer exists.",
|
"request_not_found" to "That request no longer exists.",
|
||||||
"track_not_found" to "That track no longer exists.",
|
"track_not_found" to "That track no longer exists.",
|
||||||
|
"library_not_writable" to "The music library isn't writable by the server.",
|
||||||
|
"file_delete_failed" to "The file couldn't be deleted.",
|
||||||
"album_not_found" to "That album no longer exists.",
|
"album_not_found" to "That album no longer exists.",
|
||||||
"artist_not_found" to "That artist no longer exists.",
|
"artist_not_found" to "That artist no longer exists.",
|
||||||
"playlist_not_found" to "That playlist no longer exists.",
|
"playlist_not_found" to "That playlist no longer exists.",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class ErrorCopyTest {
|
||||||
|
private fun httpError(status: Int, body: String): HttpException =
|
||||||
|
HttpException(
|
||||||
|
Response.error<Unit>(status, body.toResponseBody("application/json".toMediaType())),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun libraryNotWritableAppendsTheServerDetail() {
|
||||||
|
val 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."
|
||||||
|
val e = httpError(409, """{"error":{"code":"library_not_writable","message":"$detail"}}""")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"${ErrorCopy.messageFor("library_not_writable")} $detail",
|
||||||
|
ErrorCopy.fromThrowable(e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detailCodeWithoutAMessageShowsTheCopyAlone() {
|
||||||
|
val e = httpError(409, """{"error":{"code":"library_not_writable","message":""}}""")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("library_not_writable"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server messages are usually internal detail; appending them for every
|
||||||
|
// code would leak driver errors into snackbars. This pins the scope.
|
||||||
|
@Test
|
||||||
|
fun otherCodesNeverCarryTheServerMessage() {
|
||||||
|
val e = httpError(404, """{"error":{"code":"track_not_found","message":"pgx: no rows"}}""")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("track_not_found"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnparseableBodyFallsBackToUnknown() {
|
||||||
|
val e = httpError(500, "not json")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("unknown"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transportFailureMapsToConnectionRefused() {
|
||||||
|
assertEquals(
|
||||||
|
ErrorCopy.messageFor("connection_refused"),
|
||||||
|
ErrorCopy.fromThrowable(IOException("refused")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,13 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
||||||
|
// message is the part that tells the operator which directory and uid.
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func installQuarantineClientFn(t *testing.T, h *handlers) {
|
|||||||
}
|
}
|
||||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||||
}
|
}
|
||||||
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn)
|
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ type removeTrackResponse struct {
|
|||||||
|
|
||||||
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
||||||
//
|
//
|
||||||
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Always
|
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
|
||||||
// deletes the file + DB row and runs the album/artist cascade tidy-up. When
|
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
|
||||||
|
// nothing at all when the file cannot be removed (#3918). When
|
||||||
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
||||||
// — failure there is non-fatal (the destructive part already completed) and
|
// — failure there is non-fatal (the destructive part already completed) and
|
||||||
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
||||||
//
|
//
|
||||||
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
||||||
// wire error codes; the only error codes this handler emits are not_found,
|
// wire error codes. The codes this handler emits are not_found,
|
||||||
// server_error, plus the auth codes the middleware emits upstream.
|
// library_not_writable (409) and file_delete_failed when the file could not be
|
||||||
|
// removed, server_error, plus the auth codes the middleware emits upstream.
|
||||||
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := chi.URLParam(r, "id")
|
idStr := chi.URLParam(r, "id")
|
||||||
trackID, ok := parseUUID(idStr)
|
trackID, ok := parseUUID(idStr)
|
||||||
@@ -66,6 +68,11 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "track_id", idStr)
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.logger.Error("api: remove track failed", "err", err, "track_id", idStr)
|
h.logger.Error("api: remove track failed", "err", err, "track_id", idStr)
|
||||||
writeErr(w, apierror.InternalMsg("remove failed", err))
|
writeErr(w, apierror.InternalMsg("remove failed", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
}
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
|
||||||
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
||||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
||||||
// when they need a stubbed Lidarr.
|
// when they need a stubbed Lidarr.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileRemoveAPIError answers a delete that could not reach the track's file
|
||||||
|
// (#3918). Both delete endpoints use it, so the operator gets the same
|
||||||
|
// explanation from the admin remove-track action and from quarantine's Delete
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// The unwritable case is a 409 rather than a 500 because nothing is broken: the
|
||||||
|
// request conflicts with how the library is mounted, and the fix is the
|
||||||
|
// operator's. The message names the directory — removal writes to the parent,
|
||||||
|
// not the file — and the uid/gid the process runs as, which is the half of a
|
||||||
|
// permission problem invisible from the host. Every case says nothing was
|
||||||
|
// deleted, because that is exactly what the operator will be worried about.
|
||||||
|
func fileRemoveAPIError(err error) (*apierror.Error, bool) {
|
||||||
|
var fre *library.FileRemoveError
|
||||||
|
if !errors.As(err, &fre) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if fre.NotWritable() {
|
||||||
|
return &apierror.Error{
|
||||||
|
Status: http.StatusConflict,
|
||||||
|
Code: "library_not_writable",
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"Minstrel runs as uid %d, gid %d and cannot delete from %s (%s). "+
|
||||||
|
"The library mount must be writable by that user. Nothing was deleted.",
|
||||||
|
fre.UID, fre.GID, fre.Dir(), fre.Reason()),
|
||||||
|
Cause: err,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return &apierror.Error{
|
||||||
|
Status: http.StatusInternalServerError,
|
||||||
|
Code: "file_delete_failed",
|
||||||
|
Message: fmt.Sprintf("Could not delete %s (%s). Nothing was deleted.", fre.Path, fre.Reason()),
|
||||||
|
Cause: err,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFileRemoveFailure records a delete that could not reach its file. An
|
||||||
|
// unwritable library is an environment fact the operator can fix, so it is a
|
||||||
|
// Warn; anything else is a real fault.
|
||||||
|
func logFileRemoveFailure(logger *slog.Logger, apiErr *apierror.Error, attrs ...any) {
|
||||||
|
attrs = append(attrs, "code", apiErr.Code, "err", apiErr.Cause)
|
||||||
|
if apiErr.Status == http.StatusConflict {
|
||||||
|
logger.Warn("api: track file could not be deleted", attrs...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Error("api: track file could not be deleted", attrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
||||||
|
|
||||||
|
// removeFailure builds the error a delete service returns when the file would
|
||||||
|
// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
|
||||||
|
// wrap it — the mapping has to see through that.
|
||||||
|
func removeFailure(errno syscall.Errno) error {
|
||||||
|
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
||||||
|
Path: removeTestPath, UID: 1000, GID: 1000,
|
||||||
|
Err: &fs.PathError{Op: "remove", Path: removeTestPath, Err: errno},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRemoveAPIError(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
errno syscall.Errno
|
||||||
|
wantStatus int
|
||||||
|
wantCode string
|
||||||
|
wantIn []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "read-only mount", errno: syscall.EROFS,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"uid 1000, gid 1000", "/music/Moe Shop/WWW (2020)", "read-only file system", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied", errno: syscall.EACCES,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"permission denied", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operation not permitted", errno: syscall.EPERM,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"operation not permitted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "i/o error", errno: syscall.EIO,
|
||||||
|
wantStatus: http.StatusInternalServerError, wantCode: "file_delete_failed",
|
||||||
|
wantIn: []string{removeTestPath, "input/output error", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
apiErr, ok := fileRemoveAPIError(removeFailure(tc.errno))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a wrapped *library.FileRemoveError was not recognised")
|
||||||
|
}
|
||||||
|
if apiErr.Status != tc.wantStatus || apiErr.Code != tc.wantCode {
|
||||||
|
t.Fatalf("got %d %s, want %d %s", apiErr.Status, apiErr.Code, tc.wantStatus, tc.wantCode)
|
||||||
|
}
|
||||||
|
for _, want := range tc.wantIn {
|
||||||
|
if !strings.Contains(apiErr.Message, want) {
|
||||||
|
t.Errorf("message %q lacks %q", apiErr.Message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unwritable answer must name the DIRECTORY. Removal needs write access to
|
||||||
|
// the parent, so a message naming the file would send the operator to fix the
|
||||||
|
// wrong permissions. The directory is a prefix of the file path, which is why a
|
||||||
|
// plain "contains the directory" check could never catch that regression.
|
||||||
|
func TestFileRemoveAPIError_NotWritableNamesTheDirectoryNotTheFile(t *testing.T) {
|
||||||
|
apiErr, _ := fileRemoveAPIError(removeFailure(syscall.EROFS))
|
||||||
|
if strings.Contains(apiErr.Message, "01 - WWW.mp3") {
|
||||||
|
t.Fatalf("message names the file rather than its directory: %q", apiErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRemoveAPIError_IgnoresOtherErrors(t *testing.T) {
|
||||||
|
for name, err := range map[string]error{
|
||||||
|
"nil": nil,
|
||||||
|
"plain error": errors.New("delete track: connection reset"),
|
||||||
|
"path error": &fs.PathError{Op: "remove", Path: removeTestPath, Err: syscall.EROFS},
|
||||||
|
"not found": library.ErrTrackNotFound,
|
||||||
|
} {
|
||||||
|
if _, ok := fileRemoveAPIError(err); ok {
|
||||||
|
t.Errorf("%s: mapped as a file-remove failure", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
-33
@@ -5,12 +5,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
@@ -19,54 +23,159 @@ import (
|
|||||||
// that has no row in tracks.
|
// that has no row in tracks.
|
||||||
var ErrTrackNotFound = errors.New("library: track not found")
|
var ErrTrackNotFound = errors.New("library: track not found")
|
||||||
|
|
||||||
// DeleteTrackFile removes a track file from disk and its row from the
|
// removeFile is os.Remove behind a variable so a test can make removal fail the
|
||||||
// tracks table. Album and artist rows are left untouched.
|
// way a read-only mount or a wrongly-owned directory does. A chmod-based test
|
||||||
|
// cannot stand in for that: root ignores permission bits, so in a CI container
|
||||||
|
// running as root it would pass without ever exercising the failure.
|
||||||
|
var removeFile = os.Remove
|
||||||
|
|
||||||
|
// FileRemoveError reports that a track's file exists but could not be removed.
|
||||||
|
// When DeleteTrackFile returns one, NOTHING was deleted: the row, its likes, its
|
||||||
|
// play history and its playlist memberships are all intact.
|
||||||
|
type FileRemoveError struct {
|
||||||
|
Path string
|
||||||
|
// UID and GID are the identity the server process runs as — the half of a
|
||||||
|
// permission problem the operator cannot see from the host side.
|
||||||
|
UID, GID int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileRemoveError) Error() string { return fmt.Sprintf("remove track file: %v", e.Err) }
|
||||||
|
|
||||||
|
func (e *FileRemoveError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// Dir is the directory removal needs write access to. Unlinking a file writes to
|
||||||
|
// its PARENT, so a world-writable file inside a read-only directory still cannot
|
||||||
|
// be removed — naming the file's own permissions would send the operator to the
|
||||||
|
// wrong place.
|
||||||
|
func (e *FileRemoveError) Dir() string { return filepath.Dir(e.Path) }
|
||||||
|
|
||||||
|
// NotWritable reports whether the library is unwritable for this process — a
|
||||||
|
// read-only mount or a permission denial — rather than an I/O fault. It is the
|
||||||
|
// case the operator can fix, so callers answer it differently.
|
||||||
|
func (e *FileRemoveError) NotWritable() bool {
|
||||||
|
return errors.Is(e.Err, fs.ErrPermission) || errors.Is(e.Err, syscall.EROFS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason is the underlying cause without the path os.Remove already wrapped
|
||||||
|
// around it, for messages that name the directory themselves.
|
||||||
|
func (e *FileRemoveError) Reason() string {
|
||||||
|
var pathErr *fs.PathError
|
||||||
|
if errors.As(e.Err, &pathErr) {
|
||||||
|
return pathErr.Err.Error()
|
||||||
|
}
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletedTrack reports what a delete tidied away beyond the track itself.
|
||||||
|
type DeletedTrack struct {
|
||||||
|
// AlbumID is set when the track was its album's last, so the album went too.
|
||||||
|
AlbumID *pgtype.UUID
|
||||||
|
// ArtistID is set when that album was its artist's last, so the artist went too.
|
||||||
|
ArtistID *pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTrackFile removes a track's file from disk and then its row, tidying
|
||||||
|
// away an album or artist the delete leaves empty. It is the ONLY path that
|
||||||
|
// deletes a track file: the admin remove-track endpoint and quarantine's Delete
|
||||||
|
// file both come through here (#3918).
|
||||||
//
|
//
|
||||||
// Steps:
|
// Order is the whole contract. The file goes first, and if it cannot go — a
|
||||||
// 1. Look up the track to get its file_path.
|
// read-only mount, a permission denial, an I/O error — nothing else happens and
|
||||||
// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone.
|
// a *FileRemoveError comes back. Proceeding past that failure is how #3918 lost
|
||||||
// 3. Delete the tracks row.
|
// history: tracks CASCADEs to play_events, general_likes, contextual_likes,
|
||||||
|
// playlist_tracks, track_tags and playback_errors, so the row and everything
|
||||||
|
// hanging off it were destroyed while the file survived, and the next scan
|
||||||
|
// re-imported it as a brand-new track with none of it.
|
||||||
//
|
//
|
||||||
// Order matters: file first, then DB. If the file delete fails (permission,
|
// A file that is already gone (fs.ErrNotExist) is not a failure; the row is
|
||||||
// I/O error), we leave the DB row alone so the admin can retry.
|
// removed as asked.
|
||||||
//
|
//
|
||||||
// The reverse failure mode — file gone, DB row still present — IS reconciled
|
// This is NOT the missing-file path. That lifecycle is deliberately
|
||||||
// now, and not by this function: the scan's reconcile pass stamps
|
// non-destructive: reconcile stamps missing_since (#2523), selection paths
|
||||||
// tracks.missing_since (#2523), every selection path filters on it, and a file
|
// filter on it, and a returning file is un-marked or adopted (#2528). This is the
|
||||||
// that returns is un-marked or adopted at its new path (#2528). That is the
|
// explicit, irreversible "remove this recording", never the way to tidy up a row
|
||||||
// normal life of a vanished file and it is deliberately non-destructive: the
|
// whose file merely went away.
|
||||||
// row, its play history and its likes survive, because a missing file is a
|
|
||||||
// track Minstrel still knows about (#2527).
|
|
||||||
//
|
//
|
||||||
// So this function is NOT the missing-file path. It is the explicit admin
|
// dataDir, when set, also clears the cached art of an artist the delete removed.
|
||||||
// action "remove this recording from disk and from the library", and it is
|
// logger may be nil.
|
||||||
// irreversible: tracks CASCADEs to play_events, general_likes_tracks,
|
func DeleteTrackFile(
|
||||||
// contextual_likes, track_tags and playback_errors. Reach for it when the
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, trackID pgtype.UUID,
|
||||||
// operator means to destroy the record, never to tidy up a row whose file
|
) (DeletedTrack, error) {
|
||||||
// merely went away.
|
if logger == nil {
|
||||||
func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error {
|
logger = slog.Default()
|
||||||
|
}
|
||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
track, err := q.GetTrackByID(ctx, trackID)
|
track, err := q.GetTrackByID(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ErrTrackNotFound
|
return DeletedTrack{}, ErrTrackNotFound
|
||||||
}
|
}
|
||||||
return fmt.Errorf("get track: %w", err)
|
return DeletedTrack{}, fmt.Errorf("get track: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err := removeFile(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
return fmt.Errorf("remove file: %w", err)
|
return DeletedTrack{}, &FileRemoveError{
|
||||||
|
Path: track.FilePath, UID: os.Getuid(), GID: os.Getgid(), Err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil {
|
// The row and any album or artist it empties go together, so a failure
|
||||||
return fmt.Errorf("delete row: %w", err)
|
// partway cannot leave a deleted track with a ghost album behind it.
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DeletedTrack{}, fmt.Errorf("begin tx: %w", err)
|
||||||
}
|
}
|
||||||
// Log the change after the delete succeeds. Best-effort: a Warn-level
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
// failure here would leave the cache index orphaned on offline clients
|
tq := dbq.New(tx)
|
||||||
// until the next scan touches the surrounding album.
|
|
||||||
|
deleted, err := tq.DeleteTrack(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Removed by someone else between the lookup and here.
|
||||||
|
return DeletedTrack{}, ErrTrackNotFound
|
||||||
|
}
|
||||||
|
return DeletedTrack{}, fmt.Errorf("delete track: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out DeletedTrack
|
||||||
|
album, err := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
albumID := album.ID
|
||||||
|
out.AlbumID = &albumID
|
||||||
|
artistID, aerr := tq.DeleteArtistIfEmpty(ctx, album.ArtistID)
|
||||||
|
switch {
|
||||||
|
case aerr == nil:
|
||||||
|
out.ArtistID = &artistID
|
||||||
|
case errors.Is(aerr, pgx.ErrNoRows):
|
||||||
|
// The artist still has other albums or stray tracks.
|
||||||
|
default:
|
||||||
|
return DeletedTrack{}, fmt.Errorf("delete artist if empty: %w", aerr)
|
||||||
|
}
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
// The album still has other tracks.
|
||||||
|
default:
|
||||||
|
return DeletedTrack{}, fmt.Errorf("delete album if empty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return DeletedTrack{}, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both of these run after the delete has committed, so neither may fail
|
||||||
|
// it: the recording is gone either way. An unlogged change leaves the track
|
||||||
|
// in offline clients' caches until the next scan touches its album; a
|
||||||
|
// leftover art directory is only disk.
|
||||||
if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack,
|
if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack,
|
||||||
syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil {
|
syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil {
|
||||||
return fmt.Errorf("log change: %w", err)
|
logger.Warn("track delete: LogChange failed", "track_id", syncpkg.FormatUUID(trackID), "err", err)
|
||||||
}
|
}
|
||||||
return nil
|
if out.ArtistID != nil && dataDir != "" {
|
||||||
|
if err := coverart.CleanupArtistArt(dataDir, *out.ArtistID); err != nil {
|
||||||
|
logger.Warn("track delete: artist-art cleanup failed",
|
||||||
|
"artist_id", syncpkg.FormatUUID(*out.ArtistID), "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -64,6 +66,15 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, filePath string) (dbq.Track, db
|
|||||||
return track, album, artist
|
return track, album, artist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stubRemoveFile makes file removal fail (or succeed) on demand for one test.
|
||||||
|
// See removeFile for why this is a seam rather than a chmod.
|
||||||
|
func stubRemoveFile(t *testing.T, fn func(string) error) {
|
||||||
|
t.Helper()
|
||||||
|
orig := removeFile
|
||||||
|
removeFile = fn
|
||||||
|
t.Cleanup(func() { removeFile = orig })
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
@@ -73,9 +84,18 @@ func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||||
t.Fatalf("write file: %v", err)
|
t.Fatalf("write file: %v", err)
|
||||||
}
|
}
|
||||||
track, album, _ := seedTrack(t, pool, path)
|
track, album, artist := seedTrack(t, pool, path)
|
||||||
|
// A sibling keeps the album non-empty, so this case pins that the tidy-up
|
||||||
|
// only removes an album the delete actually emptied.
|
||||||
|
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: "Sibling", AlbumID: album.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 1000, FilePath: filepath.Join(dir, "sibling.mp3"), FileSize: 100, FileFormat: "mp3",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("sibling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("DeleteTrackFile: %v", err)
|
t.Fatalf("DeleteTrackFile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +105,99 @@ func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
|||||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||||
t.Errorf("track row still exists")
|
t.Errorf("track row still exists")
|
||||||
}
|
}
|
||||||
// Album row preserved (other tracks may reference it).
|
|
||||||
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
||||||
t.Errorf("album row vanished: %v", err)
|
t.Errorf("album with a remaining track vanished: %v", err)
|
||||||
|
}
|
||||||
|
if got.AlbumID != nil || got.ArtistID != nil {
|
||||||
|
t.Errorf("reported tidy-up %+v for an album that still has a track", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTrackFile_EmptiedAlbumAndArtistGoToo(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "lone.mp3")
|
||||||
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
track, album, artist := seedTrack(t, pool, path)
|
||||||
|
|
||||||
|
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteTrackFile: %v", err)
|
||||||
|
}
|
||||||
|
if got.AlbumID == nil || *got.AlbumID != album.ID {
|
||||||
|
t.Errorf("AlbumID = %v, want %v", got.AlbumID, album.ID)
|
||||||
|
}
|
||||||
|
if got.ArtistID == nil || *got.ArtistID != artist.ID {
|
||||||
|
t.Errorf("ArtistID = %v, want %v", got.ArtistID, artist.ID)
|
||||||
|
}
|
||||||
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil {
|
||||||
|
t.Errorf("emptied album row still exists")
|
||||||
|
}
|
||||||
|
if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil {
|
||||||
|
t.Errorf("emptied artist row still exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The #3918 proof. A file that cannot be removed must leave EVERYTHING in place:
|
||||||
|
// the row is what carries likes, plays and playlist memberships, and the file
|
||||||
|
// surviving means the next scan would re-import it as a stranger.
|
||||||
|
func TestDeleteTrackFile_UnremovableFileDeletesNothing(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
errno syscall.Errno
|
||||||
|
notWritable bool
|
||||||
|
}{
|
||||||
|
{"read-only mount", syscall.EROFS, true},
|
||||||
|
{"permission denied", syscall.EACCES, true},
|
||||||
|
{"operation not permitted", syscall.EPERM, true},
|
||||||
|
{"i/o error", syscall.EIO, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "track.mp3")
|
||||||
|
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
track, album, _ := seedTrack(t, pool, path)
|
||||||
|
stubRemoveFile(t, func(name string) error {
|
||||||
|
return &fs.PathError{Op: "remove", Path: name, Err: tc.errno}
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||||
|
|
||||||
|
var fre *FileRemoveError
|
||||||
|
if !errors.As(err, &fre) {
|
||||||
|
t.Fatalf("err = %v, want a *FileRemoveError", err)
|
||||||
|
}
|
||||||
|
if fre.NotWritable() != tc.notWritable {
|
||||||
|
t.Errorf("NotWritable = %v, want %v", fre.NotWritable(), tc.notWritable)
|
||||||
|
}
|
||||||
|
if fre.Dir() != dir {
|
||||||
|
t.Errorf("Dir = %q, want the parent directory %q", fre.Dir(), dir)
|
||||||
|
}
|
||||||
|
if fre.Reason() != tc.errno.Error() {
|
||||||
|
t.Errorf("Reason = %q, want %q", fre.Reason(), tc.errno.Error())
|
||||||
|
}
|
||||||
|
if fre.UID != os.Getuid() || fre.GID != os.Getgid() {
|
||||||
|
t.Errorf("identity = %d:%d, want this process's %d:%d", fre.UID, fre.GID, os.Getuid(), os.Getgid())
|
||||||
|
}
|
||||||
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err != nil {
|
||||||
|
t.Errorf("track row was deleted although its file was not: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
||||||
|
t.Errorf("album row was deleted although the track's file was not: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("file gone although removal was refused: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +207,7 @@ func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) {
|
|||||||
|
|
||||||
track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3")
|
track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3")
|
||||||
|
|
||||||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
if _, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID); err != nil {
|
||||||
t.Fatalf("DeleteTrackFile with missing file: %v", err)
|
t.Fatalf("DeleteTrackFile with missing file: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||||
@@ -112,7 +222,7 @@ func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) {
|
|||||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
bogus.Valid = true
|
bogus.Valid = true
|
||||||
|
|
||||||
err := DeleteTrackFile(context.Background(), pool, bogus)
|
_, err := DeleteTrackFile(context.Background(), pool, nil, "", bogus)
|
||||||
if !errors.Is(err, ErrTrackNotFound) {
|
if !errors.Is(err, ErrTrackNotFound) {
|
||||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ var (
|
|||||||
// config changes in lidarrconfig take effect immediately. clientFn returns
|
// config changes in lidarrconfig take effect immediately. clientFn returns
|
||||||
// nil when Lidarr is disabled.
|
// nil when Lidarr is disabled.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
// dataDir lets a Delete file that empties an artist clear that artist's
|
||||||
|
// cached art, the same as the admin remove-track path.
|
||||||
|
dataDir string
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
clientFn func() *lidarr.Client
|
clientFn func() *lidarr.Client
|
||||||
@@ -42,11 +45,11 @@ type Service struct {
|
|||||||
|
|
||||||
// NewService constructs a Service. Pass nil for clientFn to disable the
|
// NewService constructs a Service. Pass nil for clientFn to disable the
|
||||||
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
|
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
|
||||||
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service {
|
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, dataDir string) *Service {
|
||||||
if clientFn == nil {
|
if clientFn == nil {
|
||||||
clientFn = func() *lidarr.Client { return nil }
|
clientFn = func() *lidarr.Client { return nil }
|
||||||
}
|
}
|
||||||
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn}
|
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, dataDir: dataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flag inserts or updates a quarantine row for the caller. Re-flagging
|
// Flag inserts or updates a quarantine row for the caller. Re-flagging
|
||||||
@@ -245,10 +248,13 @@ func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFile removes the track file from disk and the tracks row, then
|
// DeleteFile removes the track file from disk and the tracks row (tidying away
|
||||||
// (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that
|
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
|
||||||
// track and writes an audit row. If the file deletion fails, the per-user
|
// clears every per-user quarantine row for that track and writes an audit row.
|
||||||
// rows stay so admin can retry. No partial state.
|
//
|
||||||
|
// If the file cannot be removed, nothing is deleted and the per-user rows stay
|
||||||
|
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
|
||||||
|
// handler turns into an answer naming the cause (#3918). No partial state.
|
||||||
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
|
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
|
||||||
q := dbq.New(s.pool)
|
q := dbq.New(s.pool)
|
||||||
track, err := q.GetTrackByID(ctx, trackID)
|
track, err := q.GetTrackByID(ctx, trackID)
|
||||||
@@ -267,7 +273,7 @@ func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
|
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
|
||||||
}
|
}
|
||||||
if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil {
|
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
|
||||||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
|
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
|
||||||
}
|
}
|
||||||
// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
|
// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestFlag_HappyPath(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
|
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
|
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Flag: %v", err)
|
t.Fatalf("Flag: %v", err)
|
||||||
@@ -105,7 +105,7 @@ func TestFlag_UpsertOnSecondFlag(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
|
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
|
||||||
t.Fatalf("first flag: %v", err)
|
t.Fatalf("first flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) {
|
|||||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
bogus.Valid = true
|
bogus.Valid = true
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
_, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "")
|
_, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "")
|
||||||
if !errors.Is(err, ErrTrackNotFound) {
|
if !errors.Is(err, ErrTrackNotFound) {
|
||||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||||
@@ -141,7 +141,7 @@ func TestFlag_BadReasonRejected(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
|
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
|
||||||
if !errors.Is(err, ErrBadReason) {
|
if !errors.Is(err, ErrBadReason) {
|
||||||
t.Errorf("err = %v, want ErrBadReason", err)
|
t.Errorf("err = %v, want ErrBadReason", err)
|
||||||
@@ -153,7 +153,7 @@ func TestUnflag_DeletesRow(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
||||||
t.Fatalf("Flag: %v", err)
|
t.Fatalf("Flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestListMine_OrderedNewestFirst(t *testing.T) {
|
|||||||
t1, _, _ := seedTrack(t, pool, "T1", "x")
|
t1, _, _ := seedTrack(t, pool, "T1", "x")
|
||||||
t2, _, _ := seedTrack(t, pool, "T2", "y")
|
t2, _, _ := seedTrack(t, pool, "T2", "y")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
|
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
|
||||||
t.Fatalf("Flag t1: %v", err)
|
t.Fatalf("Flag t1: %v", err)
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
|
|||||||
carol := seedUser(t, pool, "carol")
|
carol := seedUser(t, pool, "carol")
|
||||||
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
|
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
||||||
t.Fatalf("alice flag: %v", err)
|
t.Fatalf("alice flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
|
|||||||
bob := seedUser(t, pool, "bob")
|
bob := seedUser(t, pool, "bob")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
||||||
t.Fatalf("alice flag: %v", err)
|
t.Fatalf("alice flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
// No flags applied — resolve a track with zero quarantine rows.
|
// No flags applied — resolve a track with zero quarantine rows.
|
||||||
audit, err := svc.Resolve(context.Background(), track.ID, user.ID)
|
audit, err := svc.Resolve(context.Background(), track.ID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,7 +291,7 @@ func TestResolve_TrackNotFound(t *testing.T) {
|
|||||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
bogus.Valid = true
|
bogus.Valid = true
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
_, err := svc.Resolve(context.Background(), bogus, user.ID)
|
_, err := svc.Resolve(context.Background(), bogus, user.ID)
|
||||||
if !errors.Is(err, ErrTrackNotFound) {
|
if !errors.Is(err, ErrTrackNotFound) {
|
||||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||||
@@ -315,7 +315,7 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
|
|||||||
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
||||||
})
|
})
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
||||||
t.Fatalf("Flag: %v", err)
|
t.Fatalf("Flag: %v", err)
|
||||||
}
|
}
|
||||||
@@ -424,7 +424,7 @@ func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) {
|
|||||||
user := seedUser(t, pool, "alice")
|
user := seedUser(t, pool, "alice")
|
||||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
||||||
if !errors.Is(err, ErrLidarrDisabled) {
|
if !errors.Is(err, ErrLidarrDisabled) {
|
||||||
t.Errorf("err = %v, want ErrLidarrDisabled", err)
|
t.Errorf("err = %v, want ErrLidarrDisabled", err)
|
||||||
@@ -499,7 +499,7 @@ func TestDeleteFile_TrackNotFound(t *testing.T) {
|
|||||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
bogus.Valid = true
|
bogus.Valid = true
|
||||||
|
|
||||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||||
_, err := svc.DeleteFile(context.Background(), bogus, user.ID)
|
_, err := svc.DeleteFile(context.Background(), bogus, user.ID)
|
||||||
if !errors.Is(err, ErrTrackNotFound) {
|
if !errors.Is(err, ErrTrackNotFound) {
|
||||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
if raErr != nil {
|
if raErr != nil {
|
||||||
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
|
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
|
||||||
}
|
}
|
||||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn, s.DataDir)
|
||||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
||||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||||
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
|
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
|
||||||
|
|||||||
+26
-74
@@ -1,9 +1,9 @@
|
|||||||
// Package tracks owns the track-level admin actions exposed by the
|
// Package tracks owns the track-level admin actions behind DELETE
|
||||||
// M7 #372 track-actions menu. Today that's RemoveTrack: the destructive
|
// /api/admin/tracks/{id}. Today that's RemoveTrack: the destructive part goes
|
||||||
// part is always handled directly by Minstrel (os.Remove + DB delete +
|
// through library.DeleteTrackFile — the one path that deletes a track file —
|
||||||
// cascade); when the operator opts in via `unmonitor=true` the service
|
// and when the operator opts in via `unmonitor=true` the service also tells
|
||||||
// also tells Lidarr to flip the track's monitored flag off so Lidarr
|
// Lidarr to flip the track's monitored flag off so Lidarr doesn't search for a
|
||||||
// doesn't search for a replacement.
|
// replacement.
|
||||||
//
|
//
|
||||||
// History: an earlier shape (commit 50a231f, since rewritten) routed
|
// History: an earlier shape (commit 50a231f, since rewritten) routed
|
||||||
// Lidarr-managed tracks through lidarrquarantine.DeleteViaLidarr — but
|
// Lidarr-managed tracks through lidarrquarantine.DeleteViaLidarr — but
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
// drop sibling tracks the operator didn't ask to remove. The current
|
// drop sibling tracks the operator didn't ask to remove. The current
|
||||||
// shape per spec revision 723eee9 is "always direct delete; opt-in
|
// shape per spec revision 723eee9 is "always direct delete; opt-in
|
||||||
// Lidarr unmonitor for replacement-suppression."
|
// Lidarr unmonitor for replacement-suppression."
|
||||||
|
//
|
||||||
|
// The web track-kebab entry that called this was removed in f7278f24; the
|
||||||
|
// endpoint was kept deliberately for a safer admin surface to rebind.
|
||||||
package tracks
|
package tracks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,15 +22,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned when the track id doesn't resolve. Aliased
|
// ErrNotFound is returned when the track id doesn't resolve. Aliased
|
||||||
@@ -69,10 +71,10 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitore
|
|||||||
return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir}
|
return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveTrack deletes the file from disk and the DB rows, runs the
|
// RemoveTrack deletes the track's file and then its row, tidies away an album
|
||||||
// album-empty / artist-empty cascade tidy-up, and (when unmonitor is
|
// or artist the delete empties, and (when unmonitor is true and the track is
|
||||||
// true and the track is Lidarr-managed) tells Lidarr to flip the track's
|
// Lidarr-managed) tells Lidarr to flip the track's monitored flag off so it
|
||||||
// monitored flag off so it won't search for a replacement.
|
// won't search for a replacement.
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - deletedAlbumID: non-nil when removing the track left the album
|
// - deletedAlbumID: non-nil when removing the track left the album
|
||||||
@@ -81,9 +83,10 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitore
|
|||||||
// empty (only set if deletedAlbumID is also set).
|
// empty (only set if deletedAlbumID is also set).
|
||||||
// - lidarrUnmonitorFailed: true when the operator requested unmonitor
|
// - lidarrUnmonitorFailed: true when the operator requested unmonitor
|
||||||
// and the Lidarr call failed; the file + DB delete still succeeded.
|
// and the Lidarr call failed; the file + DB delete still succeeded.
|
||||||
// - err: only for failures *before* the destructive part completes.
|
// - err: ErrNotFound, or a failure before anything was deleted. When the
|
||||||
// A failed os.Remove is logged and tolerated. A failed Lidarr
|
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
|
||||||
// unmonitor is reflected in the bool flag, not the error.
|
// deleted — see library.DeleteTrackFile for why that order is the contract
|
||||||
|
// (#3918). A failed Lidarr unmonitor is reflected in the bool, not here.
|
||||||
//
|
//
|
||||||
// adminID is currently unused — the cascade audit-log line that would
|
// adminID is currently unused — the cascade audit-log line that would
|
||||||
// reference it isn't wired in this slice. It's threaded through the
|
// reference it isn't wired in this slice. It's threaded through the
|
||||||
@@ -104,10 +107,9 @@ func (s *Service) RemoveTrack(
|
|||||||
return nil, nil, false, fmt.Errorf("get track: %w", err)
|
return nil, nil, false, fmt.Errorf("get track: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the album's mbid *before* the cascade-delete transaction.
|
// Capture the album's mbid *before* the delete. If removing this track
|
||||||
// If removing this track empties the album, DeleteAlbumIfEmpty
|
// empties the album, its row is gone afterwards and the unmonitor walk
|
||||||
// removes the row and a post-commit GetAlbumByID would return
|
// would have no album mbid to name.
|
||||||
// pgx.ErrNoRows — leaving the unmonitor walk with no album mbid.
|
|
||||||
var albumMbid string
|
var albumMbid string
|
||||||
if track.Mbid != nil && *track.Mbid != "" && unmonitor && s.lidarr != nil {
|
if track.Mbid != nil && *track.Mbid != "" && unmonitor && s.lidarr != nil {
|
||||||
alb, alerr := q.GetAlbumByID(ctx, track.AlbumID)
|
alb, alerr := q.GetAlbumByID(ctx, track.AlbumID)
|
||||||
@@ -118,64 +120,14 @@ func (s *Service) RemoveTrack(
|
|||||||
// "no albumMbid → can't unmonitor → flag failure."
|
// "no albumMbid → can't unmonitor → flag failure."
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always: remove the file. Tolerate already-missing.
|
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
|
||||||
if track.FilePath != "" {
|
if err != nil {
|
||||||
if rerr := os.Remove(track.FilePath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
|
if errors.Is(err, library.ErrTrackNotFound) {
|
||||||
s.logger.Warn("track delete: file remove failed",
|
return nil, nil, false, ErrNotFound
|
||||||
"path", track.FilePath, "track_id", trackID, "err", rerr)
|
|
||||||
// Proceed: DB consistency is the priority.
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// DB cleanup in a transaction so a midway failure leaves things consistent.
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, false, fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
tq := dbq.New(tx)
|
|
||||||
|
|
||||||
deleted, err := tq.DeleteTrack(ctx, trackID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, false, fmt.Errorf("delete track: %w", err)
|
return nil, nil, false, fmt.Errorf("delete track: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
albumRow, aerr := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID)
|
|
||||||
switch {
|
|
||||||
case aerr == nil:
|
|
||||||
albumID := albumRow.ID
|
|
||||||
deletedAlbumID = &albumID
|
|
||||||
artistID, arerr := tq.DeleteArtistIfEmpty(ctx, albumRow.ArtistID)
|
|
||||||
switch {
|
|
||||||
case arerr == nil:
|
|
||||||
id := artistID
|
|
||||||
deletedArtistID = &id
|
|
||||||
case errors.Is(arerr, pgx.ErrNoRows):
|
|
||||||
// Artist still has other albums or stray tracks. OK.
|
|
||||||
default:
|
|
||||||
return nil, nil, false, fmt.Errorf("delete artist if empty: %w", arerr)
|
|
||||||
}
|
|
||||||
case errors.Is(aerr, pgx.ErrNoRows):
|
|
||||||
// Album still has other tracks. OK.
|
|
||||||
default:
|
|
||||||
return nil, nil, false, fmt.Errorf("delete album if empty: %w", aerr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, nil, false, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup artist-art filesystem cache if the delete cascade orphaned
|
|
||||||
// an artist. Non-fatal — the destructive part is done; we just want
|
|
||||||
// to keep the dataDir tidy.
|
|
||||||
if deletedArtistID != nil && s.dataDir != "" {
|
|
||||||
if err := coverart.CleanupArtistArt(s.dataDir, *deletedArtistID); err != nil {
|
|
||||||
s.logger.Warn("track delete: artist-art cleanup failed",
|
|
||||||
"artist_id", *deletedArtistID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lidarr unmonitor — non-fatal. The destructive part is done; any
|
// Lidarr unmonitor — non-fatal. The destructive part is done; any
|
||||||
// failure here is informational so the operator can retry manually.
|
// failure here is informational so the operator can retry manually.
|
||||||
if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil {
|
if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil {
|
||||||
@@ -193,5 +145,5 @@ func (s *Service) RemoveTrack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedAlbumID, deletedArtistID, lidarrUnmonitorFailed, nil
|
return deleted.AlbumID, deleted.ArtistID, lidarrUnmonitorFailed, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,37 @@ describe('errMessage', () => {
|
|||||||
expect(result.length).toBeGreaterThan(0);
|
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';
|
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
|
* 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
|
* error's code in the error-copy map; falls back to the supplied
|
||||||
* fallback (default: "Something went wrong.") when the code is unknown.
|
* 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 {
|
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_pending": "This request is no longer pending.",
|
||||||
"request_not_found": "That request no longer exists.",
|
"request_not_found": "That request no longer exists.",
|
||||||
"track_not_found": "That track 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.",
|
"album_not_found": "That album no longer exists.",
|
||||||
"artist_not_found": "That artist no longer exists.",
|
"artist_not_found": "That artist no longer exists.",
|
||||||
"playlist_not_found": "That playlist no longer exists.",
|
"playlist_not_found": "That playlist no longer exists.",
|
||||||
|
|||||||
Reference in New Issue
Block a user