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
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:
@@ -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.
|
||||
|
||||
|
||||
-3
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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.",
|
||||
|
||||
+3
-7
@@ -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)
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+7
-7
@@ -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,
|
||||
) {
|
||||
|
||||
+11
-11
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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/*.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user