feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:48:20 -04:00
co-authored by Claude Opus 5.5
parent c54792c51e
commit 3a16732883
170 changed files with 994 additions and 6242 deletions
+10 -10
View File
@@ -1,6 +1,6 @@
# Minstrel
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
> State and intelligence belong on the server, not the client.
@@ -11,21 +11,21 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Scope and responsible use
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients.
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind.
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
## Quickstart
@@ -91,9 +91,9 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
**3. (Optional) Set up acquisition and connections.** In admin **Acquisition** (`/admin/acquisition`), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under **Settings → Connections** (`/settings/connections`). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="Search services, download clients and searching in admin Acquisition"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
@@ -115,7 +115,7 @@ Most operators only need the env vars in the quickstart above. A few extras wort
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
ListenBrainz (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
@@ -27,8 +27,6 @@ class AdminQuarantineRepository @Inject constructor(
suspend fun resolve(trackId: String) = api.resolve(trackId)
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
}
// ── Mappers ──
@@ -49,7 +47,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
artistName = artistName,
albumTitle = albumTitle,
albumId = albumId,
lidarrAlbumMbid = lidarrAlbumMbid,
reportCount = reportCount,
latestAt = latestAt,
reasonCounts = reasonCounts,
@@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
SectionCard(
icon = Lucide.Inbox,
title = "Requests",
subtitle = "Approve or reject Lidarr requests",
subtitle = "Approve or reject requests for new music",
count = counts.requests,
onClick = { navController.navigate(AdminRequests) },
)
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -85,7 +84,6 @@ fun AdminQuarantineScreen(
rows = s.rows,
onResolve = viewModel::resolve,
onDeleteFile = viewModel::deleteFile,
onDeleteViaLidarr = viewModel::deleteViaLidarr,
)
}
}
@@ -97,7 +95,6 @@ private fun QueueList(
rows: List<AdminQuarantineItemRef>,
onResolve: (String) -> Unit,
onDeleteFile: (String) -> Unit,
onDeleteViaLidarr: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
@@ -105,7 +102,6 @@ private fun QueueList(
row = row,
onResolve = { onResolve(row.trackId) },
onDeleteFile = { onDeleteFile(row.trackId) },
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
)
HorizontalDivider()
}
@@ -117,7 +113,6 @@ private fun QuarantineRow(
row: AdminQuarantineItemRef,
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Column(
modifier = Modifier
@@ -166,7 +161,6 @@ private fun QuarantineRow(
ActionButtons(
onResolve = onResolve,
onDeleteFile = onDeleteFile,
onDeleteViaLidarr = onDeleteViaLidarr,
)
}
}
@@ -175,7 +169,6 @@ private fun QuarantineRow(
private fun ActionButtons(
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -183,6 +176,5 @@ private fun ActionButtons(
) {
TextButton(onClick = onResolve) { Text("Resolve") }
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
}
}
@@ -82,7 +82,6 @@ class AdminQuarantineViewModel @Inject constructor(
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
private fun act(trackId: String, action: suspend (String) -> Unit) {
val before = internal.value
@@ -61,7 +61,7 @@ fun AdminRequestsScreen(
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
title = "No requests waiting",
body = "When users ask Lidarr for new music, their pending " +
body = "When users ask for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> ErrorRetry(
@@ -19,7 +19,7 @@ import javax.inject.Singleton
* leaks here: (1) the session cookie was being attached to every
* external request the shared OkHttpClient services — including
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
* / Lidarr's /MediaCover endpoints — exposing the session
* — exposing the session
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
@@ -21,7 +21,7 @@ import javax.inject.Singleton
* server URL in Settings without an app relaunch (Phase 11 wiring).
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
* rewritten. Absolute external URLs — artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the
* Minstrel host produced 404s the user saw as missing Discover
@@ -105,19 +105,6 @@ object ErrorCopy {
"cleartext_public" to
"This server is on the internet, so its URL must start with https://. " +
"Plain http:// only works on your home network.",
"lidarr_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
"Set them in Admin › Acquisition.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed" to
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
@@ -8,10 +8,9 @@ import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`.
*
* Three resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → drop the local file; Lidarr keeps the track.
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
* Two resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → delete the track's file and row.
*/
interface AdminQuarantineApi {
@GET("api/admin/quarantine")
@@ -22,7 +21,4 @@ interface AdminQuarantineApi {
@POST("api/admin/quarantine/{trackId}/delete-file")
suspend fun deleteFile(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
}
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.http.Body
@@ -30,7 +30,7 @@ interface DiscoverApi {
suspend fun search(
@Query("q") query: String,
@Query("kind") kind: String,
): List<LidarrSearchResultWire>
): List<CatalogueSearchResultWire>
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
@@ -75,8 +75,8 @@ data class LikeTogglePayload(
)
/**
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
* Lidarr request that landed during a connectivity hiccup). Optional
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
* request that landed during a connectivity hiccup). Optional
* fields stay null when not applicable; the replayer rebuilds the
* `CreateRequestBody` from these.
*/
@@ -4,13 +4,13 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SeedContributionRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
@@ -28,7 +28,7 @@ import javax.inject.Singleton
enum class RequestOutcome { ACCEPTED, QUEUED }
/**
* Read-through Discover surface: Lidarr search, suggestion feed, and
* Read-through Discover surface: catalogue search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter
* `DiscoverApi` + `mutationQueueProvider` integration —
* `feedback_offline_first_for_server_writes` says writes never go
@@ -113,7 +113,7 @@ class DiscoverRepository @Inject constructor(
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
suspend fun search(query: String, kind: RequestKind): List<CatalogueResultRef> =
api.search(query = query, kind = kind.wire).map { it.toDomain() }
/**
@@ -123,7 +123,7 @@ class DiscoverRepository @Inject constructor(
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
*/
suspend fun createRequest(
kind: LidarrRequestKind,
kind: RequestKind,
artistMbid: String,
artistName: String,
albumMbid: String? = null,
@@ -163,7 +163,7 @@ class DiscoverRepository @Inject constructor(
// ── Mappers (internal — wire types stay out of UI) ──
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef(
mbid = mbid,
name = name,
secondaryText = secondaryText,
@@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search as LucideSearch
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
@@ -161,7 +161,7 @@ private fun SearchBar(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
placeholder = { Text("Search Lidarr for new music") },
placeholder = { Text("Search MusicBrainz for new music") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
trailingIcon = {
@@ -173,7 +173,7 @@ private fun SearchBar(
}
@Composable
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -181,13 +181,13 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = kind == LidarrRequestKind.ARTIST,
onClick = { onChange(LidarrRequestKind.ARTIST) },
selected = kind == RequestKind.ARTIST,
onClick = { onChange(RequestKind.ARTIST) },
label = { Text("Artists") },
)
FilterChip(
selected = kind == LidarrRequestKind.ALBUM,
onClick = { onChange(LidarrRequestKind.ALBUM) },
selected = kind == RequestKind.ALBUM,
onClick = { onChange(RequestKind.ALBUM) },
label = { Text("Albums") },
)
}
@@ -316,9 +316,9 @@ private fun SuggestionsHeader() {
@Composable
private fun ResultsList(
rows: List<LidarrSearchResultRef>,
rows: List<CatalogueResultRef>,
locallyRequestedMbids: Set<String>,
onRequest: (LidarrSearchResultRef) -> Unit,
onRequest: (CatalogueResultRef) -> Unit,
) {
if (rows.isEmpty()) {
CenteredMessage("Nothing to add for that search yet.")
@@ -30,7 +30,7 @@ import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.shared.widgets.ServerImage
@@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
@Composable
internal fun ResultTile(
row: LidarrSearchResultRef,
row: CatalogueResultRef,
locallyRequested: Boolean,
onRequest: () -> Unit,
) {
@@ -8,8 +8,8 @@ import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -24,7 +24,7 @@ import javax.inject.Inject
data class DiscoverState(
val query: String = "",
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
val kind: RequestKind = RequestKind.ARTIST,
val suggestions: SuggestionState = SuggestionState.Loading,
val results: ResultsState = ResultsState.Idle,
val locallyRequestedMbids: Set<String> = emptySet(),
@@ -51,7 +51,7 @@ sealed interface ResultsState {
/** No active search — the screen shows the suggestion feed. */
data object Idle : ResultsState
data object Loading : ResultsState
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
data class Loaded(val items: List<CatalogueResultRef>) : ResultsState
data class Error(val message: String) : ResultsState
}
@@ -87,7 +87,7 @@ class DiscoverViewModel @Inject constructor(
}
}
fun setKind(kind: LidarrRequestKind) {
fun setKind(kind: RequestKind) {
internal.update { it.copy(kind = kind) }
if (internal.value.query.isNotBlank()) runSearch()
}
@@ -177,14 +177,14 @@ class DiscoverViewModel @Inject constructor(
* QUEUED) so the screen can pick the snackbar wording and update
* the local "requested" overlay.
*/
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
suspend fun requestRow(row: CatalogueResultRef): RequestOutcome {
val kind = internal.value.kind
val outcome = repository.createRequest(
kind = kind,
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid,
artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText,
albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null,
albumTitle = if (kind == RequestKind.ALBUM) row.name else null,
)
markRequested(row.mbid)
return outcome
@@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor(
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
val outcome = repository.createRequest(
kind = LidarrRequestKind.ARTIST,
kind = RequestKind.ARTIST,
artistMbid = s.mbid,
artistName = s.name,
)
@@ -25,7 +25,6 @@ data class AdminQuarantineItemRef(
val artistName: String,
val albumTitle: String,
val albumId: String,
val lidarrAlbumMbid: String? = null,
val reportCount: Int = 0,
val latestAt: String = "",
val reasonCounts: Map<String, Int> = emptyMap(),
@@ -4,25 +4,24 @@ import kotlinx.datetime.Instant
import kotlin.math.roundToInt
/**
* Kind of Lidarr request being created. Wire form is the lowercase
* Kind of request being created. Wire form is the lowercase
* enum name; the helper [wire] keeps that mapping in one place.
*/
enum class LidarrRequestKind {
enum class RequestKind {
ARTIST, ALBUM, TRACK;
val wire: String get() = name.lowercase()
}
/**
* Lidarr search hit. Mirrors the Flutter client's
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* Catalogue search hit — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
*
* `inLibrary` and `requested` together cover the "already-handled"
* cases — UI greys the row + swaps the Request button for a pill.
*/
data class LidarrSearchResultRef(
data class CatalogueResultRef(
val mbid: String,
val name: String,
val secondaryText: String = "",
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* One quarantined track owned by the caller. Mirrors the Flutter
* `QuarantineMineRow` (web `LidarrQuarantineMineRow`).
* `QuarantineMineRow` (web `QuarantineMineRow`).
*
* `reasonLabel` provides the display string mapped from the raw
* server-side reason key — UI renders it as a pill on the row.
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.models
/**
* Status of a Lidarr request. Stored on the wire as a plain string;
* Status of a request. Stored on the wire as a plain string;
* the typed enum keeps switch / when blocks exhaustive and lets the
* UI map directly to a status pill color without raw-string matches.
*/
@@ -23,7 +23,7 @@ enum class RequestStatus {
}
/**
* One Lidarr request the user has submitted. Mirrors
* One request the user has submitted. Mirrors
* the Flutter client's `AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
@@ -29,7 +29,6 @@ data class AdminQuarantineItemWire(
@SerialName("artist_name") val artistName: String = "",
@SerialName("album_title") val albumTitle: String = "",
@SerialName("album_id") val albumId: String = "",
@SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null,
@SerialName("report_count") val reportCount: Int = 0,
@SerialName("latest_at") val latestAt: String = "",
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
@@ -4,8 +4,8 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/catalogue/search` (the MusicBrainz search that replaced
* Lidarr's, M502 #5519). Mirrors `web/src/lib/api/types.ts
* One row of `GET /api/catalogue/search` (the MusicBrainz search, M502
* #5519). Mirrors `web/src/lib/api/types.ts
* CatalogueSearchResult`. `imageUrl` is the server's own art endpoint,
* relative, so it goes through ServerImage.
*
@@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable
* empty so missing keys don't throw on decode.
*/
@Serializable
data class LidarrSearchResultWire(
data class CatalogueSearchResultWire(
val mbid: String = "",
val name: String = "",
@SerialName("secondary_text") val secondaryText: String = "",
@@ -27,8 +27,7 @@ data class LidarrSearchResultWire(
/**
* `GET /api/discover/suggestions` row — an out-of-library artist
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
* on-demand from Lidarr server-side and may be empty.
* surfaced from ListenBrainz-derived seeds. `imageUrl` may be empty.
*/
@Serializable
data class ArtistSuggestionWire(
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/quarantine/mine`. Mirrors
* the Flutter client's `QuarantineMineRow`
* (web `LidarrQuarantineMineRow`).
* (web `QuarantineMineRow`).
*
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
* / `other`. Stored as a plain string on the wire; the UI does the
@@ -18,7 +18,7 @@ import javax.inject.Singleton
enum class CancelOutcome { Synced, Queued }
/**
* Read-through accessor for the caller's own Lidarr requests +
* Read-through accessor for the caller's own requests +
* cancel-while-pending write. No Room caching for v1 — same rationale
* as HistoryRepository; the offline-snapshot mirror lands with a
* later slice. Cancel is offline-first: direct REST on the happy
@@ -78,7 +78,7 @@ fun RequestsScreen(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "Nothing requested yet",
body = "Use Discover to ask Lidarr for new music; your " +
body = "Use Discover to ask for new music; your " +
"requests show up here.",
)
is UiState.Error -> ErrorRetry(
@@ -218,7 +218,7 @@ private fun CancelConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Cancel request?") },
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
text = { Text("Cancel “$displayName”? Your request is withdrawn.") },
confirmButton = {
Button(onClick = onConfirm) { Text("Cancel request") }
},
@@ -135,9 +135,8 @@ class AuthCookieInterceptorTest {
}
// Drift #569 regression guard: a 401 from an external host must
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
// the user out.
// NOT wipe the user's Minstrel session. A misbehaving CDN that's
// 401-ing on artwork URLs used to silently sign the user out.
@Test
fun `does NOT clear cookie on 401 from non-placeholder host`() {
authStore.setSessionCookie("session=keep")
@@ -67,7 +67,7 @@ class BaseUrlInterceptorTest {
@Test
fun `passes external absolute URL through unchanged`() {
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
// External artwork hosts (artwork.musicbrainz.org,
// coverartarchive.org) are absolute external URLs. The
// interceptor must NOT rewrite them to the Minstrel base —
// doing so produces 404s the user saw as missing Discover
+1 -1
View File
@@ -297,7 +297,7 @@ func run() error {
go gcWorker.Run(ctx)
// The request reconciler completes approved requests once the library
// holds what they asked for (M502 #5520: wants, not Lidarr adds).
// holds what they asked for (M502 #5520).
// Live-event bus shared between SSE subscribers (api.Mount) and
// background workers that publish (reconciler today; scanner later).
// Constructed before any service that publishes so they all share the
+1 -1
View File
@@ -41,7 +41,7 @@ library:
# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION
branding:
app_name: "Minstrel"
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration."
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition."
subsonic:
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
+1 -1
View File
@@ -11,7 +11,7 @@ across all shots for a consistent look.
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
| `integrations.png` | `/admin/acquisition` | Indexers, download clients, searching and Lidarr |
| `integrations.png` | `/admin/acquisition` | Indexers, download clients and searching |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
+2
View File
@@ -1,5 +1,7 @@
# Project Spec: Smart Music Client (companion app)
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
## 1. Overview
A mobile music player client built as the intentional companion to the Smart Music Server (see `smart-music-server-spec.md`). This is not a general-purpose Subsonic client — it is the client that knows how to consume the server's extended API and surface its novel features (contextual likes, session-aware radio, Lidarr integration, server-canonical state) as first-class UX.
+2
View File
@@ -1,5 +1,7 @@
# Project Spec: Smart Music Server (working name)
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
## 1. Overview
A self-hosted music server and streaming backend built to close a real gap in the self-hosted music ecosystem: **state and intelligence belong on the server, not the client.** Existing Subsonic-compatible servers (Navidrome, Gonic, Airsonic-Advanced) defer all of the "smart" behavior — shuffle logic, radio, play count semantics, like management — to clients. The result is fragmented experience across devices: a song liked on one client is unknown to another; shuffle quality depends on which app you opened; play counts diverge. This project centralizes those decisions on the server so every client sees the same canonical state.
+7 -14
View File
@@ -165,7 +165,7 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
}
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
var resolvedActions = []string{string(audit.ActionDuplicateMerge)}
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
var c duplicateViewCounts
@@ -180,9 +180,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts,
return c, err
}
// resolvedActivityView is one thing the resolver did on its own: a merge
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
// Details is the audit row's metadata, which names the files or releases.
// resolvedActivityView is one merge the resolver made on its own (action
// duplicate_merge). Details is the audit row's metadata, which names the files.
type resolvedActivityView struct {
ID string `json:"id"`
Action string `json:"action"`
@@ -370,18 +369,16 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i
// keeps the report's proposal.
type mergeDuplicateRequest struct {
SurvivorTrackID string `json:"survivor_track_id"`
Unmonitor bool `json:"unmonitor"`
}
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
// deleted from disk; the operator reads them to know exactly what went.
type mergeDuplicateResponse struct {
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
}
// mergeRequestBodyLimit bounds the request body. It holds one id and a flag.
// mergeRequestBodyLimit bounds the request body. It holds one id.
const mergeRequestBodyLimit = 1 << 16
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
@@ -416,7 +413,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
}
}
res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor)
res, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID)
if err != nil {
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
@@ -442,9 +439,5 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
for _, c := range res.Removed {
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
}
if body.Unmonitor && unmonitorFailed {
failed := true
resp.LidarrUnmonitorFailed = &failed
}
writeJSON(w, http.StatusOK, resp)
}
-392
View File
@@ -1,392 +0,0 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
type lidarrConfigView struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// maskAPIKey converts an api_key for external response: non-empty keys become
// "***"; empty (never set) keys remain "".
func maskAPIKey(key string) string {
if key == "" {
return ""
}
return "***"
}
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
// api_key masked.
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
return lidarrConfigView{
Enabled: cfg.Enabled,
BaseURL: cfg.BaseURL,
APIKey: maskAPIKey(cfg.APIKey),
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
DefaultMetadataProfileID: cfg.DefaultMetadataProfileID,
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
}
}
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
// Returns the current Lidarr config with api_key masked.
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: get lidarr config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
type putLidarrConfigBody struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
// Empty api_key in the body preserves the currently saved api_key.
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
var body putLidarrConfigBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
// Resolve the effective api_key: empty in body = preserve saved value.
apiKey := body.APIKey
if apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: put lidarr config: load saved", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
apiKey = saved.APIKey
}
// Validate: enabled=true requires base_url AND api_key.
if body.Enabled {
if body.BaseURL == "" || apiKey == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
return
}
// Validate that the URL parses.
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
return
}
// Defaults must be present so future Approve calls don't dispatch
// invalid POST bodies to Lidarr (which produces 4xx with field
// errors). The frontend pre-selects first values so this almost
// never fires for non-malicious traffic, but it's the right gate.
if body.DefaultQualityProfileID == 0 ||
body.DefaultMetadataProfileID == 0 ||
body.DefaultRootFolderPath == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
return
}
}
cfg := lidarrconfig.Config{
Enabled: body.Enabled,
BaseURL: body.BaseURL,
APIKey: apiKey,
DefaultQualityProfileID: body.DefaultQualityProfileID,
DefaultMetadataProfileID: body.DefaultMetadataProfileID,
DefaultRootFolderPath: body.DefaultRootFolderPath,
}
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
h.logger.Error("admin: put lidarr config: save", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
type testLidarrBody struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test.
// All fields except Ok are optional. On a failed connection (Ok=false), only
// Ok and Error are populated. On success (Ok=true), Version is populated;
// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr
// data and ListErrors maps any per-list fetch failures by list name. The
// integrations page consumes the lists to pre-fill its dropdowns on
// first-time setup, fixing the chicken-and-egg where the dropdowns
// previously gated on cfg.Enabled.
type testLidarrResponse struct {
Ok bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"`
MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"`
RootFolders []rootFolderView `json:"root_folders,omitempty"`
ListErrors map[string]string `json:"list_errors,omitempty"`
}
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
// Always returns 200; the ok/error fields in the response body indicate
// connection success or failure.
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
var body testLidarrBody
// Ignore decode errors — an empty body is valid (all fields optional).
_ = json.NewDecoder(r.Body).Decode(&body)
// Fall back to saved config for any empty field.
baseURL := body.BaseURL
apiKey := body.APIKey
if baseURL == "" || apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: test lidarr: load config", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
return
}
if baseURL == "" {
baseURL = saved.BaseURL
}
if apiKey == "" {
apiKey = saved.APIKey
}
}
client := lidarr.NewClient(baseURL, apiKey)
result, err := client.Ping(r.Context())
if err != nil {
errCode := lidarrErrCode(err)
// Warn (not Error): a failed connection test is expected when the
// admin is configuring the integration and may have a typo'd URL
// or wrong key. Without this log line the underlying *net.DNSError
// / *net.OpError / TLS error is invisible to operators — they only
// see the bucket code in the UI. base_url is safe to log; api_key
// must NEVER be logged.
h.logger.Warn("admin: test lidarr ping failed",
"err", err, "base_url", baseURL, "code", errCode)
writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode})
return
}
quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client)
writeJSON(w, http.StatusOK, testLidarrResponse{
Ok: true,
Version: result.Version,
QualityProfiles: quality,
MetadataProfiles: metadata,
RootFolders: folders,
ListErrors: listErrs,
})
}
// fetchLidarrLists fans out the three list calls in parallel and returns
// the lists plus a map of any per-list errors. Empty map (returned as nil
// to omit the JSON field) if all succeeded. Used by the test handler to
// pre-populate the integrations page dropdowns on the same round-trip as
// the connection check.
func fetchLidarrLists(ctx context.Context, client *lidarr.Client) (
[]qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string,
) {
var (
quality []qualityProfileView
metadata []metadataProfileView
folders []rootFolderView
errMu sync.Mutex
errs = map[string]string{}
wg sync.WaitGroup
)
recordErr := func(name string, err error) {
errMu.Lock()
errs[name] = lidarrErrCode(err)
errMu.Unlock()
}
wg.Add(3)
go func() {
defer wg.Done()
ps, err := client.ListQualityProfiles(ctx)
if err != nil {
recordErr("quality_profiles", err)
return
}
quality = make([]qualityProfileView, len(ps))
for i, p := range ps {
quality[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
ps, err := client.ListMetadataProfiles(ctx)
if err != nil {
recordErr("metadata_profiles", err)
return
}
metadata = make([]metadataProfileView, len(ps))
for i, p := range ps {
metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
fs, err := client.ListRootFolders(ctx)
if err != nil {
recordErr("root_folders", err)
return
}
folders = make([]rootFolderView, len(fs))
for i, f := range fs {
folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
}()
wg.Wait()
if len(errs) == 0 {
return quality, metadata, folders, nil
}
return quality, metadata, folders, errs
}
// lidarrErrCode maps a lidarr client error to the stable string code used in
// admin API responses.
func lidarrErrCode(err error) string {
switch {
case errors.Is(err, lidarr.ErrUnreachable):
return "lidarr_unreachable"
case errors.Is(err, lidarr.ErrAuthFailed):
return "lidarr_auth_failed"
case errors.Is(err, lidarr.ErrLookupFailed):
return "lidarr_lookup_failed"
default:
return "internal_error"
}
}
// qualityProfileView is the JSON shape for a single quality profile in the
// GET /api/admin/lidarr/quality-profiles response.
type qualityProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListQualityProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]qualityProfileView, len(profiles))
for i, p := range profiles {
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// metadataProfileView is the JSON shape for a single metadata profile in the
// GET /api/admin/lidarr/metadata-profiles response.
type metadataProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles.
func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListMetadataProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]metadataProfileView, len(profiles))
for i, p := range profiles {
out[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// rootFolderView is the JSON shape for a single root folder in the
// GET /api/admin/lidarr/root-folders response.
type rootFolderView struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"free_space"`
}
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
folders, err := client.ListRootFolders(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]rootFolderView, len(folders))
for i, f := range folders {
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
writeJSON(w, http.StatusOK, out)
}
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
// when a response has already been written.
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: lidarr proxy: load config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return lidarrconfig.Config{}, nil, false
}
if !cfg.Enabled {
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
return lidarrconfig.Config{}, nil, false
}
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
}
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
// mirrors the shape the RequireAdmin middleware uses and that the spec
// defines for /api/admin/* errors.
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
-455
View File
@@ -1,455 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// newAdminLidarrRouter builds a test chi router with the five admin/lidarr
// endpoints. RequireAdmin middleware is applied. Tests inject a user into
// context directly (skipping RequireUser).
func newAdminLidarrRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
})
return r
}
// seedAdminUser creates a test user with is_admin=true.
func seedAdminUser(t *testing.T, h *handlers) dbq.User {
t.Helper()
return seedUser(t, h.pool, "admin", "pw", true)
}
// doAdminReq fires an HTTP request at the admin router with the given user
// injected into context (bypassing RequireUser).
func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
reqBody = bytes.NewBuffer(body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = withUser(req, user)
w := httptest.NewRecorder()
newAdminLidarrRouter(h).ServeHTTP(w, req)
return w
}
// saveLidarrConfigFull writes a lidarr config with the given key directly
// (for admin tests that need precise api_key control).
func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) {
t.Helper()
svc := lidarrconfig.New(h.pool)
if err := svc.Save(context.Background(), cfg); err != nil {
t.Fatalf("saveLidarrConfigFull: %v", err)
}
}
// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is
// returned as "***" and never in plaintext.
func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "secret123",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey == "secret123" {
t.Error("api_key leaked in response; want ***")
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
}
// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is
// unset in the DB, GET returns api_key="" (not "***").
func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Save config with empty api_key (null in DB via lidarrconfig.Service).
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: false,
BaseURL: "",
APIKey: "",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey != "" {
t.Errorf("api_key = %q, want empty string", resp.APIKey)
}
}
// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with
// api_key="" does not overwrite the saved api_key.
func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Seed a config with a known api_key.
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "originalkey",
})
// PUT with empty api_key — should preserve "originalkey". enabled=true
// requires the defaults gate (missing_defaults) to be satisfied.
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
// Confirm the saved api_key is still "originalkey" by reading raw config.
svc := lidarrconfig.New(h.pool)
saved, err := svc.Get(context.Background())
if err != nil {
t.Fatalf("get saved config: %v", err)
}
if saved.APIKey != "originalkey" {
t.Errorf("saved api_key = %q, want originalkey", saved.APIKey)
}
}
// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling
// Lidarr without a base_url returns 400 with missing_required_field.
func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "missing_required_field" {
t.Errorf("error = %q, want missing_required_field", resp["error"])
}
}
// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with
// the updated config and the api_key masked.
func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if !resp.Enabled {
t.Error("enabled = false, want true")
}
if resp.BaseURL != "http://lidarr.lan:8686" {
t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL)
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
if resp.DefaultQualityProfileID != 2 {
t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID)
}
if resp.DefaultRootFolderPath != "/music" {
t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath)
}
}
// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a
// stub returning valid status JSON responds 200 with ok=true and the version.
func TestHandleTestLidarrConnection_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`))
case "/api/v1/metadataprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
// POST with empty body — falls back to saved config.
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if resp["version"] != "2.0.5" {
t.Errorf("version = %v, want 2.0.5", resp["version"])
}
qp, ok := resp["quality_profiles"].([]any)
if !ok || len(qp) != 2 {
t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"])
}
mp, ok := resp["metadata_profiles"].([]any)
if !ok || len(mp) != 1 {
t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"])
}
rf, ok := resp["root_folders"].([]any)
if !ok || len(rf) != 1 {
t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"])
}
if _, present := resp["list_errors"]; present {
t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"])
}
}
// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test
// against a Lidarr that responds OK to the ping + two of three lists still
// reports ok=true with the failing list omitted and named in list_errors.
func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`))
case "/api/v1/metadataprofile":
http.Error(w, "boom", http.StatusInternalServerError)
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if _, present := resp["metadata_profiles"]; present {
t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"])
}
listErrs, ok := resp["list_errors"].(map[string]any)
if !ok {
t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"])
}
if _, present := listErrs["metadata_profiles"]; !present {
t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs)
}
}
// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against
// an unreachable URL returns 200 with ok=false and lidarr_unreachable.
func TestHandleTestLidarrConnection_Unreachable(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Point at a URL that will immediately refuse connections.
body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != false {
t.Errorf("ok = %v, want false", resp["ok"])
}
if resp["error"] != "lidarr_unreachable" {
t.Errorf("error = %v, want lidarr_unreachable", resp["error"])
}
}
// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles
// proxies and normalizes the Lidarr response.
func TestHandleListQualityProfiles_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var profiles []qualityProfileView
if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(profiles) != 2 {
t.Fatalf("len(profiles) = %d, want 2", len(profiles))
}
if profiles[0].ID != 1 || profiles[0].Name != "Lossless" {
t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0])
}
if profiles[1].ID != 2 || profiles[1].Name != "Standard" {
t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1])
}
}
// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies
// and normalizes the Lidarr response.
func TestHandleListRootFolders_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var folders []rootFolderView
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(folders) != 1 {
t.Fatalf("len(folders) = %d, want 1", len(folders))
}
f := folders[0]
if f.Path != "/music" {
t.Errorf("path = %q, want /music", f.Path)
}
if !f.Accessible {
t.Error("accessible = false, want true")
}
if f.FreeSpace != 1234567890 {
t.Errorf("free_space = %d, want 1234567890", f.FreeSpace)
}
}
// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every
// admin endpoint returns 403 with not_authorized for a non-admin user.
func TestAdminEndpoints_NonAdmin403(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
cases := []struct {
method string
path string
body []byte
}{
{http.MethodGet, "/api/admin/lidarr/config", nil},
{http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)},
{http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)},
{http.MethodGet, "/api/admin/lidarr/quality-profiles", nil},
{http.MethodGet, "/api/admin/lidarr/root-folders", nil},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "not_authorized" {
t.Errorf("error = %q, want not_authorized", resp["error"])
}
})
}
}
+49 -94
View File
@@ -10,22 +10,20 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// adminQueueRowView is a single aggregated row in the admin queue response.
type adminQueueRowView struct {
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
Reports []adminQueueReportView `json:"reports"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
Reports []adminQueueReportView `json:"reports"`
}
// adminQueueReportView is one user's report under an aggregated admin row.
@@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string {
// handleListAdminQuarantine implements GET /api/admin/quarantine.
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
rows, err := h.quarantine.ListAdminQueue(r.Context())
if err != nil {
h.logger.Error("admin: list quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -66,27 +64,25 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
})
}
out = append(out, adminQueueRowView{
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
LidarrAlbumMBID: row.LidarrAlbumMBID,
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
Reports: reports,
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
Reports: reports,
})
}
writeJSON(w, http.StatusOK, out)
}
// actionResultView is the response shape for the three admin destructive
// actions (resolve, delete-file, delete-via-lidarr).
// actionResultView is the response shape for the admin actions (resolve,
// delete-file).
type actionResultView struct {
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
@@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
if err != nil {
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
if errors.Is(err, quarantine.ErrTrackNotFound) {
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
return
}
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
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.
@@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
return
}
switch {
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
case errors.Is(err, quarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
default:
h.logger.Error("admin: delete file", "err", err)
@@ -158,60 +154,19 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
})
}
// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
default:
h.logger.Error("admin: delete via lidarr", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
// Broadcast: same rationale as delete-file — track row gone everywhere.
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
DeletedTrackCount: &deleted,
})
}
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
type actionLogView struct {
ID string `json:"id"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
// AlbumMBID is set on the older deleted_album rows: the album removed.
AlbumMBID *string `json:"album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
}
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
@@ -241,16 +196,16 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
adminID = &s
}
out = append(out, actionLogView{
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
LidarrAlbumMBID: row.LidarrAlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
AlbumMBID: row.AlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
})
}
writeJSON(w, http.StatusOK, out)
+6 -194
View File
@@ -15,9 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// newAdminQuarantineRouter builds a test chi router with the five admin
@@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router {
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
return r
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
return w
}
// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
// with a real clientFn pointing at the (current) saved lidarr_config. This
// mirrors testHandlersWithClientFn for the requests test file.
func installQuarantineClientFn(t *testing.T, h *handlers) {
t.Helper()
cfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
c, err := cfg.Get(context.Background())
if err != nil || !c.Enabled || c.BaseURL == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
}
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
t.Helper()
if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
t.Fatalf("flagDirect: %v", err)
}
}
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
return tr, path
}
// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
// required for delete-via-lidarr scenarios.
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "DvL Artist "+unique)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mbid := albumMBID
album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
ArtistID: artist.ID, Mbid: &mbid,
})
if err != nil {
t.Fatalf("UpsertAlbum: %v", err)
}
one := int32(1)
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "DvL Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
func TestResolveQuarantine_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
if actions[0].Action != dbq.QuarantineActionKindResolved {
t.Errorf("audit action = %v, want resolved", actions[0].Action)
}
}
@@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
}
}
// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
// a single album for the lookup and 200 for the delete. Verifies the
// audit row is written and the local track row is deleted.
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
}
if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
}
// Track row should be gone.
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
t.Error("track row still exists after delete-via-lidarr")
}
// Audit row written with the lidarr_album_mbid.
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
}
if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
}
}
// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
// not configured.
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
// Lidarr config is reset (disabled).
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "lidarr_disabled" {
t.Errorf("error = %q, want lidarr_disabled", resp["error"])
}
}
// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
// when the parent album has no mbid.
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
// Track whose album has NO mbid.
track, _ := seedTrackOnDisk(t, h, "no-mbid")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "album_mbid_missing" {
t.Errorf("error = %q, want album_mbid_missing", resp["error"])
}
}
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
{http.MethodGet, "/api/admin/quarantine"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
{http.MethodGet, "/api/admin/quarantine/actions"},
}
for _, tc := range cases {
+19 -26
View File
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -49,13 +48,7 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
return w
}
// testHandlersWithClientFn is the request handlers' test fixture.
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
return testHandlers(t)
}
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
// seedPendingArtistRequest creates a pending artist-kind request row
// for the given user and returns its parsed requestView.
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
@@ -65,8 +58,8 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
// and verifies that GET without ?status returns only the 2 pending rows.
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -103,8 +96,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
// only rejected rows.
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -137,8 +130,8 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
// ?status returns 400.
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -158,8 +151,8 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
// POST /approve → 409 request_not_pending.
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -188,8 +181,8 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
// 200 and the row transitions to rejected with notes saved.
func TestHandleRejectRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -217,8 +210,8 @@ func TestHandleRejectRequest_HappyPath(t *testing.T) {
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
// returns 200 (notes are optional).
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -242,8 +235,8 @@ func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
// all 3 admin request endpoints reject non-admin users with 403.
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
@@ -281,8 +274,8 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
// its release group on the wanted list, as the requester's.
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -309,8 +302,8 @@ func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
// followed on the profile the admin picked; an unknown profile is a 400.
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
+1 -1
View File
@@ -34,7 +34,7 @@ func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummar
// The summary counts each queue an admin acts on and says since when the
// oldest item has waited; an empty queue has no since (#5605).
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
listener := seedUser(t, h.pool, "listener", "pw", false)
ctx := context.Background()
+8 -31
View File
@@ -3,7 +3,6 @@ package api
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -11,27 +10,22 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The three
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two
// optional fields are omitted unless the corresponding cleanup actually
// happened, so the response stays minimal in the common case.
type removeTrackResponse struct {
DeletedTrackID string `json:"deleted_track_id"`
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
DeletedTrackID string `json:"deleted_track_id"`
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
}
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}.
//
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
// 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
// — failure there is non-fatal (the destructive part already completed) and
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
// nothing at all when the file cannot be removed (#3918).
//
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
// wire error codes. The codes this handler emits are not_found,
// The codes this handler emits are not_found,
// 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) {
@@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
unmonitor := false
if v := r.URL.Query().Get("unmonitor"); v != "" {
if parsed, err := strconv.ParseBool(v); err == nil {
unmonitor = parsed
}
}
// Defensive: RequireUser+RequireAdmin should have run upstream. If
// we got here without a user in context the routing is broken.
admin, ok := requireUser(w, r)
@@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack(
r.Context(), trackID, admin.ID, unmonitor,
)
deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID)
if err != nil {
if errors.Is(err, tracks.ErrNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
@@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
s := uuidToString(*deletedArtist)
resp.DeletedArtistID = &s
}
// Only emit the flag when unmonitor was requested AND it failed.
// Avoids "false" cluttering the wire when the operator didn't ask
// for an unmonitor in the first place.
if unmonitor && lidarrUnmonitorFailed {
t := true
resp.LidarrUnmonitorFailed = &t
}
writeJSON(w, http.StatusOK, resp)
}
+6 -100
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -15,41 +14,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
// tests dictate the return error. Used to drive both the Lidarr-success and
// Lidarr-failure branches without spinning up a stub HTTP server.
type fakeLidarrUnmonitorer struct {
calls []fakeUnmonitorCall
err error
}
type fakeUnmonitorCall struct {
trackMbid string
albumMbid string
}
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
return f.err
}
// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
// assert call counts and inject error returns.
func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
t.Helper()
fake := &fakeLidarrUnmonitorer{}
h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
return fake
}
// newAdminTracksRouter builds a test chi router with the admin-tracks
// endpoint. RequireAdmin middleware is applied; tests inject the user into
// context manually (RequireUser is bypassed). Mirrors the pattern from
// admin_lidarr_test.go and admin_quarantine_test.go.
// admin_quarantine_test.go.
func newAdminTracksRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
@@ -90,8 +60,8 @@ func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam str
}
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
// returns the track row. The mbid string is set on both track and album
// when non-empty so the unmonitor branch has the data it needs.
// returns the track row. The mbids are set on the track and album when
// non-empty.
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
@@ -206,11 +176,9 @@ func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
}
}
// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
// when the operator does NOT request unmonitor: 200 with deleted_track_id
// (and the optional cascade fields when they fired) but NO
// lidarr_unmonitor_failed key in the body.
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
// deleted_track_id and the optional cascade fields when they fired.
func TestHandleRemoveTrack_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
if got.DeletedArtistID == nil {
t.Error("deleted_artist_id missing; expected sole-track cascade")
}
if got.LidarrUnmonitorFailed != nil {
t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
*got.LidarrUnmonitorFailed)
}
// Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
// on a *bool drops it, but we double-check the wire to catch
// regressions.
var raw map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw: %v", err)
}
if _, ok := raw["lidarr_unmonitor_failed"]; ok {
t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
}
// File is gone, DB row is gone.
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -261,50 +214,3 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
t.Error("track row still exists")
}
}
// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
// operator sends ?unmonitor=true AND the Lidarr call fails, the response
// is still 200 (file + DB delete completed) but carries
// lidarr_unmonitor_failed: true.
func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
fake := installTracksLidarrStub(t, h)
fake.err = errors.New("lidarr exploded")
tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
w := doAdminTracksReq(t, h, http.MethodDelete,
"/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got removeTrackResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.DeletedTrackID != uuidToString(tr.ID) {
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
}
if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
}
// Lidarr was actually called with the right mbids.
if len(fake.calls) != 1 {
t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
}
// Destructive part still happened.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
t.Error("track row still exists")
}
}
+1 -2
View File
@@ -107,8 +107,7 @@ type wantsPage struct {
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// many of their albums the list holds, of how many, like Lidarr's artist
// index.
// many of their albums the list holds, of how many.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
+5 -5
View File
@@ -71,7 +71,7 @@ func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
beatles := "a0000000-0000-4000-8000-000000000002"
@@ -113,7 +113,7 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
@@ -226,7 +226,7 @@ func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantT
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
const group = "g0000000-0000-4000-8000-000000000010"
for _, down := range []bool{false, true} {
h, pool := testHandlersWithClientFn(t)
h, pool := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
@@ -253,7 +253,7 @@ func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
// A held album shows the library's own tracks, a lost file marked.
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
@@ -296,7 +296,7 @@ func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
// local band's album left off and put back, and a link that names no
// MusicBrainz album refused before MusicBrainz is asked.
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
+16 -26
View File
@@ -21,14 +21,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
@@ -41,7 +40,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, requestSvc *requests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -55,9 +54,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
requests: requestSvc,
lidarrQuarantine: lidarrQuar,
quarantine: quarantineSvc,
tracks: tracksSvc,
playlists: playlistsSvc,
coverart: coverEnricher,
@@ -229,9 +227,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/acquisition/services", h.handleListAcquisitionServices)
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
@@ -241,9 +236,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
// The queues that wait on an admin, for the rail and the Overview (#5605).
admin.Get("/summary", h.handleGetAdminSummary)
admin.Get("/requests", h.handleListAdminRequests)
@@ -290,7 +282,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
@@ -402,20 +393,19 @@ type handlers struct {
// librarySize memoises the track count that sizes the candidate pool
// (#3880). Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
lidarrCfg *lidarrconfig.Service
requests *requests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
librarySize *recommendation.LibrarySize
requests *requests.Service
quarantine *quarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
// notifier writes the notifications inbox (M489). Nil-safe: a nil
// notifier records nothing, which is what most handler tests want.
notifier *notifications.Notifier
+4 -9
View File
@@ -24,12 +24,11 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -66,17 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
requestSvc := requests.NewService(pool)
cat := catalogue.New(pool, nil, logger)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
// tracks.Service has no Lidarr unmonitorer in tests by default; the
// admin-tracks tests below override h.tracks via installTracksLidarrStub
// when they need a stubbed Lidarr.
quarantineSvc := quarantine.NewService(pool, "")
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
tracksSvc := tracks.NewService(pool, logger, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
return h, pool
}
+3 -4
View File
@@ -21,11 +21,10 @@ import (
// catalogueSearchLimit is how many results one search shows.
const catalogueSearchLimit = 25
// catalogueSearchResult is one row of GET /api/catalogue/search. The shape is
// the one the Lidarr search answered with, so the Discover page and request
// rows read it unchanged. artist_mbid and album_mbid are the parents a
// catalogueSearchResult is one row of GET /api/catalogue/search, read by the
// Discover page and request rows. artist_mbid and album_mbid are the parents a
// request needs: empty on an artist, the artist on an album, both on a track.
// An album is a MusicBrainz release group, as Lidarr's albums were.
// An album is a MusicBrainz release group.
type catalogueSearchResult struct {
MBID string `json:"mbid"`
Name string `json:"name"`
+1 -1
View File
@@ -56,7 +56,7 @@ func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server {
func catalogueHandlers(t *testing.T, down bool) *handlers {
t.Helper()
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
h.catalogue = catalogue.New(pool, mb, h.logger)
return h
+9
View File
@@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
// mirrors the shape the RequireAdmin middleware uses and that the spec
// defines for /api/admin/* errors.
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
+3 -3
View File
@@ -48,8 +48,8 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str
}
// publishQuarantineEvent broadcasts a quarantine action. User-side
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
// delete-via-lidarr are broadcast (empty UserID) because a single admin
// flag/unflag are scoped to the user; admin-side resolve / delete-file
// are broadcast (empty UserID) because a single admin
// action can affect every user who'd flagged that track — every client
// invalidates and the non-affected ones no-op when the query result
// matches their cache.
@@ -103,7 +103,7 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
})
}
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
// publishRequestStatusChanged broadcasts a request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
// route to the requester's user_id, not the admin's — the original user
+1 -1
View File
@@ -15,7 +15,7 @@ import (
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
// not go, wrapped the way quarantine.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{
+2 -2
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.requests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
paths := []string{
"/api/artists",
@@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
FileSize: 1, FileFormat: "mp3",
})
if _, err := pool.Exec(context.Background(),
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
user.ID, track.ID,
); err != nil {
t.Fatalf("quarantine insert: %v", err)
-55
View File
@@ -1,55 +0,0 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Helpers shared by the request, quarantine and notification tests. The
// Lidarr search they once sat beside is now the catalogue search
// (catalogue_test.go, M502 #5519).
// resetLidarrState clears requests rows and resets lidarr_config
// to the factory-default state (enabled=false, nulls) between tests.
func resetLidarrState(t *testing.T, h *handlers) {
t.Helper()
ctx := context.Background()
if _, err := h.pool.Exec(ctx,
"DELETE FROM requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
); err != nil {
t.Fatalf("resetLidarrState: %v", err)
}
}
// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL.
func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) {
t.Helper()
svc := lidarrconfig.New(h.pool)
err := svc.Save(context.Background(), lidarrconfig.Config{
Enabled: enabled,
BaseURL: baseURL,
APIKey: "test-key",
DefaultQualityProfileID: 1,
DefaultRootFolderPath: "/music",
})
if err != nil {
t.Fatalf("saveLidarrConfig: %v", err)
}
}
// newLidarrStub creates an httptest.Server that always returns the given body
// with 200 OK for any /api/v1/* path.
func newLidarrStub(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
+1 -1
View File
@@ -65,7 +65,7 @@ func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, start
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
_, err := pool.Exec(context.Background(),
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
userID, trackID,
)
if err != nil {
+5 -25
View File
@@ -53,27 +53,9 @@ func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications
}
}
// newApprovingLidarrStub answers every Lidarr call an approval makes.
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
t.Helper()
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
return stub
}
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "np-alice", "pw", false)
admin := seedUser(t, pool, "np-admin", "pw", true)
@@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
}
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "np-auto", "pw", false)
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
@@ -123,9 +104,8 @@ func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
}
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
+12 -12
View File
@@ -8,8 +8,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
@@ -22,7 +22,7 @@ type quarantineView struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
func quarantineViewFrom(row dbq.Quarantine) quarantineView {
return quarantineView{
UserID: uuidToString(row.UserID),
TrackID: uuidToString(row.TrackID),
@@ -55,12 +55,12 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
return
}
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
row, err := h.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrBadReason):
case errors.Is(err, quarantine.ErrBadReason):
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
case errors.Is(err, quarantine.ErrTrackNotFound):
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
default:
h.logger.Error("api: flag", "err", err)
@@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, quarantine.ErrQuarantineNotFound) {
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
return
}
@@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
if !ok {
return
}
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
rows, err := h.quarantine.ListMine(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list mine", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
@@ -134,10 +134,10 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
out := make([]quarantineMineView, 0, len(rows))
for _, row := range rows {
out = append(out, quarantineMineView{
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
Reason: string(row.LidarrQuarantine.Reason),
Notes: row.LidarrQuarantine.Notes,
CreatedAt: row.LidarrQuarantine.CreatedAt,
TrackID: uuidToString(row.Quarantine.TrackID),
Reason: string(row.Quarantine.Reason),
Notes: row.Quarantine.Notes,
CreatedAt: row.Quarantine.CreatedAt,
TrackTitle: row.TrackTitle,
TrackDurationMs: row.TrackDurationMs,
AlbumID: uuidToString(row.AlbumID),
+11 -11
View File
@@ -90,7 +90,7 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
if _, err := pool.Exec(context.Background(),
@@ -124,7 +124,7 @@ func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.
// returns 201 with kind=artist and status=pending.
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
@@ -158,7 +158,7 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
// kind=album without album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing album_mbid and album_title.
@@ -185,7 +185,7 @@ func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
// with missing track_mbid/track_title returns 400 with mbid_required.
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
@@ -212,7 +212,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
// requests, not bob's.
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -242,7 +242,7 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
@@ -264,7 +264,7 @@ func TestHandleGetRequest_OwnReturns200(t *testing.T) {
// cannot see another user's request.
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
charlie := seedUser(t, pool, "charlie", "pw", false)
@@ -293,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
// user's request.
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -318,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
// request returns 200 with status=rejected.
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
@@ -340,7 +340,7 @@ func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
// rejected request returns 409 with request_not_pending.
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
@@ -376,7 +376,7 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) {
// another user's request returns 409 (not_pending from the ownership miss).
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"context"
"testing"
)
// resetRequests clears requests rows between tests.
func resetRequests(t *testing.T, h *handlers) {
t.Helper()
if _, err := h.pool.Exec(context.Background(), "DELETE FROM requests"); err != nil {
t.Fatalf("resetRequests: %v", err)
}
}
-10
View File
@@ -61,16 +61,6 @@ const (
// log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge"
// Lidarr release change (M498 #5437): the duplicate resolver moved an
// album's monitored release in Lidarr to one that lists each song once.
// Written with no actor; the metadata names the album and both releases.
ActionLidarrReleaseChange Action = "lidarr_release_change"
// Lidarr rip search (M498 #5447): the resolver asked Lidarr to search an
// album whose only copy of a song is a video rip, for a better release.
// Written with no actor; the metadata names the album and how many rips.
ActionLidarrRipSearch Action = "lidarr_rip_search"
// Subsonic password (#5026): generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+1 -1
View File
@@ -106,7 +106,7 @@ func Default() Config {
Log: LogConfig{Level: "info", Format: "text"},
Branding: BrandingConfig{
AppName: "Minstrel",
Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration.",
Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition.",
},
Events: EventsConfig{
SessionTimeoutMinutes: 30,
+1 -1
View File
@@ -23,7 +23,7 @@ var ErrNoEmbeddedArt = errors.New("coverart: no embedded album art")
// usable picture; returns a wrapped error for I/O or tag-parse
// failures so callers can distinguish "no art" from "couldn't read".
//
// Picard- and Lidarr-tagged libraries typically carry the canonical
// Picard-tagged libraries typically carry the canonical
// art in this block already — extracting it beats every external API
// guess for accuracy and avoids a network round-trip.
func ExtractEmbeddedAlbumArt(path string) ([]byte, error) {
+1 -1
View File
@@ -91,7 +91,7 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error {
// Embedded layer: read the picture stored inside the audio file's
// tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr).
// Picard/Lidarr-tagged libraries typically carry the canonical art
// Picard-tagged libraries typically carry the canonical art
// here, so this beats every external provider for accuracy and
// avoids the network round-trip.
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
+2 -2
View File
@@ -19,11 +19,11 @@ SELECT
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
SELECT track_id FROM lidarr_quarantine
SELECT track_id FROM quarantine
UNION
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
(SELECT min(at) FROM (
SELECT created_at AS at FROM lidarr_quarantine
SELECT created_at AS at FROM quarantine
UNION ALL
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
(SELECT count(*) FROM duplicate_groups g
+2 -2
View File
@@ -84,8 +84,8 @@ type ListAuditLogByActionsParams struct {
}
// One page of the given actions, newest first: the duplicates report's
// "resolved automatically" activity (M498) reads the resolver's merges and
// Lidarr release changes from here.
// "resolved automatically" activity (M498) reads the resolver's merges from
// here.
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogByActions,
arg.Actions,
+4 -4
View File
@@ -28,7 +28,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE ml.user_id = $1 AND ml.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
@@ -108,7 +108,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -173,7 +173,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -237,7 +237,7 @@ WITH scored AS (
WHERE gl.user_id = $3 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $3 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
+1 -1
View File
@@ -24,7 +24,7 @@ SELECT pe.id AS event_id,
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY pe.started_at DESC
-109
View File
@@ -1,109 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: lidarr_config.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getLidarrConfig = `-- name: GetLidarrConfig :one
SELECT id, enabled, base_url, api_key, default_quality_profile_id,
default_metadata_profile_id, default_root_folder_path,
created_at, updated_at
FROM lidarr_config
WHERE id = 1
`
type GetLidarrConfigRow struct {
ID int16
Enabled bool
BaseUrl *string
ApiKey *string
DefaultQualityProfileID *int32
DefaultMetadataProfileID *int32
DefaultRootFolderPath *string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
func (q *Queries) GetLidarrConfig(ctx context.Context) (GetLidarrConfigRow, error) {
row := q.db.QueryRow(ctx, getLidarrConfig)
var i GetLidarrConfigRow
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.DefaultQualityProfileID,
&i.DefaultMetadataProfileID,
&i.DefaultRootFolderPath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateLidarrConfig = `-- name: UpdateLidarrConfig :one
UPDATE lidarr_config
SET enabled = $1,
base_url = $2,
api_key = $3,
default_quality_profile_id = $4,
default_metadata_profile_id = $5,
default_root_folder_path = $6,
updated_at = now()
WHERE id = 1
RETURNING id, enabled, base_url, api_key, default_quality_profile_id,
default_metadata_profile_id, default_root_folder_path,
created_at, updated_at
`
type UpdateLidarrConfigParams struct {
Enabled bool
BaseUrl *string
ApiKey *string
DefaultQualityProfileID *int32
DefaultMetadataProfileID *int32
DefaultRootFolderPath *string
}
type UpdateLidarrConfigRow struct {
ID int16
Enabled bool
BaseUrl *string
ApiKey *string
DefaultQualityProfileID *int32
DefaultMetadataProfileID *int32
DefaultRootFolderPath *string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (UpdateLidarrConfigRow, error) {
row := q.db.QueryRow(ctx, updateLidarrConfig,
arg.Enabled,
arg.BaseUrl,
arg.ApiKey,
arg.DefaultQualityProfileID,
arg.DefaultMetadataProfileID,
arg.DefaultRootFolderPath,
)
var i UpdateLidarrConfigRow
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.DefaultQualityProfileID,
&i.DefaultMetadataProfileID,
&i.DefaultRootFolderPath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+2 -8
View File
@@ -12,11 +12,9 @@ import (
)
const listDuplicateGroupMergeMembers = `-- name: ListDuplicateGroupMergeMembers :many
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
t.mbid, albums.mbid AS album_mbid
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id
FROM duplicate_group_members m
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN tracks t ON t.id = m.track_id
WHERE m.group_id = $1
ORDER BY t.id
`
@@ -28,8 +26,6 @@ type ListDuplicateGroupMergeMembersRow struct {
FileSize int64
AddedAt pgtype.Timestamptz
AlbumID pgtype.UUID
Mbid *string
AlbumMbid *string
}
func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupMergeMembersRow, error) {
@@ -48,8 +44,6 @@ func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pg
&i.FileSize,
&i.AddedAt,
&i.AlbumID,
&i.Mbid,
&i.AlbumMbid,
); err != nil {
return nil, err
}
+2 -2
View File
@@ -63,8 +63,8 @@ type ListMissingTrackPairsRow struct {
}
// Missing-pair pass (M485). A track marked missing whose replacement is already
// on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at
// a new path — is folded into that replacement, so its history moves across and
// on disk under another row — most often a quality upgrade, mp3 → flac at a new
// path — is folded into that replacement, so its history moves across and
// re-acquisition stops chasing music the library already has.
//
// Move adoption (#2528) cannot catch these: it links a new file to a missing row
+55 -67
View File
@@ -11,92 +11,92 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type LidarrQuarantineActionKind string
type QuarantineActionKind string
const (
LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved"
LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file"
LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr"
QuarantineActionKindResolved QuarantineActionKind = "resolved"
QuarantineActionKindDeletedFile QuarantineActionKind = "deleted_file"
QuarantineActionKindDeletedAlbum QuarantineActionKind = "deleted_album"
)
func (e *LidarrQuarantineActionKind) Scan(src interface{}) error {
func (e *QuarantineActionKind) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrQuarantineActionKind(s)
*e = QuarantineActionKind(s)
case string:
*e = LidarrQuarantineActionKind(s)
*e = QuarantineActionKind(s)
default:
return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src)
return fmt.Errorf("unsupported scan type for QuarantineActionKind: %T", src)
}
return nil
}
type NullLidarrQuarantineActionKind struct {
LidarrQuarantineActionKind LidarrQuarantineActionKind
Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL
type NullQuarantineActionKind struct {
QuarantineActionKind QuarantineActionKind
Valid bool // Valid is true if QuarantineActionKind is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error {
func (ns *NullQuarantineActionKind) Scan(value interface{}) error {
if value == nil {
ns.LidarrQuarantineActionKind, ns.Valid = "", false
ns.QuarantineActionKind, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrQuarantineActionKind.Scan(value)
return ns.QuarantineActionKind.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) {
func (ns NullQuarantineActionKind) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrQuarantineActionKind), nil
return string(ns.QuarantineActionKind), nil
}
type LidarrQuarantineReason string
type QuarantineReason string
const (
LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip"
LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file"
LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags"
LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate"
LidarrQuarantineReasonOther LidarrQuarantineReason = "other"
QuarantineReasonBadRip QuarantineReason = "bad_rip"
QuarantineReasonWrongFile QuarantineReason = "wrong_file"
QuarantineReasonWrongTags QuarantineReason = "wrong_tags"
QuarantineReasonDuplicate QuarantineReason = "duplicate"
QuarantineReasonOther QuarantineReason = "other"
)
func (e *LidarrQuarantineReason) Scan(src interface{}) error {
func (e *QuarantineReason) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrQuarantineReason(s)
*e = QuarantineReason(s)
case string:
*e = LidarrQuarantineReason(s)
*e = QuarantineReason(s)
default:
return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src)
return fmt.Errorf("unsupported scan type for QuarantineReason: %T", src)
}
return nil
}
type NullLidarrQuarantineReason struct {
LidarrQuarantineReason LidarrQuarantineReason
Valid bool // Valid is true if LidarrQuarantineReason is not NULL
type NullQuarantineReason struct {
QuarantineReason QuarantineReason
Valid bool // Valid is true if QuarantineReason is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error {
func (ns *NullQuarantineReason) Scan(value interface{}) error {
if value == nil {
ns.LidarrQuarantineReason, ns.Valid = "", false
ns.QuarantineReason, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrQuarantineReason.Scan(value)
return ns.QuarantineReason.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
func (ns NullQuarantineReason) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrQuarantineReason), nil
return string(ns.QuarantineReason), nil
}
type RequestKind string
@@ -506,39 +506,6 @@ type LibraryChange struct {
ChangedAt pgtype.Timestamptz
}
type LidarrConfig struct {
ID int16
Enabled bool
BaseUrl *string
ApiKey *string
DefaultQualityProfileID *int32
DefaultRootFolderPath *string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
DefaultMetadataProfileID *int32
}
type LidarrQuarantine struct {
UserID pgtype.UUID
TrackID pgtype.UUID
Reason LidarrQuarantineReason
Notes *string
CreatedAt pgtype.Timestamptz
}
type LidarrQuarantineAction struct {
ID pgtype.UUID
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle *string
Action LidarrQuarantineActionKind
AdminID pgtype.UUID
LidarrAlbumMbid *string
AffectedUsers int32
CreatedAt pgtype.Timestamptz
}
type ListenbrainzSimilarRecording struct {
SeedTrackID pgtype.UUID
RecordingMbid string
@@ -696,6 +663,27 @@ type PlaylistTrack struct {
PickKind *string
}
type Quarantine struct {
UserID pgtype.UUID
TrackID pgtype.UUID
Reason QuarantineReason
Notes *string
CreatedAt pgtype.Timestamptz
}
type QuarantineAction struct {
ID pgtype.UUID
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle *string
Action QuarantineActionKind
AdminID pgtype.UUID
AlbumMbid *string
AffectedUsers int32
CreatedAt pgtype.Timestamptz
}
type ReacquisitionSetting struct {
ID bool
Enabled bool
@@ -1,7 +1,7 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: lidarr_quarantine.sql
// source: quarantine.sql
package dbq
@@ -12,7 +12,7 @@ import (
)
const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one
SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1
SELECT count(*)::int FROM quarantine WHERE track_id = $1
`
// Reads affected_users for the audit row before the delete fires.
@@ -24,7 +24,7 @@ func (q *Queries) CountQuarantineForTrack(ctx context.Context, trackID pgtype.UU
}
const deleteQuarantine = `-- name: DeleteQuarantine :one
DELETE FROM lidarr_quarantine
DELETE FROM quarantine
WHERE user_id = $1 AND track_id = $2
RETURNING user_id, track_id, reason, notes, created_at
`
@@ -36,9 +36,9 @@ type DeleteQuarantineParams struct {
// Removes the caller's row. Returns the deleted row so the handler can
// distinguish "no row existed" (zero rows -> ErrNoRows) from success.
func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (LidarrQuarantine, error) {
func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (Quarantine, error) {
row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID)
var i LidarrQuarantine
var i Quarantine
err := row.Scan(
&i.UserID,
&i.TrackID,
@@ -50,7 +50,7 @@ func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantinePara
}
const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec
DELETE FROM lidarr_quarantine WHERE track_id = $1
DELETE FROM quarantine WHERE track_id = $1
`
// Clears all per-user rows for a given track. Used by Resolve and the
@@ -68,26 +68,24 @@ SELECT
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id,
al.mbid AS lidarr_album_mbid,
count(q.user_id)::int AS report_count,
max(q.created_at)::timestamptz AS latest_at
FROM lidarr_quarantine q
FROM quarantine q
JOIN tracks t ON t.id = q.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
GROUP BY t.id, ar.name, al.title, al.id, al.mbid
GROUP BY t.id, ar.name, al.title, al.id
ORDER BY max(q.created_at) DESC
`
type ListAdminQuarantineQueueRow struct {
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
LidarrAlbumMbid *string
ReportCount int32
LatestAt pgtype.Timestamptz
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
ReportCount int32
LatestAt pgtype.Timestamptz
}
// Aggregated admin queue. One row per track. The handler post-processes
@@ -108,7 +106,6 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
&i.ArtistName,
&i.AlbumTitle,
&i.AlbumID,
&i.LidarrAlbumMbid,
&i.ReportCount,
&i.LatestAt,
); err != nil {
@@ -123,20 +120,20 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
}
const listQuarantineActions = `-- name: ListQuarantineActions :many
SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at FROM lidarr_quarantine_actions
SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at FROM quarantine_actions
ORDER BY created_at DESC
LIMIT $1
`
func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]LidarrQuarantineAction, error) {
func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]QuarantineAction, error) {
rows, err := q.db.Query(ctx, listQuarantineActions, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrQuarantineAction
var items []QuarantineAction
for rows.Next() {
var i LidarrQuarantineAction
var i QuarantineAction
if err := rows.Scan(
&i.ID,
&i.TrackID,
@@ -145,7 +142,7 @@ func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]Lid
&i.AlbumTitle,
&i.Action,
&i.AdminID,
&i.LidarrAlbumMbid,
&i.AlbumMbid,
&i.AffectedUsers,
&i.CreatedAt,
); err != nil {
@@ -170,7 +167,7 @@ SELECT
al.cover_art_path AS album_cover_art_path,
ar.id AS artist_id,
ar.name AS artist_name
FROM lidarr_quarantine q
FROM quarantine q
JOIN tracks t ON t.id = q.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
@@ -179,7 +176,7 @@ ORDER BY q.created_at DESC
`
type ListQuarantineForUserRow struct {
LidarrQuarantine LidarrQuarantine
Quarantine Quarantine
TrackIDJoin pgtype.UUID
TrackTitle string
TrackDurationMs int32
@@ -204,11 +201,11 @@ func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID)
for rows.Next() {
var i ListQuarantineForUserRow
if err := rows.Scan(
&i.LidarrQuarantine.UserID,
&i.LidarrQuarantine.TrackID,
&i.LidarrQuarantine.Reason,
&i.LidarrQuarantine.Notes,
&i.LidarrQuarantine.CreatedAt,
&i.Quarantine.UserID,
&i.Quarantine.TrackID,
&i.Quarantine.Reason,
&i.Quarantine.Notes,
&i.Quarantine.CreatedAt,
&i.TrackIDJoin,
&i.TrackTitle,
&i.TrackDurationMs,
@@ -235,7 +232,7 @@ SELECT
q.reason,
q.notes,
q.created_at
FROM lidarr_quarantine q
FROM quarantine q
JOIN users u ON u.id = q.user_id
WHERE q.track_id = $1
ORDER BY q.created_at DESC
@@ -244,7 +241,7 @@ ORDER BY q.created_at DESC
type ListQuarantineReportsForTrackRow struct {
UserID pgtype.UUID
Username string
Reason LidarrQuarantineReason
Reason QuarantineReason
Notes *string
CreatedAt pgtype.Timestamptz
}
@@ -278,7 +275,7 @@ func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgt
}
const upsertQuarantine = `-- name: UpsertQuarantine :one
INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes)
INSERT INTO quarantine (user_id, track_id, reason, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, track_id) DO UPDATE SET
reason = EXCLUDED.reason,
@@ -290,20 +287,20 @@ RETURNING user_id, track_id, reason, notes, created_at
type UpsertQuarantineParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
Reason LidarrQuarantineReason
Reason QuarantineReason
Notes *string
}
// Insert a new quarantine row, or update reason/notes if the user has
// already flagged this track.
func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (LidarrQuarantine, error) {
func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (Quarantine, error) {
row := q.db.QueryRow(ctx, upsertQuarantine,
arg.UserID,
arg.TrackID,
arg.Reason,
arg.Notes,
)
var i LidarrQuarantine
var i Quarantine
err := row.Scan(
&i.UserID,
&i.TrackID,
@@ -315,26 +312,25 @@ func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantinePara
}
const writeQuarantineAction = `-- name: WriteQuarantineAction :one
INSERT INTO lidarr_quarantine_actions (
INSERT INTO quarantine_actions (
track_id, track_title, artist_name, album_title,
action, admin_id, lidarr_album_mbid, affected_users
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at
action, admin_id, affected_users
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at
`
type WriteQuarantineActionParams struct {
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle *string
Action LidarrQuarantineActionKind
AdminID pgtype.UUID
LidarrAlbumMbid *string
AffectedUsers int32
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle *string
Action QuarantineActionKind
AdminID pgtype.UUID
AffectedUsers int32
}
// Audit row for an admin destructive action.
func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (LidarrQuarantineAction, error) {
func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (QuarantineAction, error) {
row := q.db.QueryRow(ctx, writeQuarantineAction,
arg.TrackID,
arg.TrackTitle,
@@ -342,10 +338,9 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
arg.AlbumTitle,
arg.Action,
arg.AdminID,
arg.LidarrAlbumMbid,
arg.AffectedUsers,
)
var i LidarrQuarantineAction
var i QuarantineAction
err := row.Scan(
&i.ID,
&i.TrackID,
@@ -354,7 +349,7 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
&i.AlbumTitle,
&i.Action,
&i.AdminID,
&i.LidarrAlbumMbid,
&i.AlbumMbid,
&i.AffectedUsers,
&i.CreatedAt,
)
+4 -4
View File
@@ -218,7 +218,7 @@ JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -304,7 +304,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -728,7 +728,7 @@ WHERE t.id <> $2
AND started_at > now() - $3 * interval '1 hour'
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
`
@@ -827,7 +827,7 @@ excluded_ids AS (
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
UNION ALL
SELECT q.track_id AS id
FROM lidarr_quarantine q
FROM quarantine q
WHERE q.user_id = $1
),
lb_similar AS (
+5 -5
View File
@@ -43,7 +43,7 @@ SELECT t.id, t.album_id, t.artist_id
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -129,7 +129,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
AND alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
@@ -229,7 +229,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
@@ -308,7 +308,7 @@ SELECT t.id, t.album_id, t.artist_id
JOIN windowed w ON w.track_id = t.id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY w.c DESC, md5(t.id::text || $2::text)
@@ -375,7 +375,7 @@ SELECT t.id, t.album_id, t.artist_id
AND s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
+1 -1
View File
@@ -197,7 +197,7 @@ JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
`
+10 -10
View File
@@ -46,8 +46,8 @@ UPDATE tracks
AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text
`
// A held-back track whose name no longer carries a marker (renamed, retagged
// by Lidarr) is released again.
// A held-back track whose name no longer carries a marker (renamed or
// retagged) is released again.
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
if err != nil {
@@ -130,7 +130,7 @@ const countTracksMatching = `-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
`
@@ -162,8 +162,8 @@ type DeleteTrackRow struct {
}
// M7 #372: hard delete with FK cascade. The CASCADE on track_id from
// play_events / general_likes_tracks / lidarr_quarantine /
// lidarr_quarantine_actions handles their cleanup. RETURNING gives us
// play_events / general_likes_tracks / quarantine /
// quarantine_actions handles their cleanup. RETURNING gives us
// album_id + artist_id for the album-empty / artist-empty cascade
// checks the service does next.
func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) {
@@ -414,7 +414,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
@@ -433,7 +433,7 @@ type ListArtistTracksForUserRow struct {
}
// M6a: every track for the artist across their albums, with album_title
// and artist_name joined. Honors per-user lidarr_quarantine. Used by
// and artist_name joined. Honors per-user quarantine. Used by
// /api/artists/{id}/tracks for the artist-card play affordance, which
// shuffles client-side. Ordering matches album/track natural order so
// the shuffle has a deterministic input.
@@ -578,7 +578,7 @@ FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY random()
@@ -786,7 +786,7 @@ const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY disc_number NULLS LAST, track_number NULLS LAST
@@ -910,7 +910,7 @@ const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY title
+1 -1
View File
@@ -205,7 +205,7 @@ DELETE FROM users WHERE id = $1
// Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
// handle plays/likes/sessions/etc. Caller must enforce the last-admin
// guard at the HTTP layer (count admins, refuse if target is the only
// admin). The lidarr_quarantine, general_likes*, play_events,
// admin). The quarantine, general_likes*, play_events,
// sessions, and other user-FK tables all have ON DELETE CASCADE so
// this is a clean drop.
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
@@ -0,0 +1,29 @@
ALTER TABLE quarantine_actions RENAME CONSTRAINT quarantine_actions_admin_id_fkey TO lidarr_quarantine_actions_admin_id_fkey;
ALTER TABLE quarantine RENAME CONSTRAINT quarantine_track_id_fkey TO lidarr_quarantine_track_id_fkey;
ALTER TABLE quarantine RENAME CONSTRAINT quarantine_user_id_fkey TO lidarr_quarantine_user_id_fkey;
ALTER INDEX quarantine_actions_created_idx RENAME TO lidarr_quarantine_actions_created_idx;
ALTER INDEX quarantine_actions_track_idx RENAME TO lidarr_quarantine_actions_track_idx;
ALTER INDEX quarantine_actions_pkey RENAME TO lidarr_quarantine_actions_pkey;
ALTER TABLE quarantine_actions RENAME COLUMN album_mbid TO lidarr_album_mbid;
ALTER TYPE quarantine_action_kind RENAME VALUE 'deleted_album' TO 'deleted_via_lidarr';
ALTER TYPE quarantine_action_kind RENAME TO lidarr_quarantine_action_kind;
ALTER TABLE quarantine_actions RENAME TO lidarr_quarantine_actions;
ALTER INDEX quarantine_pkey RENAME TO lidarr_quarantine_pkey;
ALTER INDEX quarantine_track_idx RENAME TO lidarr_quarantine_track_idx;
ALTER TYPE quarantine_reason RENAME TO lidarr_quarantine_reason;
ALTER TABLE quarantine RENAME TO lidarr_quarantine;
CREATE TABLE lidarr_config (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
enabled boolean NOT NULL DEFAULT false,
base_url text,
api_key text,
default_quality_profile_id int,
default_root_folder_path text,
default_metadata_profile_id integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO lidarr_config (id, enabled) VALUES (1, false);
@@ -0,0 +1,29 @@
-- Minstrel no longer talks to Lidarr (M502 #5525): it finds, downloads,
-- checks and files music itself.
--
-- lidarr_config the connection's settings: dropped.
-- lidarr_quarantine(_actions) the per-user track reports and the admin
-- actions on them, which never needed Lidarr:
-- renamed to quarantine(_actions), with their
-- types and indexes.
-- 'deleted_via_lidarr' the history of an album removed through
-- Lidarr reads as what it did: 'deleted_album'.
-- lidarr_album_mbid the album id that removal recorded: album_mbid.
DROP TABLE lidarr_config;
ALTER TABLE lidarr_quarantine RENAME TO quarantine;
ALTER TYPE lidarr_quarantine_reason RENAME TO quarantine_reason;
ALTER INDEX lidarr_quarantine_track_idx RENAME TO quarantine_track_idx;
ALTER INDEX lidarr_quarantine_pkey RENAME TO quarantine_pkey;
ALTER TABLE lidarr_quarantine_actions RENAME TO quarantine_actions;
ALTER TYPE lidarr_quarantine_action_kind RENAME TO quarantine_action_kind;
ALTER TYPE quarantine_action_kind RENAME VALUE 'deleted_via_lidarr' TO 'deleted_album';
ALTER TABLE quarantine_actions RENAME COLUMN lidarr_album_mbid TO album_mbid;
ALTER INDEX lidarr_quarantine_actions_pkey RENAME TO quarantine_actions_pkey;
ALTER INDEX lidarr_quarantine_actions_track_idx RENAME TO quarantine_actions_track_idx;
ALTER INDEX lidarr_quarantine_actions_created_idx RENAME TO quarantine_actions_created_idx;
ALTER TABLE quarantine RENAME CONSTRAINT lidarr_quarantine_user_id_fkey TO quarantine_user_id_fkey;
ALTER TABLE quarantine RENAME CONSTRAINT lidarr_quarantine_track_id_fkey TO quarantine_track_id_fkey;
ALTER TABLE quarantine_actions RENAME CONSTRAINT lidarr_quarantine_actions_admin_id_fkey TO quarantine_actions_admin_id_fkey;
+2 -2
View File
@@ -11,11 +11,11 @@ SELECT
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
SELECT track_id FROM lidarr_quarantine
SELECT track_id FROM quarantine
UNION
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
(SELECT min(at) FROM (
SELECT created_at AS at FROM lidarr_quarantine
SELECT created_at AS at FROM quarantine
UNION ALL
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
(SELECT count(*) FROM duplicate_groups g
+2 -2
View File
@@ -15,8 +15,8 @@ SELECT count(*)::bigint FROM audit_log
-- name: ListAuditLogByActions :many
-- One page of the given actions, newest first: the duplicates report's
-- "resolved automatically" activity (M498) reads the resolver's merges and
-- Lidarr release changes from here.
-- "resolved automatically" activity (M498) reads the resolver's merges from
-- here.
SELECT * FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[])
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL)
+4 -4
View File
@@ -41,7 +41,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -74,7 +74,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE ml.user_id = $1 AND ml.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
@@ -100,7 +100,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -144,7 +144,7 @@ WITH scored AS (
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
+1 -1
View File
@@ -16,7 +16,7 @@ SELECT pe.id AS event_id,
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY pe.started_at DESC
-20
View File
@@ -1,20 +0,0 @@
-- name: GetLidarrConfig :one
SELECT id, enabled, base_url, api_key, default_quality_profile_id,
default_metadata_profile_id, default_root_folder_path,
created_at, updated_at
FROM lidarr_config
WHERE id = 1;
-- name: UpdateLidarrConfig :one
UPDATE lidarr_config
SET enabled = $1,
base_url = $2,
api_key = $3,
default_quality_profile_id = $4,
default_metadata_profile_id = $5,
default_root_folder_path = $6,
updated_at = now()
WHERE id = 1
RETURNING id, enabled, base_url, api_key, default_quality_profile_id,
default_metadata_profile_id, default_root_folder_path,
created_at, updated_at;
+2 -4
View File
@@ -13,11 +13,9 @@ SELECT id, tier, status
FOR UPDATE;
-- name: ListDuplicateGroupMergeMembers :many
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
t.mbid, albums.mbid AS album_mbid
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id
FROM duplicate_group_members m
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN tracks t ON t.id = m.track_id
WHERE m.group_id = sqlc.arg(group_id)
ORDER BY t.id;
+2 -2
View File
@@ -1,6 +1,6 @@
-- Missing-pair pass (M485). A track marked missing whose replacement is already
-- on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at
-- a new path — is folded into that replacement, so its history moves across and
-- on disk under another row — most often a quality upgrade, mp3 → flac at a new
-- path — is folded into that replacement, so its history moves across and
-- re-acquisition stops chasing music the library already has.
--
-- Move adoption (#2528) cannot catch these: it links a new file to a missing row
@@ -1,7 +1,7 @@
-- name: UpsertQuarantine :one
-- Insert a new quarantine row, or update reason/notes if the user has
-- already flagged this track.
INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes)
INSERT INTO quarantine (user_id, track_id, reason, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, track_id) DO UPDATE SET
reason = EXCLUDED.reason,
@@ -12,7 +12,7 @@ RETURNING user_id, track_id, reason, notes, created_at;
-- name: DeleteQuarantine :one
-- Removes the caller's row. Returns the deleted row so the handler can
-- distinguish "no row existed" (zero rows -> ErrNoRows) from success.
DELETE FROM lidarr_quarantine
DELETE FROM quarantine
WHERE user_id = $1 AND track_id = $2
RETURNING user_id, track_id, reason, notes, created_at;
@@ -31,7 +31,7 @@ SELECT
al.cover_art_path AS album_cover_art_path,
ar.id AS artist_id,
ar.name AS artist_name
FROM lidarr_quarantine q
FROM quarantine q
JOIN tracks t ON t.id = q.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
@@ -48,14 +48,13 @@ SELECT
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id,
al.mbid AS lidarr_album_mbid,
count(q.user_id)::int AS report_count,
max(q.created_at)::timestamptz AS latest_at
FROM lidarr_quarantine q
FROM quarantine q
JOIN tracks t ON t.id = q.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
GROUP BY t.id, ar.name, al.title, al.id, al.mbid
GROUP BY t.id, ar.name, al.title, al.id
ORDER BY max(q.created_at) DESC;
-- name: ListQuarantineReportsForTrack :many
@@ -67,7 +66,7 @@ SELECT
q.reason,
q.notes,
q.created_at
FROM lidarr_quarantine q
FROM quarantine q
JOIN users u ON u.id = q.user_id
WHERE q.track_id = $1
ORDER BY q.created_at DESC;
@@ -76,21 +75,21 @@ ORDER BY q.created_at DESC;
-- Clears all per-user rows for a given track. Used by Resolve and the
-- two delete actions. Caller writes the audit row separately before
-- this fires (so we can capture the affected_users count).
DELETE FROM lidarr_quarantine WHERE track_id = $1;
DELETE FROM quarantine WHERE track_id = $1;
-- name: CountQuarantineForTrack :one
-- Reads affected_users for the audit row before the delete fires.
SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1;
SELECT count(*)::int FROM quarantine WHERE track_id = $1;
-- name: WriteQuarantineAction :one
-- Audit row for an admin destructive action.
INSERT INTO lidarr_quarantine_actions (
INSERT INTO quarantine_actions (
track_id, track_title, artist_name, album_title,
action, admin_id, lidarr_album_mbid, affected_users
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
action, admin_id, affected_users
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListQuarantineActions :many
SELECT * FROM lidarr_quarantine_actions
SELECT * FROM quarantine_actions
ORDER BY created_at DESC
LIMIT $1;
+4 -4
View File
@@ -31,7 +31,7 @@ WHERE t.id <> $2
AND started_at > now() - $3 * interval '1 hour'
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
);
@@ -83,7 +83,7 @@ excluded_ids AS (
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
UNION ALL
SELECT q.track_id AS id
FROM lidarr_quarantine q
FROM quarantine q
WHERE q.user_id = $1
),
lb_similar AS (
@@ -416,7 +416,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -443,7 +443,7 @@ JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = sqlc.arg(artist_id)
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
+5 -5
View File
@@ -44,7 +44,7 @@ SELECT t.id, t.album_id, t.artist_id
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -75,7 +75,7 @@ SELECT t.id, t.album_id, t.artist_id
AND s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
@@ -153,7 +153,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
@@ -192,7 +192,7 @@ SELECT t.id, t.album_id, t.artist_id
JOIN windowed w ON w.track_id = t.id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY w.c DESC, md5(t.id::text || $2::text)
@@ -248,7 +248,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
AND alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
+1 -1
View File
@@ -24,7 +24,7 @@ JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
);
+10 -10
View File
@@ -62,7 +62,7 @@ SELECT * FROM tracks WHERE file_path = $1;
SELECT * FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
@@ -76,7 +76,7 @@ SELECT count(*) FROM tracks WHERE album_id = $1;
SELECT * FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY title
@@ -87,13 +87,13 @@ LIMIT $3 OFFSET $4;
SELECT COUNT(*) FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
);
-- name: ListArtistTracksForUser :many
-- M6a: every track for the artist across their albums, with album_title
-- and artist_name joined. Honors per-user lidarr_quarantine. Used by
-- and artist_name joined. Honors per-user quarantine. Used by
-- /api/artists/{id}/tracks for the artist-card play affordance, which
-- shuffles client-side. Ordering matches album/track natural order so
-- the shuffle has a deterministic input.
@@ -105,7 +105,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
@@ -122,7 +122,7 @@ FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY random()
@@ -136,8 +136,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
-- name: DeleteTrack :one
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
-- play_events / general_likes_tracks / lidarr_quarantine /
-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us
-- play_events / general_likes_tracks / quarantine /
-- quarantine_actions handles their cleanup. RETURNING gives us
-- album_id + artist_id for the album-empty / artist-empty cascade
-- checks the service does next.
DELETE FROM tracks WHERE id = $1
@@ -321,8 +321,8 @@ UPDATE tracks
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: ClearStaleSuspectSourceTracks :execrows
-- A held-back track whose name no longer carries a marker (renamed, retagged
-- by Lidarr) is released again.
-- A held-back track whose name no longer carries a marker (renamed or
-- retagged) is released again.
UPDATE tracks
SET source_verdict = NULL, source_verdict_at = NULL
WHERE source_verdict = 'suspect'
+1 -1
View File
@@ -96,7 +96,7 @@ RETURNING *;
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
-- guard at the HTTP layer (count admins, refuse if target is the only
-- admin). The lidarr_quarantine, general_likes*, play_events,
-- admin). The quarantine, general_likes*, play_events,
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
-- this is a clean drop.
DELETE FROM users WHERE id = $1;
+3 -3
View File
@@ -53,8 +53,8 @@ var dataTables = []string{
"skip_events",
"play_sessions",
"sessions",
"lidarr_quarantine_actions",
"lidarr_quarantine",
"quarantine_actions",
"quarantine",
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
@@ -108,7 +108,7 @@ var dataTables = []string{
"mb_artist_release_groups", // M502 #5519
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
"requests", // M502 #5520 (was lidarr_requests)
"requests", // M502 #5520
"download_interventions", // M502 #5523
"library_album_matches", // #5569
"release_blocks", // M502 #5521
+4 -5
View File
@@ -20,9 +20,8 @@ type Placement struct {
Recording string
}
// Plan names every file's destination under root the way Lidarr named the
// library Minstrel took over (note #5527), so new arrivals sit beside what
// is there:
// Plan names every file's destination under root the way the library is
// already named (note #5527), so new arrivals sit beside what is there:
//
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
//
@@ -83,11 +82,11 @@ const segmentMax = 200
var illegal = strings.NewReplacer(
`\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "",
// Lidarr's default "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
// The "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
": ", " - ", ":", "-",
)
// Segment makes a name safe as one path segment, the way Lidarr and Sonarr
// Segment makes a name safe as one path segment, the way the *arr programs
// clean file names: their illegal-character mapping, no leading or trailing
// spaces or dots, at most segmentMax bytes, never "" or "..".
func Segment(s string) string {
+1 -1
View File
@@ -161,7 +161,7 @@ func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
}
}
func TestPlan_LidarrsNaming(t *testing.T) {
func TestPlan_LibraryNaming(t *testing.T) {
e := edition("rel-cd", 2)
e.Tracks[1].Title = "Within: Part 2/3?"
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
+3 -5
View File
@@ -27,10 +27,8 @@ var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the
// MergedCopy is one copy a merge kept or removed.
type MergedCopy struct {
TrackID pgtype.UUID
FilePath string
TrackMbid *string
AlbumMbid *string
TrackID pgtype.UUID
FilePath string
}
// MergeResult says what a merge did.
@@ -296,7 +294,7 @@ func splitSurvivor(
}
func mergedCopyOf(m dbq.ListDuplicateGroupMergeMembersRow) MergedCopy {
return MergedCopy{TrackID: m.ID, FilePath: m.FilePath, TrackMbid: m.Mbid, AlbumMbid: m.AlbumMbid}
return MergedCopy{TrackID: m.ID, FilePath: m.FilePath}
}
// mergeChanges collects the sync-log entries a merge owes clients' caches, each
+1 -1
View File
@@ -17,7 +17,7 @@ import (
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Duplicate resolver (M498 #5435, #5437; without Lidarr since M502 #5517).
// Duplicate resolver (M498 #5435, #5437; M502 #5517).
//
// The sweep proposes duplicate groups; the resolver decides what each one is
// and settles the ones that are safe to settle without asking. Minstrel is the
+3 -5
View File
@@ -93,9 +93,8 @@ func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n in
}
// One album holding a song twice: the extra copy goes, the copy whose name and
// tags fit the album stays, and the merge is recorded as the resolver's. No
// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
// library.
// tags fit the album stays, and the merge is recorded as the resolver's
// (M502 #5517): Minstrel is the only program writing the library.
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
f := newResolveFixture(t)
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
@@ -192,8 +191,7 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
}
// A video rip on one release with the clean copy on another: the rip is no
// release's copy, so it goes and the album cut stays (#5439, now without
// Lidarr).
// release's copy, so it goes and the album cut stays (#5439).
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
+2 -2
View File
@@ -56,8 +56,8 @@ type FingerprintSettings struct {
BackfillConcurrency int32
SweepIntervalHours int32
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
// copies Lidarr does not need and change a Lidarr release that lists songs
// twice. Off, it only classifies, and every group waits for the operator.
// copies no release needs. Off, it only classifies, and every group waits
// for the operator.
AutoResolve bool
// UpdatedAt is when the settings were last saved. Set by the database;
// ignored by Set.
+1 -1
View File
@@ -44,7 +44,7 @@ func extractRecordingMBID(m tag.Metadata) string {
// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID —
// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as
// mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one
// edition. Lidarr names albums by release group, so re-acquisition and
// edition. Wants name albums by release group, so re-acquisition and
// request completion key on this one (#5241). Separate from extractMBIDs for
// the same reason extractRecordingMBID is.
func extractReleaseGroupMBID(m tag.Metadata) string {
+2 -2
View File
@@ -18,8 +18,8 @@ import (
// A track marked missing whose replacement already sits on disk under another
// row is folded into that replacement: its likes, plays, playlist entries and
// tags move across and the missing row goes. On the operator's library this was
// 355 of 451 missing tracks (#5274), nearly all Lidarr quality upgrades — the
// mp3 removed, a flac imported at a new path.
// 355 of 451 missing tracks (#5274), nearly all quality upgrades — the mp3
// removed, a flac imported at a new path.
//
// Move adoption (moved.go) can't catch these. It links a new file to a missing
// row when the file is first inserted, matching on the recording MBID or the
+1 -1
View File
@@ -231,7 +231,7 @@ func TestPairMissingTracks_Integration(t *testing.T) {
}
}
// The case that made M485: a Lidarr quality upgrade. The mp3 is removed and a
// The case that made M485: a quality upgrade. The mp3 is removed and a
// re-encode of it arrives at a new path with no recording MBID, so move adoption
// has nothing to match. The scan must still end with one row, carrying the old
// row's history.

Some files were not shown because too many files have changed in this diff Show More