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
|
# 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.
|
> 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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
|
## 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
|
## 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>
|
<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.
|
**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_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`).
|
- `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.
|
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 resolve(trackId: String) = api.resolve(trackId)
|
||||||
|
|
||||||
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
||||||
|
|
||||||
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers ──
|
// ── Mappers ──
|
||||||
@@ -49,7 +47,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
|
|||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
albumTitle = albumTitle,
|
albumTitle = albumTitle,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
lidarrAlbumMbid = lidarrAlbumMbid,
|
|
||||||
reportCount = reportCount,
|
reportCount = reportCount,
|
||||||
latestAt = latestAt,
|
latestAt = latestAt,
|
||||||
reasonCounts = reasonCounts,
|
reasonCounts = reasonCounts,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
SectionCard(
|
SectionCard(
|
||||||
icon = Lucide.Inbox,
|
icon = Lucide.Inbox,
|
||||||
title = "Requests",
|
title = "Requests",
|
||||||
subtitle = "Approve or reject Lidarr requests",
|
subtitle = "Approve or reject requests for new music",
|
||||||
count = counts.requests,
|
count = counts.requests,
|
||||||
onClick = { navController.navigate(AdminRequests) },
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -85,7 +84,6 @@ fun AdminQuarantineScreen(
|
|||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
onResolve = viewModel::resolve,
|
onResolve = viewModel::resolve,
|
||||||
onDeleteFile = viewModel::deleteFile,
|
onDeleteFile = viewModel::deleteFile,
|
||||||
onDeleteViaLidarr = viewModel::deleteViaLidarr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,6 @@ private fun QueueList(
|
|||||||
rows: List<AdminQuarantineItemRef>,
|
rows: List<AdminQuarantineItemRef>,
|
||||||
onResolve: (String) -> Unit,
|
onResolve: (String) -> Unit,
|
||||||
onDeleteFile: (String) -> Unit,
|
onDeleteFile: (String) -> Unit,
|
||||||
onDeleteViaLidarr: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = rows, key = { it.trackId }) { row ->
|
items(items = rows, key = { it.trackId }) { row ->
|
||||||
@@ -105,7 +102,6 @@ private fun QueueList(
|
|||||||
row = row,
|
row = row,
|
||||||
onResolve = { onResolve(row.trackId) },
|
onResolve = { onResolve(row.trackId) },
|
||||||
onDeleteFile = { onDeleteFile(row.trackId) },
|
onDeleteFile = { onDeleteFile(row.trackId) },
|
||||||
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
|
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
@@ -117,7 +113,6 @@ private fun QuarantineRow(
|
|||||||
row: AdminQuarantineItemRef,
|
row: AdminQuarantineItemRef,
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
onDeleteViaLidarr: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -166,7 +161,6 @@ private fun QuarantineRow(
|
|||||||
ActionButtons(
|
ActionButtons(
|
||||||
onResolve = onResolve,
|
onResolve = onResolve,
|
||||||
onDeleteFile = onDeleteFile,
|
onDeleteFile = onDeleteFile,
|
||||||
onDeleteViaLidarr = onDeleteViaLidarr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +169,6 @@ private fun QuarantineRow(
|
|||||||
private fun ActionButtons(
|
private fun ActionButtons(
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
onDeleteViaLidarr: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -183,6 +176,5 @@ private fun ActionButtons(
|
|||||||
) {
|
) {
|
||||||
TextButton(onClick = onResolve) { Text("Resolve") }
|
TextButton(onClick = onResolve) { Text("Resolve") }
|
||||||
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
|
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 resolve(trackId: String) = act(trackId) { repository.resolve(it) }
|
||||||
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(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) {
|
private fun act(trackId: String, action: suspend (String) -> Unit) {
|
||||||
val before = internal.value
|
val before = internal.value
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun AdminRequestsScreen(
|
|||||||
AdminRequestsUiState.Loading -> LoadingCentered()
|
AdminRequestsUiState.Loading -> LoadingCentered()
|
||||||
AdminRequestsUiState.Empty -> EmptyState(
|
AdminRequestsUiState.Empty -> EmptyState(
|
||||||
title = "No requests waiting",
|
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.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import javax.inject.Singleton
|
|||||||
* leaks here: (1) the session cookie was being attached to every
|
* leaks here: (1) the session cookie was being attached to every
|
||||||
* external request the shared OkHttpClient services — including
|
* external request the shared OkHttpClient services — including
|
||||||
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
* 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
|
* identifier to third-party logging; (2) a 401 from any of those
|
||||||
* external hosts silently wiped the user's Minstrel session.
|
* external hosts silently wiped the user's Minstrel session.
|
||||||
* Restricting both attach + clear to placeholder-host requests
|
* 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).
|
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||||
*
|
*
|
||||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
* 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
|
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||||
* reach their authored host unchanged; rewriting them to the
|
* reach their authored host unchanged; rewriting them to the
|
||||||
* Minstrel host produced 404s the user saw as missing Discover
|
* Minstrel host produced 404s the user saw as missing Discover
|
||||||
|
|||||||
@@ -105,19 +105,6 @@ object ErrorCopy {
|
|||||||
"cleartext_public" to
|
"cleartext_public" to
|
||||||
"This server is on the internet, so its URL must start with https://. " +
|
"This server is on the internet, so its URL must start with https://. " +
|
||||||
"Plain http:// only works on your home network.",
|
"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_pending" to "This request is no longer pending.",
|
||||||
"request_not_found" to "That request no longer exists.",
|
"request_not_found" to "That request no longer exists.",
|
||||||
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
|
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
|
||||||
|
|||||||
+2
-6
@@ -8,10 +8,9 @@ import retrofit2.http.Path
|
|||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/quarantine`.
|
* Retrofit interface for `/api/admin/quarantine`.
|
||||||
*
|
*
|
||||||
* Three resolution endpoints:
|
* Two resolution endpoints:
|
||||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||||
* - `delete-file` → drop the local file; Lidarr keeps the track.
|
* - `delete-file` → delete the track's file and row.
|
||||||
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
|
|
||||||
*/
|
*/
|
||||||
interface AdminQuarantineApi {
|
interface AdminQuarantineApi {
|
||||||
@GET("api/admin/quarantine")
|
@GET("api/admin/quarantine")
|
||||||
@@ -22,7 +21,4 @@ interface AdminQuarantineApi {
|
|||||||
|
|
||||||
@POST("api/admin/quarantine/{trackId}/delete-file")
|
@POST("api/admin/quarantine/{trackId}/delete-file")
|
||||||
suspend fun deleteFile(@Path("trackId") trackId: String)
|
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.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
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.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -30,7 +30,7 @@ interface DiscoverApi {
|
|||||||
suspend fun search(
|
suspend fun search(
|
||||||
@Query("q") query: String,
|
@Query("q") query: String,
|
||||||
@Query("kind") kind: String,
|
@Query("kind") kind: String,
|
||||||
): List<LidarrSearchResultWire>
|
): List<CatalogueSearchResultWire>
|
||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|||||||
+2
-2
@@ -75,8 +75,8 @@ data class LikeTogglePayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
|
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
|
||||||
* Lidarr request that landed during a connectivity hiccup). Optional
|
* request that landed during a connectivity hiccup). Optional
|
||||||
* fields stay null when not applicable; the replayer rebuilds the
|
* fields stay null when not applicable; the replayer rebuilds the
|
||||||
* `CreateRequestBody` from these.
|
* `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.MutationQueue
|
||||||
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
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.SeedContributionWire
|
||||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
@@ -28,7 +28,7 @@ import javax.inject.Singleton
|
|||||||
enum class RequestOutcome { ACCEPTED, QUEUED }
|
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
|
* the request-creation write path. Mirrors the Flutter
|
||||||
* `DiscoverApi` + `mutationQueueProvider` integration —
|
* `DiscoverApi` + `mutationQueueProvider` integration —
|
||||||
* `feedback_offline_first_for_server_writes` says writes never go
|
* `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 in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
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() }
|
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.
|
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
||||||
*/
|
*/
|
||||||
suspend fun createRequest(
|
suspend fun createRequest(
|
||||||
kind: LidarrRequestKind,
|
kind: RequestKind,
|
||||||
artistMbid: String,
|
artistMbid: String,
|
||||||
artistName: String,
|
artistName: String,
|
||||||
albumMbid: String? = null,
|
albumMbid: String? = null,
|
||||||
@@ -163,7 +163,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
|
|
||||||
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
|
private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef(
|
||||||
mbid = mbid,
|
mbid = mbid,
|
||||||
name = name,
|
name = name,
|
||||||
secondaryText = secondaryText,
|
secondaryText = secondaryText,
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Search as LucideSearch
|
import com.composables.icons.lucide.Search as LucideSearch
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
@@ -161,7 +161,7 @@ private fun SearchBar(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("Search Lidarr for new music") },
|
placeholder = { Text("Search MusicBrainz for new music") },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
@@ -173,7 +173,7 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
|
private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -181,13 +181,13 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == LidarrRequestKind.ARTIST,
|
selected = kind == RequestKind.ARTIST,
|
||||||
onClick = { onChange(LidarrRequestKind.ARTIST) },
|
onClick = { onChange(RequestKind.ARTIST) },
|
||||||
label = { Text("Artists") },
|
label = { Text("Artists") },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == LidarrRequestKind.ALBUM,
|
selected = kind == RequestKind.ALBUM,
|
||||||
onClick = { onChange(LidarrRequestKind.ALBUM) },
|
onClick = { onChange(RequestKind.ALBUM) },
|
||||||
label = { Text("Albums") },
|
label = { Text("Albums") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -316,9 +316,9 @@ private fun SuggestionsHeader() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultsList(
|
private fun ResultsList(
|
||||||
rows: List<LidarrSearchResultRef>,
|
rows: List<CatalogueResultRef>,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (LidarrSearchResultRef) -> Unit,
|
onRequest: (CatalogueResultRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
CenteredMessage("Nothing to add for that search yet.")
|
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.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
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.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ResultTile(
|
internal fun ResultTile(
|
||||||
row: LidarrSearchResultRef,
|
row: CatalogueResultRef,
|
||||||
locallyRequested: Boolean,
|
locallyRequested: Boolean,
|
||||||
onRequest: () -> Unit,
|
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.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class DiscoverState(
|
data class DiscoverState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
|
val kind: RequestKind = RequestKind.ARTIST,
|
||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
@@ -51,7 +51,7 @@ sealed interface ResultsState {
|
|||||||
/** No active search — the screen shows the suggestion feed. */
|
/** No active search — the screen shows the suggestion feed. */
|
||||||
data object Idle : ResultsState
|
data object Idle : ResultsState
|
||||||
data object Loading : 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
|
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) }
|
internal.update { it.copy(kind = kind) }
|
||||||
if (internal.value.query.isNotBlank()) runSearch()
|
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
|
* QUEUED) so the screen can pick the snackbar wording and update
|
||||||
* the local "requested" overlay.
|
* the local "requested" overlay.
|
||||||
*/
|
*/
|
||||||
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
|
suspend fun requestRow(row: CatalogueResultRef): RequestOutcome {
|
||||||
val kind = internal.value.kind
|
val kind = internal.value.kind
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
|
artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid,
|
||||||
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
|
artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText,
|
||||||
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
|
albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null,
|
||||||
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
|
albumTitle = if (kind == RequestKind.ALBUM) row.name else null,
|
||||||
)
|
)
|
||||||
markRequested(row.mbid)
|
markRequested(row.mbid)
|
||||||
return outcome
|
return outcome
|
||||||
@@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = LidarrRequestKind.ARTIST,
|
kind = RequestKind.ARTIST,
|
||||||
artistMbid = s.mbid,
|
artistMbid = s.mbid,
|
||||||
artistName = s.name,
|
artistName = s.name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ data class AdminQuarantineItemRef(
|
|||||||
val artistName: String,
|
val artistName: String,
|
||||||
val albumTitle: String,
|
val albumTitle: String,
|
||||||
val albumId: String,
|
val albumId: String,
|
||||||
val lidarrAlbumMbid: String? = null,
|
|
||||||
val reportCount: Int = 0,
|
val reportCount: Int = 0,
|
||||||
val latestAt: String = "",
|
val latestAt: String = "",
|
||||||
val reasonCounts: Map<String, Int> = emptyMap(),
|
val reasonCounts: Map<String, Int> = emptyMap(),
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ import kotlinx.datetime.Instant
|
|||||||
import kotlin.math.roundToInt
|
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 name; the helper [wire] keeps that mapping in one place.
|
||||||
*/
|
*/
|
||||||
enum class LidarrRequestKind {
|
enum class RequestKind {
|
||||||
ARTIST, ALBUM, TRACK;
|
ARTIST, ALBUM, TRACK;
|
||||||
|
|
||||||
val wire: String get() = name.lowercase()
|
val wire: String get() = name.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lidarr search hit. Mirrors the Flutter client's
|
* Catalogue search hit — `mbid` is the result's own MBID; `artistMbid`
|
||||||
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
|
||||||
* and `albumMbid` are filled when the row is an album/track and the
|
* and `albumMbid` are filled when the row is an album/track and the
|
||||||
* UI needs the parent IDs to build the request.
|
* UI needs the parent IDs to build the request.
|
||||||
*
|
*
|
||||||
* `inLibrary` and `requested` together cover the "already-handled"
|
* `inLibrary` and `requested` together cover the "already-handled"
|
||||||
* cases — UI greys the row + swaps the Request button for a pill.
|
* cases — UI greys the row + swaps the Request button for a pill.
|
||||||
*/
|
*/
|
||||||
data class LidarrSearchResultRef(
|
data class CatalogueResultRef(
|
||||||
val mbid: String,
|
val mbid: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val secondaryText: String = "",
|
val secondaryText: String = "",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One quarantined track owned by the caller. Mirrors the Flutter
|
* 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
|
* `reasonLabel` provides the display string mapped from the raw
|
||||||
* server-side reason key — UI renders it as a pill on the row.
|
* server-side reason key — UI renders it as a pill on the row.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.models
|
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
|
* the typed enum keeps switch / when blocks exhaustive and lets the
|
||||||
* UI map directly to a status pill color without raw-string matches.
|
* 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` —
|
* the Flutter client's `AdminRequest` —
|
||||||
* shared between the user-side `/api/requests` view and the admin
|
* shared between the user-side `/api/requests` view and the admin
|
||||||
* cross-user view since the wire shape is identical.
|
* cross-user view since the wire shape is identical.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ data class AdminQuarantineItemWire(
|
|||||||
@SerialName("artist_name") val artistName: String = "",
|
@SerialName("artist_name") val artistName: String = "",
|
||||||
@SerialName("album_title") val albumTitle: String = "",
|
@SerialName("album_title") val albumTitle: String = "",
|
||||||
@SerialName("album_id") val albumId: String = "",
|
@SerialName("album_id") val albumId: String = "",
|
||||||
@SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null,
|
|
||||||
@SerialName("report_count") val reportCount: Int = 0,
|
@SerialName("report_count") val reportCount: Int = 0,
|
||||||
@SerialName("latest_at") val latestAt: String = "",
|
@SerialName("latest_at") val latestAt: String = "",
|
||||||
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
|
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of `GET /api/catalogue/search` (the MusicBrainz search that replaced
|
* One row of `GET /api/catalogue/search` (the MusicBrainz search, M502
|
||||||
* Lidarr's, M502 #5519). Mirrors `web/src/lib/api/types.ts
|
* #5519). Mirrors `web/src/lib/api/types.ts
|
||||||
* CatalogueSearchResult`. `imageUrl` is the server's own art endpoint,
|
* CatalogueSearchResult`. `imageUrl` is the server's own art endpoint,
|
||||||
* relative, so it goes through ServerImage.
|
* relative, so it goes through ServerImage.
|
||||||
*
|
*
|
||||||
@@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable
|
|||||||
* empty so missing keys don't throw on decode.
|
* empty so missing keys don't throw on decode.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LidarrSearchResultWire(
|
data class CatalogueSearchResultWire(
|
||||||
val mbid: String = "",
|
val mbid: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("secondary_text") val secondaryText: String = "",
|
@SerialName("secondary_text") val secondaryText: String = "",
|
||||||
@@ -27,8 +27,7 @@ data class LidarrSearchResultWire(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /api/discover/suggestions` row — an out-of-library artist
|
* `GET /api/discover/suggestions` row — an out-of-library artist
|
||||||
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
|
* surfaced from ListenBrainz-derived seeds. `imageUrl` may be empty.
|
||||||
* on-demand from Lidarr server-side and may be empty.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ArtistSuggestionWire(
|
data class ArtistSuggestionWire(
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
|||||||
/**
|
/**
|
||||||
* One row of `GET /api/quarantine/mine`. Mirrors
|
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||||
* the Flutter client's `QuarantineMineRow`
|
* the Flutter client's `QuarantineMineRow`
|
||||||
* (web `LidarrQuarantineMineRow`).
|
* (web `QuarantineMineRow`).
|
||||||
*
|
*
|
||||||
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
||||||
* / `other`. Stored as a plain string on the wire; the UI does the
|
* / `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 }
|
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
|
* cancel-while-pending write. No Room caching for v1 — same rationale
|
||||||
* as HistoryRepository; the offline-snapshot mirror lands with a
|
* as HistoryRepository; the offline-snapshot mirror lands with a
|
||||||
* later slice. Cancel is offline-first: direct REST on the happy
|
* later slice. Cancel is offline-first: direct REST on the happy
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fun RequestsScreen(
|
|||||||
UiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
UiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "Nothing requested yet",
|
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.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
@@ -218,7 +218,7 @@ private fun CancelConfirmDialog(
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Cancel request?") },
|
title = { Text("Cancel request?") },
|
||||||
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
|
text = { Text("Cancel “$displayName”? Your request is withdrawn.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = onConfirm) { Text("Cancel request") }
|
Button(onClick = onConfirm) { Text("Cancel request") }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,9 +135,8 @@ class AuthCookieInterceptorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drift #569 regression guard: a 401 from an external host must
|
// Drift #569 regression guard: a 401 from an external host must
|
||||||
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
|
// NOT wipe the user's Minstrel session. A misbehaving CDN that's
|
||||||
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
|
// 401-ing on artwork URLs used to silently sign the user out.
|
||||||
// the user out.
|
|
||||||
@Test
|
@Test
|
||||||
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
||||||
authStore.setSessionCookie("session=keep")
|
authStore.setSessionCookie("session=keep")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class BaseUrlInterceptorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `passes external absolute URL through unchanged`() {
|
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
|
// coverartarchive.org) are absolute external URLs. The
|
||||||
// interceptor must NOT rewrite them to the Minstrel base —
|
// interceptor must NOT rewrite them to the Minstrel base —
|
||||||
// doing so produces 404s the user saw as missing Discover
|
// doing so produces 404s the user saw as missing Discover
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ func run() error {
|
|||||||
go gcWorker.Run(ctx)
|
go gcWorker.Run(ctx)
|
||||||
|
|
||||||
// The request reconciler completes approved requests once the library
|
// 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
|
// Live-event bus shared between SSE subscribers (api.Mount) and
|
||||||
// background workers that publish (reconciler today; scanner later).
|
// background workers that publish (reconciler today; scanner later).
|
||||||
// Constructed before any service that publishes so they all share the
|
// 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
|
# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION
|
||||||
branding:
|
branding:
|
||||||
app_name: "Minstrel"
|
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:
|
subsonic:
|
||||||
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
|
# 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) |
|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
| `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) |
|
| `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 |
|
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Project Spec: Smart Music Client (companion app)
|
# 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
|
## 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.
|
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)
|
# 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
|
## 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.
|
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.
|
// 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) {
|
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
|
||||||
var c duplicateViewCounts
|
var c duplicateViewCounts
|
||||||
@@ -180,9 +180,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts,
|
|||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvedActivityView is one thing the resolver did on its own: a merge
|
// resolvedActivityView is one merge the resolver made on its own (action
|
||||||
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
|
// duplicate_merge). Details is the audit row's metadata, which names the files.
|
||||||
// Details is the audit row's metadata, which names the files or releases.
|
|
||||||
type resolvedActivityView struct {
|
type resolvedActivityView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
@@ -370,7 +369,6 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i
|
|||||||
// keeps the report's proposal.
|
// keeps the report's proposal.
|
||||||
type mergeDuplicateRequest struct {
|
type mergeDuplicateRequest struct {
|
||||||
SurvivorTrackID string `json:"survivor_track_id"`
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
Unmonitor bool `json:"unmonitor"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
|
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
|
||||||
@@ -378,10 +376,9 @@ type mergeDuplicateRequest struct {
|
|||||||
type mergeDuplicateResponse struct {
|
type mergeDuplicateResponse struct {
|
||||||
SurvivorTrackID string `json:"survivor_track_id"`
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
RemovedPaths []string `json:"removed_paths"`
|
RemovedPaths []string `json:"removed_paths"`
|
||||||
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
const mergeRequestBodyLimit = 1 << 16
|
||||||
|
|
||||||
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
|
// 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 err != nil {
|
||||||
if apiErr, ok := fileRemoveAPIError(err); ok {
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
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 {
|
for _, c := range res.Removed {
|
||||||
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
||||||
}
|
}
|
||||||
if body.Unmonitor && unmonitorFailed {
|
|
||||||
failed := true
|
|
||||||
resp.LidarrUnmonitorFailed = &failed
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
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,8 +10,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminQueueRowView is a single aggregated row in the admin queue response.
|
// adminQueueRowView is a single aggregated row in the admin queue response.
|
||||||
@@ -21,7 +20,6 @@ type adminQueueRowView struct {
|
|||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumTitle string `json:"album_title"`
|
AlbumTitle string `json:"album_title"`
|
||||||
AlbumID string `json:"album_id"`
|
AlbumID string `json:"album_id"`
|
||||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
|
||||||
ReportCount int32 `json:"report_count"`
|
ReportCount int32 `json:"report_count"`
|
||||||
LatestAt string `json:"latest_at"`
|
LatestAt string `json:"latest_at"`
|
||||||
ReasonCounts map[string]int `json:"reason_counts"`
|
ReasonCounts map[string]int `json:"reason_counts"`
|
||||||
@@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string {
|
|||||||
|
|
||||||
// handleListAdminQuarantine implements GET /api/admin/quarantine.
|
// handleListAdminQuarantine implements GET /api/admin/quarantine.
|
||||||
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("admin: list quarantine", "err", err)
|
h.logger.Error("admin: list quarantine", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -71,7 +69,6 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
|
|||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
LidarrAlbumMBID: row.LidarrAlbumMBID,
|
|
||||||
ReportCount: row.ReportCount,
|
ReportCount: row.ReportCount,
|
||||||
LatestAt: formatTimestamp(row.LatestAt),
|
LatestAt: formatTimestamp(row.LatestAt),
|
||||||
ReasonCounts: row.ReasonCounts,
|
ReasonCounts: row.ReasonCounts,
|
||||||
@@ -81,12 +78,11 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// actionResultView is the response shape for the three admin destructive
|
// actionResultView is the response shape for the admin actions (resolve,
|
||||||
// actions (resolve, delete-file, delete-via-lidarr).
|
// delete-file).
|
||||||
type actionResultView struct {
|
type actionResultView struct {
|
||||||
ActionID string `json:"action_id"`
|
ActionID string `json:"action_id"`
|
||||||
AffectedUsers int32 `json:"affected_users"`
|
AffectedUsers int32 `json:"affected_users"`
|
||||||
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
|
// 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")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
return
|
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 err != nil {
|
||||||
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
|
if errors.Is(err, quarantine.ErrTrackNotFound) {
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
||||||
// message is the part that tells the operator which directory and uid.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
case errors.Is(err, quarantine.ErrTrackNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: delete file", "err", err)
|
h.logger.Error("admin: delete file", "err", err)
|
||||||
@@ -158,48 +154,6 @@ 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.
|
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
|
||||||
type actionLogView struct {
|
type actionLogView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -209,7 +163,8 @@ type actionLogView struct {
|
|||||||
AlbumTitle *string `json:"album_title,omitempty"`
|
AlbumTitle *string `json:"album_title,omitempty"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
AdminID *string `json:"admin_id,omitempty"`
|
AdminID *string `json:"admin_id,omitempty"`
|
||||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
// AlbumMBID is set on the older deleted_album rows: the album removed.
|
||||||
|
AlbumMBID *string `json:"album_mbid,omitempty"`
|
||||||
AffectedUsers int32 `json:"affected_users"`
|
AffectedUsers int32 `json:"affected_users"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -248,7 +203,7 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
|
|||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
Action: string(row.Action),
|
Action: string(row.Action),
|
||||||
AdminID: adminID,
|
AdminID: adminID,
|
||||||
LidarrAlbumMBID: row.LidarrAlbumMbid,
|
AlbumMBID: row.AlbumMbid,
|
||||||
AffectedUsers: row.AffectedUsers,
|
AffectedUsers: row.AffectedUsers,
|
||||||
CreatedAt: formatTimestamp(row.CreatedAt),
|
CreatedAt: formatTimestamp(row.CreatedAt),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
// 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.Get("/quarantine", h.handleListAdminQuarantine)
|
||||||
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||||||
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
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.Get("/quarantine/actions", h.handleListQuarantineActions)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
|
|||||||
return w
|
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
|
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
||||||
// Service for setup. Used to construct admin queue scenarios.
|
// Service for setup. Used to construct admin queue scenarios.
|
||||||
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
|
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
|
||||||
t.Helper()
|
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)
|
t.Fatalf("flagDirect: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
|
|||||||
return tr, path
|
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
|
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
|
||||||
// track with mixed reasons. Verifies one aggregated row with report_count=3,
|
// track with mixed reasons. Verifies one aggregated row with report_count=3,
|
||||||
// reason_counts populated, and a Reports list of length 3.
|
// reason_counts populated, and a Reports list of length 3.
|
||||||
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
|
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "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) {
|
func TestResolveQuarantine_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
|
|||||||
if len(actions) != 1 {
|
if len(actions) != 1 {
|
||||||
t.Fatalf("audit rows = %d, want 1", len(actions))
|
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)
|
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) {
|
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
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
|
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
|
||||||
// endpoints reject non-admin callers with 403.
|
// endpoints reject non-admin callers with 403.
|
||||||
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
||||||
fakeID := "00000000-0000-0000-0000-000000000001"
|
fakeID := "00000000-0000-0000-0000-000000000001"
|
||||||
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
|||||||
{http.MethodGet, "/api/admin/quarantine"},
|
{http.MethodGet, "/api/admin/quarantine"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
|
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
|
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
|
|
||||||
{http.MethodGet, "/api/admin/quarantine/actions"},
|
{http.MethodGet, "/api/admin/quarantine/actions"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// testHandlersWithClientFn is the request handlers' test fixture.
|
// seedPendingArtistRequest creates a pending artist-kind request row
|
||||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|
||||||
t.Helper()
|
|
||||||
return testHandlers(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
|
|
||||||
// for the given user and returns its parsed requestView.
|
// for the given user and returns its parsed requestView.
|
||||||
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
||||||
t.Helper()
|
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
|
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
|
||||||
// and verifies that GET without ?status returns only the 2 pending rows.
|
// and verifies that GET without ?status returns only the 2 pending rows.
|
||||||
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
|
||||||
// only rejected rows.
|
// only rejected rows.
|
||||||
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
|
||||||
// ?status returns 400.
|
// ?status returns 400.
|
||||||
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
|
||||||
// POST /approve → 409 request_not_pending.
|
// POST /approve → 409 request_not_pending.
|
||||||
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
|
||||||
// 200 and the row transitions to rejected with notes saved.
|
// 200 and the row transitions to rejected with notes saved.
|
||||||
func TestHandleRejectRequest_HappyPath(t *testing.T) {
|
func TestHandleRejectRequest_HappyPath(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
|
||||||
// returns 200 (notes are optional).
|
// returns 200 (notes are optional).
|
||||||
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
|
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
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
|
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
|
||||||
// all 3 admin request endpoints reject non-admin users with 403.
|
// all 3 admin request endpoints reject non-admin users with 403.
|
||||||
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
|
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
|
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
|
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
|
||||||
// its release group on the wanted list, as the requester's.
|
// its release group on the wanted list, as the requester's.
|
||||||
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
|
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
|
||||||
@@ -309,8 +302,8 @@ func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
|
|||||||
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
|
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
|
||||||
// followed on the profile the admin picked; an unknown profile is a 400.
|
// followed on the profile the admin picked; an unknown profile is a 400.
|
||||||
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
|
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
ctx := context.Background()
|
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
|
// 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).
|
// oldest item has waited; an empty queue has no since (#5605).
|
||||||
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
|
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
listener := seedUser(t, h.pool, "listener", "pw", false)
|
listener := seedUser(t, h.pool, "listener", "pw", false)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -11,27 +10,22 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"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
|
// optional fields are omitted unless the corresponding cleanup actually
|
||||||
// happened, so the response stays minimal in the common case.
|
// happened, so the response stays minimal in the common case.
|
||||||
type removeTrackResponse struct {
|
type removeTrackResponse struct {
|
||||||
DeletedTrackID string `json:"deleted_track_id"`
|
DeletedTrackID string `json:"deleted_track_id"`
|
||||||
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
|
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
|
||||||
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
|
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
|
||||||
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,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
|
// 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
|
// 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
|
// nothing at all when the file cannot be removed (#3918).
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
// The codes this handler emits are not_found,
|
||||||
// wire error codes. The codes this handler emits are not_found,
|
|
||||||
// library_not_writable (409) and file_delete_failed when the file could not be
|
// 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.
|
// removed, server_error, plus the auth codes the middleware emits upstream.
|
||||||
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Defensive: RequireUser+RequireAdmin should have run upstream. If
|
||||||
// we got here without a user in context the routing is broken.
|
// we got here without a user in context the routing is broken.
|
||||||
admin, ok := requireUser(w, r)
|
admin, ok := requireUser(w, r)
|
||||||
@@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack(
|
deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID)
|
||||||
r.Context(), trackID, admin.ID, unmonitor,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, tracks.ErrNotFound) {
|
if errors.Is(err, tracks.ErrNotFound) {
|
||||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
||||||
@@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
s := uuidToString(*deletedArtist)
|
s := uuidToString(*deletedArtist)
|
||||||
resp.DeletedArtistID = &s
|
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)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,41 +14,12 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
// newAdminTracksRouter builds a test chi router with the admin-tracks
|
||||||
// endpoint. RequireAdmin middleware is applied; tests inject the user into
|
// endpoint. RequireAdmin middleware is applied; tests inject the user into
|
||||||
// context manually (RequireUser is bypassed). Mirrors the pattern from
|
// 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 {
|
func newAdminTracksRouter(h *handlers) chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Route("/api/admin", func(admin chi.Router) {
|
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
|
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
|
||||||
// returns the track row. The mbid string is set on both track and album
|
// returns the track row. The mbids are set on the track and album when
|
||||||
// when non-empty so the unmonitor branch has the data it needs.
|
// non-empty.
|
||||||
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
|
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
|
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
|
// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
|
||||||
// when the operator does NOT request unmonitor: 200 with deleted_track_id
|
// deleted_track_id and the optional cascade fields when they fired.
|
||||||
// (and the optional cascade fields when they fired) but NO
|
func TestHandleRemoveTrack_HappyPath(t *testing.T) {
|
||||||
// lidarr_unmonitor_failed key in the body.
|
|
||||||
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
@@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
|
|||||||
if got.DeletedArtistID == nil {
|
if got.DeletedArtistID == nil {
|
||||||
t.Error("deleted_artist_id missing; expected sole-track cascade")
|
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.
|
// File is gone, DB row is gone.
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
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")
|
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
|
// 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
|
// many of their albums the list holds, of how many.
|
||||||
// index.
|
|
||||||
type wantArtistView struct {
|
type wantArtistView struct {
|
||||||
ArtistMBID string `json:"artist_mbid"`
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
ArtistName string `json:"artist_name"`
|
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
|
// name where the library holds the artist, each artist's albums oldest
|
||||||
// first; q narrows it to an artist or an album.
|
// first; q narrows it to an artist or an album.
|
||||||
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
|
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
beatles := "a0000000-0000-4000-8000-000000000002"
|
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
|
// with how many of their albums are held; an artist's page lists every album
|
||||||
// of theirs, held or not, under that artist.
|
// of theirs, held or not, under that artist.
|
||||||
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
|
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
q := dbq.New(h.pool)
|
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) {
|
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
|
||||||
const group = "g0000000-0000-4000-8000-000000000010"
|
const group = "g0000000-0000-4000-8000-000000000010"
|
||||||
for _, down := range []bool{false, true} {
|
for _, down := range []bool{false, true} {
|
||||||
h, pool := testHandlersWithClientFn(t)
|
h, pool := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
|
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
|
||||||
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
|
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.
|
// A held album shows the library's own tracks, a lost file marked.
|
||||||
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
|
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
q := dbq.New(h.pool)
|
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
|
// local band's album left off and put back, and a link that names no
|
||||||
// MusicBrainz album refused before MusicBrainz is asked.
|
// MusicBrainz album refused before MusicBrainz is asked.
|
||||||
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
|
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
|
|||||||
+4
-14
@@ -21,14 +21,13 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"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/lifecycle"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"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/reacquisition"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
@@ -41,7 +40,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
setupToken, err := auth.NewSetupToken()
|
setupToken, err := auth.NewSetupToken()
|
||||||
if err != nil {
|
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,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
recSettings: recSettings,
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
|
||||||
requests: requestSvc,
|
requests: requestSvc,
|
||||||
lidarrQuarantine: lidarrQuar,
|
quarantine: quarantineSvc,
|
||||||
tracks: tracksSvc,
|
tracks: tracksSvc,
|
||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
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) {
|
authed.Route("/admin", func(admin chi.Router) {
|
||||||
admin.Use(auth.RequireAdmin())
|
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.Get("/acquisition/services", h.handleListAcquisitionServices)
|
||||||
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
|
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
|
||||||
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
|
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.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
|
||||||
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
||||||
admin.Delete("/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).
|
// The queues that wait on an admin, for the rail and the Overview (#5605).
|
||||||
admin.Get("/summary", h.handleGetAdminSummary)
|
admin.Get("/summary", h.handleGetAdminSummary)
|
||||||
admin.Get("/requests", h.handleListAdminRequests)
|
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.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
||||||
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
||||||
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
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.Get("/quarantine/actions", h.handleListQuarantineActions)
|
||||||
|
|
||||||
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
||||||
@@ -403,9 +394,8 @@ type handlers struct {
|
|||||||
// (#3880). Held here rather than counted per request: the count is a
|
// (#3880). Held here rather than counted per request: the count is a
|
||||||
// full table scan, and library size only moves when a scan runs.
|
// full table scan, and library size only moves when a scan runs.
|
||||||
librarySize *recommendation.LibrarySize
|
librarySize *recommendation.LibrarySize
|
||||||
lidarrCfg *lidarrconfig.Service
|
|
||||||
requests *requests.Service
|
requests *requests.Service
|
||||||
lidarrQuarantine *lidarrquarantine.Service
|
quarantine *quarantine.Service
|
||||||
tracks *tracks.Service
|
tracks *tracks.Service
|
||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
|
|||||||
@@ -24,12 +24,11 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"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/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"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/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
@@ -66,17 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recsettings: %v", err)
|
t.Fatalf("recsettings: %v", err)
|
||||||
}
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
|
||||||
requestSvc := requests.NewService(pool)
|
requestSvc := requests.NewService(pool)
|
||||||
cat := catalogue.New(pool, nil, logger)
|
cat := catalogue.New(pool, nil, logger)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
|
quarantineSvc := quarantine.NewService(pool, "")
|
||||||
// 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.
|
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, dataDir)
|
||||||
playlistsSvc := playlists.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
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ import (
|
|||||||
// catalogueSearchLimit is how many results one search shows.
|
// catalogueSearchLimit is how many results one search shows.
|
||||||
const catalogueSearchLimit = 25
|
const catalogueSearchLimit = 25
|
||||||
|
|
||||||
// catalogueSearchResult is one row of GET /api/catalogue/search. The shape is
|
// catalogueSearchResult is one row of GET /api/catalogue/search, read by the
|
||||||
// the one the Lidarr search answered with, so the Discover page and request
|
// Discover page and request rows. artist_mbid and album_mbid are the parents a
|
||||||
// rows read it unchanged. artist_mbid and album_mbid are the parents a
|
|
||||||
// request needs: empty on an artist, the artist on an album, both on a track.
|
// 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 {
|
type catalogueSearchResult struct {
|
||||||
MBID string `json:"mbid"`
|
MBID string `json:"mbid"`
|
||||||
Name string `json:"name"`
|
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 {
|
func catalogueHandlers(t *testing.T, down bool) *handlers {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
|
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
|
||||||
h.catalogue = catalogue.New(pool, mb, h.logger)
|
h.catalogue = catalogue.New(pool, mb, h.logger)
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(body)
|
_ = 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
|
// publishQuarantineEvent broadcasts a quarantine action. User-side
|
||||||
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
|
// flag/unflag are scoped to the user; admin-side resolve / delete-file
|
||||||
// delete-via-lidarr are broadcast (empty UserID) because a single admin
|
// are broadcast (empty UserID) because a single admin
|
||||||
// action can affect every user who'd flagged that track — every client
|
// action can affect every user who'd flagged that track — every client
|
||||||
// invalidates and the non-affected ones no-op when the query result
|
// invalidates and the non-affected ones no-op when the query result
|
||||||
// matches their cache.
|
// 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
|
// the request's original requester so their /requests page reflects the
|
||||||
// new state without manual refresh. Admin actors (approve / reject) still
|
// new state without manual refresh. Admin actors (approve / reject) still
|
||||||
// route to the requester's user_id, not the admin's — the original user
|
// 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"
|
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
||||||
|
|
||||||
// removeFailure builds the error a delete service returns when the file would
|
// 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.
|
// wrap it — the mapping has to see through that.
|
||||||
func removeFailure(errno syscall.Errno) error {
|
func removeFailure(errno syscall.Errno) error {
|
||||||
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
@@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
|
|||||||
FileSize: 1, FileFormat: "mp3",
|
FileSize: 1, FileFormat: "mp3",
|
||||||
})
|
})
|
||||||
if _, err := pool.Exec(context.Background(),
|
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,
|
user.ID, track.ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("quarantine insert: %v", err)
|
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) {
|
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := pool.Exec(context.Background(),
|
_, 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,
|
userID, trackID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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) {
|
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "np-alice", "pw", false)
|
alice := seedUser(t, pool, "np-alice", "pw", false)
|
||||||
admin := seedUser(t, pool, "np-admin", "pw", true)
|
admin := seedUser(t, pool, "np-admin", "pw", true)
|
||||||
@@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "np-auto", "pw", false)
|
alice := seedUser(t, h.pool, "np-auto", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
|
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) {
|
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
|
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
|
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/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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/notifications"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// quarantineView is the JSON shape returned by the user-facing endpoints
|
// quarantineView is the JSON shape returned by the user-facing endpoints
|
||||||
@@ -22,7 +22,7 @@ type quarantineView struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
|
func quarantineViewFrom(row dbq.Quarantine) quarantineView {
|
||||||
return quarantineView{
|
return quarantineView{
|
||||||
UserID: uuidToString(row.UserID),
|
UserID: uuidToString(row.UserID),
|
||||||
TrackID: uuidToString(row.TrackID),
|
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"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrBadReason):
|
case errors.Is(err, quarantine.ErrBadReason):
|
||||||
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
|
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"})
|
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
|
||||||
default:
|
default:
|
||||||
h.logger.Error("api: flag", "err", err)
|
h.logger.Error("api: flag", "err", err)
|
||||||
@@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
||||||
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
|
if errors.Is(err, quarantine.ErrQuarantineNotFound) {
|
||||||
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
|
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
|
rows, err := h.quarantine.ListMine(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list mine", "err", err)
|
h.logger.Error("api: list mine", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("list failed", 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))
|
out := make([]quarantineMineView, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out = append(out, quarantineMineView{
|
out = append(out, quarantineMineView{
|
||||||
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
|
TrackID: uuidToString(row.Quarantine.TrackID),
|
||||||
Reason: string(row.LidarrQuarantine.Reason),
|
Reason: string(row.Quarantine.Reason),
|
||||||
Notes: row.LidarrQuarantine.Notes,
|
Notes: row.Quarantine.Notes,
|
||||||
CreatedAt: row.LidarrQuarantine.CreatedAt,
|
CreatedAt: row.Quarantine.CreatedAt,
|
||||||
TrackTitle: row.TrackTitle,
|
TrackTitle: row.TrackTitle,
|
||||||
TrackDurationMs: row.TrackDurationMs,
|
TrackDurationMs: row.TrackDurationMs,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
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.
|
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
|
||||||
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
|
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
if _, err := pool.Exec(context.Background(),
|
if _, err := pool.Exec(context.Background(),
|
||||||
@@ -124,7 +124,7 @@ func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.
|
|||||||
// returns 201 with kind=artist and status=pending.
|
// returns 201 with kind=artist and status=pending.
|
||||||
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
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.
|
// kind=album without album_mbid returns 400 with mbid_required.
|
||||||
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
// Missing album_mbid and album_title.
|
// 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.
|
// with missing track_mbid/track_title returns 400 with mbid_required.
|
||||||
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
// Provides album fields but misses track_mbid and track_title.
|
// 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.
|
// requests, not bob's.
|
||||||
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
|
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "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.
|
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
|
||||||
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
|
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
|
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.
|
// cannot see another user's request.
|
||||||
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
charlie := seedUser(t, pool, "charlie", "pw", false)
|
charlie := seedUser(t, pool, "charlie", "pw", false)
|
||||||
@@ -293,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
|||||||
// user's request.
|
// user's request.
|
||||||
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
@@ -318,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
|||||||
// request returns 200 with status=rejected.
|
// request returns 200 with status=rejected.
|
||||||
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
|
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
|
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.
|
// rejected request returns 409 with request_not_pending.
|
||||||
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
|
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
|
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).
|
// another user's request returns 409 (not_pending from the ownership miss).
|
||||||
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
|
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
|
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob-cancel-other", "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.
|
// log can answer "where did that file go" long after the report is gone.
|
||||||
ActionDuplicateMerge Action = "duplicate_merge"
|
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.
|
// Subsonic password (#5026): generated in Settings for t/s-only clients.
|
||||||
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||||
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func Default() Config {
|
|||||||
Log: LogConfig{Level: "info", Format: "text"},
|
Log: LogConfig{Level: "info", Format: "text"},
|
||||||
Branding: BrandingConfig{
|
Branding: BrandingConfig{
|
||||||
AppName: "Minstrel",
|
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{
|
Events: EventsConfig{
|
||||||
SessionTimeoutMinutes: 30,
|
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
|
// usable picture; returns a wrapped error for I/O or tag-parse
|
||||||
// failures so callers can distinguish "no art" from "couldn't read".
|
// 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
|
// art in this block already — extracting it beats every external API
|
||||||
// guess for accuracy and avoids a network round-trip.
|
// guess for accuracy and avoids a network round-trip.
|
||||||
func ExtractEmbeddedAlbumArt(path string) ([]byte, error) {
|
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
|
// Embedded layer: read the picture stored inside the audio file's
|
||||||
// tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr).
|
// 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
|
// here, so this beats every external provider for accuracy and
|
||||||
// avoids the network round-trip.
|
// avoids the network round-trip.
|
||||||
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
|
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ SELECT
|
|||||||
-- it, or it failed to play (#5606). Counted by track, as the Reports
|
-- it, or it failed to play (#5606). Counted by track, as the Reports
|
||||||
-- page lists them.
|
-- page lists them.
|
||||||
(SELECT count(*) FROM (
|
(SELECT count(*) FROM (
|
||||||
SELECT track_id FROM lidarr_quarantine
|
SELECT track_id FROM quarantine
|
||||||
UNION
|
UNION
|
||||||
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
||||||
(SELECT min(at) FROM (
|
(SELECT min(at) FROM (
|
||||||
SELECT created_at AS at FROM lidarr_quarantine
|
SELECT created_at AS at FROM quarantine
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
||||||
(SELECT count(*) FROM duplicate_groups g
|
(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
|
// One page of the given actions, newest first: the duplicates report's
|
||||||
// "resolved automatically" activity (M498) reads the resolver's merges and
|
// "resolved automatically" activity (M498) reads the resolver's merges from
|
||||||
// Lidarr release changes from here.
|
// here.
|
||||||
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
|
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
|
||||||
rows, err := q.db.Query(ctx, listAuditLogByActions,
|
rows, err := q.db.Query(ctx, listAuditLogByActions,
|
||||||
arg.Actions,
|
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
|
WHERE ml.user_id = $1 AND ml.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
GROUP BY t.id, t.album_id, t.artist_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
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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
|
WHERE gl.user_id = $3 AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $3 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
GROUP BY t.id, t.album_id, t.artist_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
|
WHERE pe.user_id = $1
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY pe.started_at DESC
|
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
|
const listDuplicateGroupMergeMembers = `-- name: ListDuplicateGroupMergeMembers :many
|
||||||
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
|
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
|
|
||||||
FROM duplicate_group_members m
|
FROM duplicate_group_members m
|
||||||
JOIN tracks t ON t.id = m.track_id
|
JOIN tracks t ON t.id = m.track_id
|
||||||
JOIN albums ON albums.id = t.album_id
|
|
||||||
WHERE m.group_id = $1
|
WHERE m.group_id = $1
|
||||||
ORDER BY t.id
|
ORDER BY t.id
|
||||||
`
|
`
|
||||||
@@ -28,8 +26,6 @@ type ListDuplicateGroupMergeMembersRow struct {
|
|||||||
FileSize int64
|
FileSize int64
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
Mbid *string
|
|
||||||
AlbumMbid *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupMergeMembersRow, error) {
|
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.FileSize,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.Mbid,
|
|
||||||
&i.AlbumMbid,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ type ListMissingTrackPairsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Missing-pair pass (M485). A track marked missing whose replacement is already
|
// 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
|
// on disk under another row — most often a quality upgrade, mp3 → flac at a new
|
||||||
// a new path — is folded into that replacement, so its history moves across and
|
// path — is folded into that replacement, so its history moves across and
|
||||||
// re-acquisition stops chasing music the library already has.
|
// re-acquisition stops chasing music the library already has.
|
||||||
//
|
//
|
||||||
// Move adoption (#2528) cannot catch these: it links a new file to a missing row
|
// 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"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LidarrQuarantineActionKind string
|
type QuarantineActionKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved"
|
QuarantineActionKindResolved QuarantineActionKind = "resolved"
|
||||||
LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file"
|
QuarantineActionKindDeletedFile QuarantineActionKind = "deleted_file"
|
||||||
LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr"
|
QuarantineActionKindDeletedAlbum QuarantineActionKind = "deleted_album"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *LidarrQuarantineActionKind) Scan(src interface{}) error {
|
func (e *QuarantineActionKind) Scan(src interface{}) error {
|
||||||
switch s := src.(type) {
|
switch s := src.(type) {
|
||||||
case []byte:
|
case []byte:
|
||||||
*e = LidarrQuarantineActionKind(s)
|
*e = QuarantineActionKind(s)
|
||||||
case string:
|
case string:
|
||||||
*e = LidarrQuarantineActionKind(s)
|
*e = QuarantineActionKind(s)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src)
|
return fmt.Errorf("unsupported scan type for QuarantineActionKind: %T", src)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type NullLidarrQuarantineActionKind struct {
|
type NullQuarantineActionKind struct {
|
||||||
LidarrQuarantineActionKind LidarrQuarantineActionKind
|
QuarantineActionKind QuarantineActionKind
|
||||||
Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL
|
Valid bool // Valid is true if QuarantineActionKind is not NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan implements the Scanner interface.
|
// Scan implements the Scanner interface.
|
||||||
func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error {
|
func (ns *NullQuarantineActionKind) Scan(value interface{}) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
ns.LidarrQuarantineActionKind, ns.Valid = "", false
|
ns.QuarantineActionKind, ns.Valid = "", false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ns.Valid = true
|
ns.Valid = true
|
||||||
return ns.LidarrQuarantineActionKind.Scan(value)
|
return ns.QuarantineActionKind.Scan(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value implements the driver Valuer interface.
|
// Value implements the driver Valuer interface.
|
||||||
func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) {
|
func (ns NullQuarantineActionKind) Value() (driver.Value, error) {
|
||||||
if !ns.Valid {
|
if !ns.Valid {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return string(ns.LidarrQuarantineActionKind), nil
|
return string(ns.QuarantineActionKind), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type LidarrQuarantineReason string
|
type QuarantineReason string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip"
|
QuarantineReasonBadRip QuarantineReason = "bad_rip"
|
||||||
LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file"
|
QuarantineReasonWrongFile QuarantineReason = "wrong_file"
|
||||||
LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags"
|
QuarantineReasonWrongTags QuarantineReason = "wrong_tags"
|
||||||
LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate"
|
QuarantineReasonDuplicate QuarantineReason = "duplicate"
|
||||||
LidarrQuarantineReasonOther LidarrQuarantineReason = "other"
|
QuarantineReasonOther QuarantineReason = "other"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *LidarrQuarantineReason) Scan(src interface{}) error {
|
func (e *QuarantineReason) Scan(src interface{}) error {
|
||||||
switch s := src.(type) {
|
switch s := src.(type) {
|
||||||
case []byte:
|
case []byte:
|
||||||
*e = LidarrQuarantineReason(s)
|
*e = QuarantineReason(s)
|
||||||
case string:
|
case string:
|
||||||
*e = LidarrQuarantineReason(s)
|
*e = QuarantineReason(s)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src)
|
return fmt.Errorf("unsupported scan type for QuarantineReason: %T", src)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type NullLidarrQuarantineReason struct {
|
type NullQuarantineReason struct {
|
||||||
LidarrQuarantineReason LidarrQuarantineReason
|
QuarantineReason QuarantineReason
|
||||||
Valid bool // Valid is true if LidarrQuarantineReason is not NULL
|
Valid bool // Valid is true if QuarantineReason is not NULL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan implements the Scanner interface.
|
// Scan implements the Scanner interface.
|
||||||
func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error {
|
func (ns *NullQuarantineReason) Scan(value interface{}) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
ns.LidarrQuarantineReason, ns.Valid = "", false
|
ns.QuarantineReason, ns.Valid = "", false
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ns.Valid = true
|
ns.Valid = true
|
||||||
return ns.LidarrQuarantineReason.Scan(value)
|
return ns.QuarantineReason.Scan(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value implements the driver Valuer interface.
|
// Value implements the driver Valuer interface.
|
||||||
func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
|
func (ns NullQuarantineReason) Value() (driver.Value, error) {
|
||||||
if !ns.Valid {
|
if !ns.Valid {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return string(ns.LidarrQuarantineReason), nil
|
return string(ns.QuarantineReason), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestKind string
|
type RequestKind string
|
||||||
@@ -506,39 +506,6 @@ type LibraryChange struct {
|
|||||||
ChangedAt pgtype.Timestamptz
|
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 {
|
type ListenbrainzSimilarRecording struct {
|
||||||
SeedTrackID pgtype.UUID
|
SeedTrackID pgtype.UUID
|
||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
@@ -696,6 +663,27 @@ type PlaylistTrack struct {
|
|||||||
PickKind *string
|
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 {
|
type ReacquisitionSetting struct {
|
||||||
ID bool
|
ID bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.31.1
|
||||||
// source: lidarr_quarantine.sql
|
// source: quarantine.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one
|
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.
|
// 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
|
const deleteQuarantine = `-- name: DeleteQuarantine :one
|
||||||
DELETE FROM lidarr_quarantine
|
DELETE FROM quarantine
|
||||||
WHERE user_id = $1 AND track_id = $2
|
WHERE user_id = $1 AND track_id = $2
|
||||||
RETURNING user_id, track_id, reason, notes, created_at
|
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
|
// Removes the caller's row. Returns the deleted row so the handler can
|
||||||
// distinguish "no row existed" (zero rows -> ErrNoRows) from success.
|
// 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)
|
row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID)
|
||||||
var i LidarrQuarantine
|
var i Quarantine
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
@@ -50,7 +50,7 @@ func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantinePara
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec
|
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
|
// Clears all per-user rows for a given track. Used by Resolve and the
|
||||||
@@ -68,14 +68,13 @@ SELECT
|
|||||||
ar.name AS artist_name,
|
ar.name AS artist_name,
|
||||||
al.title AS album_title,
|
al.title AS album_title,
|
||||||
al.id AS album_id,
|
al.id AS album_id,
|
||||||
al.mbid AS lidarr_album_mbid,
|
|
||||||
count(q.user_id)::int AS report_count,
|
count(q.user_id)::int AS report_count,
|
||||||
max(q.created_at)::timestamptz AS latest_at
|
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 tracks t ON t.id = q.track_id
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN artists ar ON ar.id = t.artist_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
|
ORDER BY max(q.created_at) DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ type ListAdminQuarantineQueueRow struct {
|
|||||||
ArtistName string
|
ArtistName string
|
||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
LidarrAlbumMbid *string
|
|
||||||
ReportCount int32
|
ReportCount int32
|
||||||
LatestAt pgtype.Timestamptz
|
LatestAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
@@ -108,7 +106,6 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
|
|||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.LidarrAlbumMbid,
|
|
||||||
&i.ReportCount,
|
&i.ReportCount,
|
||||||
&i.LatestAt,
|
&i.LatestAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -123,20 +120,20 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listQuarantineActions = `-- name: ListQuarantineActions :many
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT $1
|
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)
|
rows, err := q.db.Query(ctx, listQuarantineActions, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []LidarrQuarantineAction
|
var items []QuarantineAction
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i LidarrQuarantineAction
|
var i QuarantineAction
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
@@ -145,7 +142,7 @@ func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]Lid
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.Action,
|
&i.Action,
|
||||||
&i.AdminID,
|
&i.AdminID,
|
||||||
&i.LidarrAlbumMbid,
|
&i.AlbumMbid,
|
||||||
&i.AffectedUsers,
|
&i.AffectedUsers,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -170,7 +167,7 @@ SELECT
|
|||||||
al.cover_art_path AS album_cover_art_path,
|
al.cover_art_path AS album_cover_art_path,
|
||||||
ar.id AS artist_id,
|
ar.id AS artist_id,
|
||||||
ar.name AS artist_name
|
ar.name AS artist_name
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
JOIN tracks t ON t.id = q.track_id
|
JOIN tracks t ON t.id = q.track_id
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN artists ar ON ar.id = t.artist_id
|
JOIN artists ar ON ar.id = t.artist_id
|
||||||
@@ -179,7 +176,7 @@ ORDER BY q.created_at DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type ListQuarantineForUserRow struct {
|
type ListQuarantineForUserRow struct {
|
||||||
LidarrQuarantine LidarrQuarantine
|
Quarantine Quarantine
|
||||||
TrackIDJoin pgtype.UUID
|
TrackIDJoin pgtype.UUID
|
||||||
TrackTitle string
|
TrackTitle string
|
||||||
TrackDurationMs int32
|
TrackDurationMs int32
|
||||||
@@ -204,11 +201,11 @@ func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListQuarantineForUserRow
|
var i ListQuarantineForUserRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.LidarrQuarantine.UserID,
|
&i.Quarantine.UserID,
|
||||||
&i.LidarrQuarantine.TrackID,
|
&i.Quarantine.TrackID,
|
||||||
&i.LidarrQuarantine.Reason,
|
&i.Quarantine.Reason,
|
||||||
&i.LidarrQuarantine.Notes,
|
&i.Quarantine.Notes,
|
||||||
&i.LidarrQuarantine.CreatedAt,
|
&i.Quarantine.CreatedAt,
|
||||||
&i.TrackIDJoin,
|
&i.TrackIDJoin,
|
||||||
&i.TrackTitle,
|
&i.TrackTitle,
|
||||||
&i.TrackDurationMs,
|
&i.TrackDurationMs,
|
||||||
@@ -235,7 +232,7 @@ SELECT
|
|||||||
q.reason,
|
q.reason,
|
||||||
q.notes,
|
q.notes,
|
||||||
q.created_at
|
q.created_at
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
JOIN users u ON u.id = q.user_id
|
JOIN users u ON u.id = q.user_id
|
||||||
WHERE q.track_id = $1
|
WHERE q.track_id = $1
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
@@ -244,7 +241,7 @@ ORDER BY q.created_at DESC
|
|||||||
type ListQuarantineReportsForTrackRow struct {
|
type ListQuarantineReportsForTrackRow struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Username string
|
Username string
|
||||||
Reason LidarrQuarantineReason
|
Reason QuarantineReason
|
||||||
Notes *string
|
Notes *string
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
@@ -278,7 +275,7 @@ func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertQuarantine = `-- name: UpsertQuarantine :one
|
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)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
||||||
reason = EXCLUDED.reason,
|
reason = EXCLUDED.reason,
|
||||||
@@ -290,20 +287,20 @@ RETURNING user_id, track_id, reason, notes, created_at
|
|||||||
type UpsertQuarantineParams struct {
|
type UpsertQuarantineParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Reason LidarrQuarantineReason
|
Reason QuarantineReason
|
||||||
Notes *string
|
Notes *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert a new quarantine row, or update reason/notes if the user has
|
// Insert a new quarantine row, or update reason/notes if the user has
|
||||||
// already flagged this track.
|
// 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,
|
row := q.db.QueryRow(ctx, upsertQuarantine,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
arg.TrackID,
|
arg.TrackID,
|
||||||
arg.Reason,
|
arg.Reason,
|
||||||
arg.Notes,
|
arg.Notes,
|
||||||
)
|
)
|
||||||
var i LidarrQuarantine
|
var i Quarantine
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
@@ -315,11 +312,11 @@ func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantinePara
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writeQuarantineAction = `-- name: WriteQuarantineAction :one
|
const writeQuarantineAction = `-- name: WriteQuarantineAction :one
|
||||||
INSERT INTO lidarr_quarantine_actions (
|
INSERT INTO quarantine_actions (
|
||||||
track_id, track_title, artist_name, album_title,
|
track_id, track_title, artist_name, album_title,
|
||||||
action, admin_id, lidarr_album_mbid, affected_users
|
action, admin_id, affected_users
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at
|
RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type WriteQuarantineActionParams struct {
|
type WriteQuarantineActionParams struct {
|
||||||
@@ -327,14 +324,13 @@ type WriteQuarantineActionParams struct {
|
|||||||
TrackTitle string
|
TrackTitle string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
AlbumTitle *string
|
AlbumTitle *string
|
||||||
Action LidarrQuarantineActionKind
|
Action QuarantineActionKind
|
||||||
AdminID pgtype.UUID
|
AdminID pgtype.UUID
|
||||||
LidarrAlbumMbid *string
|
|
||||||
AffectedUsers int32
|
AffectedUsers int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit row for an admin destructive action.
|
// 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,
|
row := q.db.QueryRow(ctx, writeQuarantineAction,
|
||||||
arg.TrackID,
|
arg.TrackID,
|
||||||
arg.TrackTitle,
|
arg.TrackTitle,
|
||||||
@@ -342,10 +338,9 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
|
|||||||
arg.AlbumTitle,
|
arg.AlbumTitle,
|
||||||
arg.Action,
|
arg.Action,
|
||||||
arg.AdminID,
|
arg.AdminID,
|
||||||
arg.LidarrAlbumMbid,
|
|
||||||
arg.AffectedUsers,
|
arg.AffectedUsers,
|
||||||
)
|
)
|
||||||
var i LidarrQuarantineAction
|
var i QuarantineAction
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
@@ -354,7 +349,7 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.Action,
|
&i.Action,
|
||||||
&i.AdminID,
|
&i.AdminID,
|
||||||
&i.LidarrAlbumMbid,
|
&i.AlbumMbid,
|
||||||
&i.AffectedUsers,
|
&i.AffectedUsers,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -218,7 +218,7 @@ JOIN artists ON artists.id = t.artist_id
|
|||||||
WHERE t.artist_id = $1
|
WHERE t.artist_id = $1
|
||||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY p.cnt DESC, 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
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY p.cnt DESC, 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 started_at > now() - $3 * interval '1 hour'
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
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'
|
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT q.track_id AS id
|
SELECT q.track_id AS id
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
WHERE q.user_id = $1
|
WHERE q.user_id = $1
|
||||||
),
|
),
|
||||||
lb_similar AS (
|
lb_similar AS (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
AND COALESCE(pc.c, 0) <= 2
|
AND COALESCE(pc.c, 0) <= 2
|
||||||
AND COALESCE(sc.c, 0) < 2
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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 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 attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY
|
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
|
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
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY alt.tier, alt.created_at DESC, t.album_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
|
JOIN windowed w ON w.track_id = t.id
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY w.c DESC, md5(t.id::text || $2::text)
|
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.c >= 3
|
||||||
AND s.last_at <= now() - interval '30 days'
|
AND s.last_at <= now() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
-- Declined-resurfacing cooldown (#1258): a skip on a
|
-- 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
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
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
|
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
|
AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text
|
||||||
`
|
`
|
||||||
|
|
||||||
// A held-back track whose name no longer carries a marker (renamed, retagged
|
// A held-back track whose name no longer carries a marker (renamed or
|
||||||
// by Lidarr) is released again.
|
// retagged) is released again.
|
||||||
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
|
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
|
||||||
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
|
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +130,7 @@ const countTracksMatching = `-- name: CountTracksMatching :one
|
|||||||
SELECT COUNT(*) FROM tracks
|
SELECT COUNT(*) FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
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
|
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
|
// M7 #372: hard delete with FK cascade. The CASCADE on track_id from
|
||||||
// play_events / general_likes_tracks / lidarr_quarantine /
|
// play_events / general_likes_tracks / quarantine /
|
||||||
// lidarr_quarantine_actions handles their cleanup. RETURNING gives us
|
// quarantine_actions handles their cleanup. RETURNING gives us
|
||||||
// album_id + artist_id for the album-empty / artist-empty cascade
|
// album_id + artist_id for the album-empty / artist-empty cascade
|
||||||
// checks the service does next.
|
// checks the service does next.
|
||||||
func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) {
|
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
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.artist_id = $1
|
WHERE t.artist_id = $1
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
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
|
// 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
|
// /api/artists/{id}/tracks for the artist-card play affordance, which
|
||||||
// shuffles client-side. Ordering matches album/track natural order so
|
// shuffles client-side. Ordering matches album/track natural order so
|
||||||
// the shuffle has a deterministic input.
|
// the shuffle has a deterministic input.
|
||||||
@@ -578,7 +578,7 @@ FROM tracks t
|
|||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
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
|
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
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||||||
)
|
)
|
||||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST
|
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
|
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 || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||||||
)
|
)
|
||||||
ORDER BY title
|
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
|
// Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
|
||||||
// handle plays/likes/sessions/etc. Caller must enforce the last-admin
|
// handle plays/likes/sessions/etc. Caller must enforce the last-admin
|
||||||
// guard at the HTTP layer (count admins, refuse if target is the only
|
// 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
|
// sessions, and other user-FK tables all have ON DELETE CASCADE so
|
||||||
// this is a clean drop.
|
// this is a clean drop.
|
||||||
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
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
|
-- it, or it failed to play (#5606). Counted by track, as the Reports
|
||||||
-- page lists them.
|
-- page lists them.
|
||||||
(SELECT count(*) FROM (
|
(SELECT count(*) FROM (
|
||||||
SELECT track_id FROM lidarr_quarantine
|
SELECT track_id FROM quarantine
|
||||||
UNION
|
UNION
|
||||||
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
||||||
(SELECT min(at) FROM (
|
(SELECT min(at) FROM (
|
||||||
SELECT created_at AS at FROM lidarr_quarantine
|
SELECT created_at AS at FROM quarantine
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
||||||
(SELECT count(*) FROM duplicate_groups g
|
(SELECT count(*) FROM duplicate_groups g
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ SELECT count(*)::bigint FROM audit_log
|
|||||||
|
|
||||||
-- name: ListAuditLogByActions :many
|
-- name: ListAuditLogByActions :many
|
||||||
-- One page of the given actions, newest first: the duplicates report's
|
-- One page of the given actions, newest first: the duplicates report's
|
||||||
-- "resolved automatically" activity (M498) reads the resolver's merges and
|
-- "resolved automatically" activity (M498) reads the resolver's merges from
|
||||||
-- Lidarr release changes from here.
|
-- here.
|
||||||
SELECT * FROM audit_log
|
SELECT * FROM audit_log
|
||||||
WHERE action = ANY(sqlc.arg(actions)::text[])
|
WHERE action = ANY(sqlc.arg(actions)::text[])
|
||||||
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL)
|
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
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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
|
WHERE ml.user_id = $1 AND ml.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
GROUP BY t.id, t.album_id, t.artist_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
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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
|
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
GROUP BY t.id, t.album_id, t.artist_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
|
WHERE pe.user_id = $1
|
||||||
AND pe.was_skipped = false
|
AND pe.was_skipped = false
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY pe.started_at DESC
|
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;
|
FOR UPDATE;
|
||||||
|
|
||||||
-- name: ListDuplicateGroupMergeMembers :many
|
-- name: ListDuplicateGroupMergeMembers :many
|
||||||
SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
|
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
|
|
||||||
FROM duplicate_group_members m
|
FROM duplicate_group_members m
|
||||||
JOIN tracks t ON t.id = m.track_id
|
JOIN tracks t ON t.id = m.track_id
|
||||||
JOIN albums ON albums.id = t.album_id
|
|
||||||
WHERE m.group_id = sqlc.arg(group_id)
|
WHERE m.group_id = sqlc.arg(group_id)
|
||||||
ORDER BY t.id;
|
ORDER BY t.id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- Missing-pair pass (M485). A track marked missing whose replacement is already
|
-- 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
|
-- on disk under another row — most often a quality upgrade, mp3 → flac at a new
|
||||||
-- a new path — is folded into that replacement, so its history moves across and
|
-- path — is folded into that replacement, so its history moves across and
|
||||||
-- re-acquisition stops chasing music the library already has.
|
-- re-acquisition stops chasing music the library already has.
|
||||||
--
|
--
|
||||||
-- Move adoption (#2528) cannot catch these: it links a new file to a missing row
|
-- Move adoption (#2528) cannot catch these: it links a new file to a missing row
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- name: UpsertQuarantine :one
|
-- name: UpsertQuarantine :one
|
||||||
-- Insert a new quarantine row, or update reason/notes if the user has
|
-- Insert a new quarantine row, or update reason/notes if the user has
|
||||||
-- already flagged this track.
|
-- 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)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
||||||
reason = EXCLUDED.reason,
|
reason = EXCLUDED.reason,
|
||||||
@@ -12,7 +12,7 @@ RETURNING user_id, track_id, reason, notes, created_at;
|
|||||||
-- name: DeleteQuarantine :one
|
-- name: DeleteQuarantine :one
|
||||||
-- Removes the caller's row. Returns the deleted row so the handler can
|
-- Removes the caller's row. Returns the deleted row so the handler can
|
||||||
-- distinguish "no row existed" (zero rows -> ErrNoRows) from success.
|
-- distinguish "no row existed" (zero rows -> ErrNoRows) from success.
|
||||||
DELETE FROM lidarr_quarantine
|
DELETE FROM quarantine
|
||||||
WHERE user_id = $1 AND track_id = $2
|
WHERE user_id = $1 AND track_id = $2
|
||||||
RETURNING user_id, track_id, reason, notes, created_at;
|
RETURNING user_id, track_id, reason, notes, created_at;
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ SELECT
|
|||||||
al.cover_art_path AS album_cover_art_path,
|
al.cover_art_path AS album_cover_art_path,
|
||||||
ar.id AS artist_id,
|
ar.id AS artist_id,
|
||||||
ar.name AS artist_name
|
ar.name AS artist_name
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
JOIN tracks t ON t.id = q.track_id
|
JOIN tracks t ON t.id = q.track_id
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN artists ar ON ar.id = t.artist_id
|
JOIN artists ar ON ar.id = t.artist_id
|
||||||
@@ -48,14 +48,13 @@ SELECT
|
|||||||
ar.name AS artist_name,
|
ar.name AS artist_name,
|
||||||
al.title AS album_title,
|
al.title AS album_title,
|
||||||
al.id AS album_id,
|
al.id AS album_id,
|
||||||
al.mbid AS lidarr_album_mbid,
|
|
||||||
count(q.user_id)::int AS report_count,
|
count(q.user_id)::int AS report_count,
|
||||||
max(q.created_at)::timestamptz AS latest_at
|
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 tracks t ON t.id = q.track_id
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN artists ar ON ar.id = t.artist_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;
|
ORDER BY max(q.created_at) DESC;
|
||||||
|
|
||||||
-- name: ListQuarantineReportsForTrack :many
|
-- name: ListQuarantineReportsForTrack :many
|
||||||
@@ -67,7 +66,7 @@ SELECT
|
|||||||
q.reason,
|
q.reason,
|
||||||
q.notes,
|
q.notes,
|
||||||
q.created_at
|
q.created_at
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
JOIN users u ON u.id = q.user_id
|
JOIN users u ON u.id = q.user_id
|
||||||
WHERE q.track_id = $1
|
WHERE q.track_id = $1
|
||||||
ORDER BY q.created_at DESC;
|
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
|
-- Clears all per-user rows for a given track. Used by Resolve and the
|
||||||
-- two delete actions. Caller writes the audit row separately before
|
-- two delete actions. Caller writes the audit row separately before
|
||||||
-- this fires (so we can capture the affected_users count).
|
-- 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
|
-- name: CountQuarantineForTrack :one
|
||||||
-- Reads affected_users for the audit row before the delete fires.
|
-- 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
|
-- name: WriteQuarantineAction :one
|
||||||
-- Audit row for an admin destructive action.
|
-- Audit row for an admin destructive action.
|
||||||
INSERT INTO lidarr_quarantine_actions (
|
INSERT INTO quarantine_actions (
|
||||||
track_id, track_title, artist_name, album_title,
|
track_id, track_title, artist_name, album_title,
|
||||||
action, admin_id, lidarr_album_mbid, affected_users
|
action, admin_id, affected_users
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ListQuarantineActions :many
|
-- name: ListQuarantineActions :many
|
||||||
SELECT * FROM lidarr_quarantine_actions
|
SELECT * FROM quarantine_actions
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1;
|
LIMIT $1;
|
||||||
@@ -31,7 +31,7 @@ WHERE t.id <> $2
|
|||||||
AND started_at > now() - $3 * interval '1 hour'
|
AND started_at > now() - $3 * interval '1 hour'
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
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
|
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'
|
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT q.track_id AS id
|
SELECT q.track_id AS id
|
||||||
FROM lidarr_quarantine q
|
FROM quarantine q
|
||||||
WHERE q.user_id = $1
|
WHERE q.user_id = $1
|
||||||
),
|
),
|
||||||
lb_similar AS (
|
lb_similar AS (
|
||||||
@@ -416,7 +416,7 @@ JOIN albums ON albums.id = t.album_id
|
|||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY p.cnt DESC, 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)
|
WHERE t.artist_id = sqlc.arg(artist_id)
|
||||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY p.cnt DESC, 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(pc.c, 0) <= 2
|
||||||
AND COALESCE(sc.c, 0) < 2
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
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.c >= 3
|
||||||
AND s.last_at <= now() - interval '30 days'
|
AND s.last_at <= now() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
-- Declined-resurfacing cooldown (#1258): a skip on a
|
-- 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
|
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
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY alt.tier, alt.created_at DESC, t.album_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
|
JOIN windowed w ON w.track_id = t.id
|
||||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY w.c DESC, md5(t.id::text || $2::text)
|
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 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 attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ JOIN albums a ON a.id = t.album_id
|
|||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
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
|
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
|
SELECT * FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||||||
)
|
)
|
||||||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
|
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
|
SELECT * FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||||||
)
|
)
|
||||||
ORDER BY title
|
ORDER BY title
|
||||||
@@ -87,13 +87,13 @@ LIMIT $3 OFFSET $4;
|
|||||||
SELECT COUNT(*) FROM tracks
|
SELECT COUNT(*) FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||||||
);
|
);
|
||||||
|
|
||||||
-- name: ListArtistTracksForUser :many
|
-- name: ListArtistTracksForUser :many
|
||||||
-- M6a: every track for the artist across their albums, with album_title
|
-- 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
|
-- /api/artists/{id}/tracks for the artist-card play affordance, which
|
||||||
-- shuffles client-side. Ordering matches album/track natural order so
|
-- shuffles client-side. Ordering matches album/track natural order so
|
||||||
-- the shuffle has a deterministic input.
|
-- 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
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.artist_id = $1
|
WHERE t.artist_id = $1
|
||||||
AND NOT EXISTS (
|
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
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
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 albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE NOT EXISTS (
|
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
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
@@ -136,8 +136,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
|
|||||||
|
|
||||||
-- name: DeleteTrack :one
|
-- name: DeleteTrack :one
|
||||||
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
|
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
|
||||||
-- play_events / general_likes_tracks / lidarr_quarantine /
|
-- play_events / general_likes_tracks / quarantine /
|
||||||
-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us
|
-- quarantine_actions handles their cleanup. RETURNING gives us
|
||||||
-- album_id + artist_id for the album-empty / artist-empty cascade
|
-- album_id + artist_id for the album-empty / artist-empty cascade
|
||||||
-- checks the service does next.
|
-- checks the service does next.
|
||||||
DELETE FROM tracks WHERE id = $1
|
DELETE FROM tracks WHERE id = $1
|
||||||
@@ -321,8 +321,8 @@ UPDATE tracks
|
|||||||
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
||||||
|
|
||||||
-- name: ClearStaleSuspectSourceTracks :execrows
|
-- name: ClearStaleSuspectSourceTracks :execrows
|
||||||
-- A held-back track whose name no longer carries a marker (renamed, retagged
|
-- A held-back track whose name no longer carries a marker (renamed or
|
||||||
-- by Lidarr) is released again.
|
-- retagged) is released again.
|
||||||
UPDATE tracks
|
UPDATE tracks
|
||||||
SET source_verdict = NULL, source_verdict_at = NULL
|
SET source_verdict = NULL, source_verdict_at = NULL
|
||||||
WHERE source_verdict = 'suspect'
|
WHERE source_verdict = 'suspect'
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ RETURNING *;
|
|||||||
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
|
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
|
||||||
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
|
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
|
||||||
-- guard at the HTTP layer (count admins, refuse if target is the only
|
-- 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
|
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
|
||||||
-- this is a clean drop.
|
-- this is a clean drop.
|
||||||
DELETE FROM users WHERE id = $1;
|
DELETE FROM users WHERE id = $1;
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ var dataTables = []string{
|
|||||||
"skip_events",
|
"skip_events",
|
||||||
"play_sessions",
|
"play_sessions",
|
||||||
"sessions",
|
"sessions",
|
||||||
"lidarr_quarantine_actions",
|
"quarantine_actions",
|
||||||
"lidarr_quarantine",
|
"quarantine",
|
||||||
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
|
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
|
||||||
// candidates are out-of-library — so the CASCADE from artists/users
|
// candidates are out-of-library — so the CASCADE from artists/users
|
||||||
// does NOT reach it for a leftover row whose user survived. Truncate
|
// 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_artist_release_groups", // M502 #5519
|
||||||
"mb_release_groups", // M502 #5519
|
"mb_release_groups", // M502 #5519
|
||||||
"mb_artists", // M502 #5519
|
"mb_artists", // M502 #5519
|
||||||
"requests", // M502 #5520 (was lidarr_requests)
|
"requests", // M502 #5520
|
||||||
"download_interventions", // M502 #5523
|
"download_interventions", // M502 #5523
|
||||||
"library_album_matches", // #5569
|
"library_album_matches", // #5569
|
||||||
"release_blocks", // M502 #5521
|
"release_blocks", // M502 #5521
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ type Placement struct {
|
|||||||
Recording string
|
Recording string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan names every file's destination under root the way Lidarr named the
|
// Plan names every file's destination under root the way the library is
|
||||||
// library Minstrel took over (note #5527), so new arrivals sit beside what
|
// already named (note #5527), so new arrivals sit beside what is there:
|
||||||
// is there:
|
|
||||||
//
|
//
|
||||||
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
|
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
|
||||||
//
|
//
|
||||||
@@ -83,11 +82,11 @@ const segmentMax = 200
|
|||||||
|
|
||||||
var illegal = strings.NewReplacer(
|
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
|
// clean file names: their illegal-character mapping, no leading or trailing
|
||||||
// spaces or dots, at most segmentMax bytes, never "" or "..".
|
// spaces or dots, at most segmentMax bytes, never "" or "..".
|
||||||
func Segment(s string) string {
|
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 := edition("rel-cd", 2)
|
||||||
e.Tracks[1].Title = "Within: Part 2/3?"
|
e.Tracks[1].Title = "Within: Part 2/3?"
|
||||||
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
|
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the
|
|||||||
type MergedCopy struct {
|
type MergedCopy struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
FilePath string
|
FilePath string
|
||||||
TrackMbid *string
|
|
||||||
AlbumMbid *string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeResult says what a merge did.
|
// MergeResult says what a merge did.
|
||||||
@@ -296,7 +294,7 @@ func splitSurvivor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mergedCopyOf(m dbq.ListDuplicateGroupMergeMembersRow) MergedCopy {
|
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
|
// 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"
|
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
|
// 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
|
// 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
|
// 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
|
// tags fit the album stays, and the merge is recorded as the resolver's
|
||||||
// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
|
// (M502 #5517): Minstrel is the only program writing the library.
|
||||||
// library.
|
|
||||||
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
|
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
|
||||||
f := newResolveFixture(t)
|
f := newResolveFixture(t)
|
||||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
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
|
// 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
|
// release's copy, so it goes and the album cut stays (#5439).
|
||||||
// Lidarr).
|
|
||||||
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
|
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
|
||||||
f := newCrossReleaseRipFixture(t)
|
f := newCrossReleaseRipFixture(t)
|
||||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ type FingerprintSettings struct {
|
|||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
SweepIntervalHours int32
|
SweepIntervalHours int32
|
||||||
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
|
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
|
||||||
// copies Lidarr does not need and change a Lidarr release that lists songs
|
// copies no release needs. Off, it only classifies, and every group waits
|
||||||
// twice. Off, it only classifies, and every group waits for the operator.
|
// for the operator.
|
||||||
AutoResolve bool
|
AutoResolve bool
|
||||||
// UpdatedAt is when the settings were last saved. Set by the database;
|
// UpdatedAt is when the settings were last saved. Set by the database;
|
||||||
// ignored by Set.
|
// ignored by Set.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func extractRecordingMBID(m tag.Metadata) string {
|
|||||||
// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID —
|
// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID —
|
||||||
// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as
|
// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as
|
||||||
// mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one
|
// 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
|
// request completion key on this one (#5241). Separate from extractMBIDs for
|
||||||
// the same reason extractRecordingMBID is.
|
// the same reason extractRecordingMBID is.
|
||||||
func extractReleaseGroupMBID(m tag.Metadata) string {
|
func extractReleaseGroupMBID(m tag.Metadata) string {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
// A track marked missing whose replacement already sits on disk under another
|
// A track marked missing whose replacement already sits on disk under another
|
||||||
// row is folded into that replacement: its likes, plays, playlist entries and
|
// 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
|
// 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
|
// 355 of 451 missing tracks (#5274), nearly all quality upgrades — the mp3
|
||||||
// mp3 removed, a flac imported at a new path.
|
// removed, a flac imported at a new path.
|
||||||
//
|
//
|
||||||
// Move adoption (moved.go) can't catch these. It links a new file to a missing
|
// 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
|
// 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
|
// 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
|
// has nothing to match. The scan must still end with one row, carrying the old
|
||||||
// row's history.
|
// 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