From 3a16732883c9a8acbad15bd30310a17f0694d7c1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 11 Oct 2026 13:48:20 -0400 Subject: [PATCH] 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) 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 --- README.md | 20 +- .../admin/data/AdminQuarantineRepository.kt | 3 - .../minstrel/admin/ui/AdminLandingScreen.kt | 2 +- .../admin/ui/AdminQuarantineScreen.kt | 8 - .../admin/ui/AdminQuarantineViewModel.kt | 1 - .../minstrel/admin/ui/AdminRequestsScreen.kt | 2 +- .../minstrel/api/AuthCookieInterceptor.kt | 2 +- .../minstrel/api/BaseUrlInterceptor.kt | 2 +- .../com/fabledsword/minstrel/api/ErrorCopy.kt | 13 - .../api/endpoints/AdminQuarantineApi.kt | 10 +- .../minstrel/api/endpoints/DiscoverApi.kt | 4 +- .../minstrel/cache/mutations/MutationQueue.kt | 4 +- .../discover/data/DiscoverRepository.kt | 14 +- .../minstrel/discover/ui/DiscoverScreen.kt | 20 +- .../minstrel/discover/ui/DiscoverTiles.kt | 4 +- .../minstrel/discover/ui/DiscoverViewModel.kt | 22 +- .../minstrel/models/AdminQuarantineItem.kt | 1 - .../fabledsword/minstrel/models/Discover.kt | 9 +- .../minstrel/models/QuarantineRef.kt | 2 +- .../fabledsword/minstrel/models/Request.kt | 4 +- .../models/wire/AdminQuarantineWire.kt | 1 - .../minstrel/models/wire/DiscoverWire.kt | 9 +- .../models/wire/QuarantineMineWire.kt | 2 +- .../requests/data/RequestsRepository.kt | 2 +- .../minstrel/requests/ui/RequestsScreen.kt | 4 +- .../minstrel/api/AuthCookieInterceptorTest.kt | 5 +- .../minstrel/api/BaseUrlInterceptorTest.kt | 2 +- cmd/minstrel/main.go | 2 +- config.example.yaml | 2 +- docs/screenshots/README.md | 2 +- docs/smart-music-client-spec.md | 2 + docs/smart-music-server-spec.md | 2 + internal/api/admin_duplicates.go | 21 +- internal/api/admin_lidarr.go | 392 -------- internal/api/admin_lidarr_test.go | 455 --------- internal/api/admin_quarantine.go | 143 +-- internal/api/admin_quarantine_test.go | 200 +--- internal/api/admin_requests_test.go | 45 +- internal/api/admin_summary_test.go | 2 +- internal/api/admin_tracks.go | 39 +- internal/api/admin_tracks_test.go | 106 +-- internal/api/admin_wanted.go | 3 +- internal/api/admin_wanted_test.go | 10 +- internal/api/api.go | 42 +- internal/api/auth_test.go | 13 +- internal/api/catalogue.go | 7 +- internal/api/catalogue_test.go | 2 +- internal/api/errors.go | 9 + internal/api/events_publish.go | 6 +- internal/api/file_remove_error_test.go | 2 +- internal/api/library_test.go | 4 +- internal/api/lidarr_test.go | 55 -- internal/api/me_history_test.go | 2 +- internal/api/notify_producers_test.go | 30 +- internal/api/quarantine.go | 24 +- internal/api/requests_test.go | 22 +- internal/api/reset_test.go | 14 + internal/audit/audit.go | 10 - internal/config/config.go | 2 +- internal/coverart/embedded.go | 2 +- internal/coverart/enricher.go | 2 +- internal/db/dbq/admin_summary.sql.go | 4 +- internal/db/dbq/audit_log.sql.go | 4 +- internal/db/dbq/discover.sql.go | 8 +- internal/db/dbq/history.sql.go | 2 +- internal/db/dbq/lidarr_config.sql.go | 109 --- internal/db/dbq/merge.sql.go | 10 +- internal/db/dbq/missing_pairs.sql.go | 4 +- internal/db/dbq/models.go | 122 ++- ...rr_quarantine.sql.go => quarantine.sql.go} | 99 +- internal/db/dbq/recommendation.sql.go | 8 +- internal/db/dbq/system_mixes.sql.go | 10 +- internal/db/dbq/taste_profile.sql.go | 2 +- internal/db/dbq/tracks.sql.go | 20 +- internal/db/dbq/users.sql.go | 2 +- .../db/migrations/0091_remove_lidarr.down.sql | 29 + .../db/migrations/0091_remove_lidarr.up.sql | 29 + internal/db/queries/admin_summary.sql | 4 +- internal/db/queries/audit_log.sql | 4 +- internal/db/queries/discover.sql | 8 +- internal/db/queries/history.sql | 2 +- internal/db/queries/lidarr_config.sql | 20 - internal/db/queries/merge.sql | 6 +- internal/db/queries/missing_pairs.sql | 4 +- .../{lidarr_quarantine.sql => quarantine.sql} | 25 +- internal/db/queries/recommendation.sql | 8 +- internal/db/queries/system_mixes.sql | 10 +- internal/db/queries/taste_profile.sql | 2 +- internal/db/queries/tracks.sql | 20 +- internal/db/queries/users.sql | 2 +- internal/dbtest/reset.go | 6 +- internal/importer/name.go | 9 +- internal/importer/verify_test.go | 2 +- internal/library/duplicate_merge.go | 8 +- internal/library/duplicate_resolve.go | 2 +- internal/library/duplicate_resolve_test.go | 8 +- internal/library/fingerprint_settings.go | 4 +- internal/library/mbids.go | 2 +- internal/library/missing_pairs.go | 4 +- internal/library/missing_pairs_test.go | 2 +- internal/library/scanner.go | 2 +- internal/lidarr/client.go | 511 ---------- internal/lidarr/client_test.go | 892 ------------------ internal/lidarr/delete.go | 69 -- internal/lidarr/delete_test.go | 214 ----- internal/lidarr/errors.go | 33 - internal/lidarr/lookup_mbid.go | 81 -- .../lidarr/testdata/album_lookup_by_mbid.json | 8 - .../testdata/artist_lookup_by_mbid.json | 7 - internal/lidarr/testdata/lookup_album.json | 23 - internal/lidarr/testdata/lookup_artist.json | 19 - internal/lidarr/testdata/lookup_track.json | 18 - .../lidarr/testdata/quality_profiles.json | 5 - internal/lidarr/testdata/root_folders.json | 5 - internal/lidarr/types.go | 97 -- internal/lidarr/unmonitor.go | 91 -- internal/lidarrconfig/service.go | 94 -- internal/lidarrconfig/service_test.go | 88 -- internal/lidarrquarantine/service.go | 360 ------- internal/musicbrainz/entities.go | 2 +- internal/notifications/kinds.go | 4 +- internal/notifications/render.go | 4 +- internal/playlists/service_test.go | 2 +- internal/playlists/system_test.go | 4 +- internal/quarantine/service.go | 267 ++++++ .../service_test.go | 226 +---- internal/reacquisition/settings.go | 4 +- .../recommendation/home_integration_test.go | 2 +- internal/release/quality.go | 8 +- internal/release/release_test.go | 2 +- internal/server/server.go | 48 +- internal/server/server_test.go | 8 +- internal/similarity/worker.go | 6 +- internal/tags/provider_musicbrainz.go | 2 +- internal/tracks/merge_test.go | 39 - internal/tracks/service.go | 155 +-- internal/tracks/service_test.go | 218 +---- internal/wanted/manual.go | 2 +- internal/wanted/wanted.go | 2 +- web/csp.go | 4 +- web/src/lib/admin/reports.test.ts | 6 +- web/src/lib/admin/reports.ts | 17 +- web/src/lib/api/admin.test.ts | 124 +-- web/src/lib/api/admin.ts | 88 +- web/src/lib/api/quarantine.test.ts | 8 +- web/src/lib/api/quarantine.ts | 16 +- web/src/lib/api/queries.ts | 6 +- web/src/lib/api/requests.ts | 2 +- web/src/lib/api/types.ts | 110 +-- web/src/lib/components/AcquisitionCard.svelte | 2 +- .../lib/components/DiscoverResultCard.test.ts | 2 +- web/src/lib/components/FlagPopover.svelte | 6 +- web/src/lib/components/Shell.svelte | 2 +- .../components/admin/LidarrSettings.svelte | 327 ------- .../components/admin/LidarrSettings.test.ts | 188 ---- .../wanted/CatalogueArtistCard.svelte | 6 +- .../components/wanted/CatalogueGrid.svelte | 3 +- .../components/wanted/CollectionRail.svelte | 9 +- .../components/wanted/UnmatchedList.svelte | 12 +- web/src/lib/styles/error-copy.json | 8 - web/src/routes/admin/acquisition/+page.svelte | 6 +- web/src/routes/admin/duplicates/+page.svelte | 53 +- .../admin/duplicates/duplicates.test.ts | 28 +- web/src/routes/admin/reports/+page.svelte | 82 +- web/src/routes/admin/reports/reports.test.ts | 10 +- .../routes/admin/suspect-sources/+page.svelte | 2 +- web/src/routes/artists/[id]/+page.svelte | 2 +- web/src/routes/collection/+layout.svelte | 5 +- web/src/routes/library/hidden/+page.svelte | 6 +- web/src/routes/library/hidden/hidden.test.ts | 4 +- 170 files changed, 994 insertions(+), 6242 deletions(-) delete mode 100644 internal/api/admin_lidarr.go delete mode 100644 internal/api/admin_lidarr_test.go delete mode 100644 internal/api/lidarr_test.go create mode 100644 internal/api/reset_test.go delete mode 100644 internal/db/dbq/lidarr_config.sql.go rename internal/db/dbq/{lidarr_quarantine.sql.go => quarantine.sql.go} (81%) create mode 100644 internal/db/migrations/0091_remove_lidarr.down.sql create mode 100644 internal/db/migrations/0091_remove_lidarr.up.sql delete mode 100644 internal/db/queries/lidarr_config.sql rename internal/db/queries/{lidarr_quarantine.sql => quarantine.sql} (84%) delete mode 100644 internal/lidarr/client.go delete mode 100644 internal/lidarr/client_test.go delete mode 100644 internal/lidarr/delete.go delete mode 100644 internal/lidarr/delete_test.go delete mode 100644 internal/lidarr/errors.go delete mode 100644 internal/lidarr/lookup_mbid.go delete mode 100644 internal/lidarr/testdata/album_lookup_by_mbid.json delete mode 100644 internal/lidarr/testdata/artist_lookup_by_mbid.json delete mode 100644 internal/lidarr/testdata/lookup_album.json delete mode 100644 internal/lidarr/testdata/lookup_artist.json delete mode 100644 internal/lidarr/testdata/lookup_track.json delete mode 100644 internal/lidarr/testdata/quality_profiles.json delete mode 100644 internal/lidarr/testdata/root_folders.json delete mode 100644 internal/lidarr/types.go delete mode 100644 internal/lidarr/unmonitor.go delete mode 100644 internal/lidarrconfig/service.go delete mode 100644 internal/lidarrconfig/service_test.go delete mode 100644 internal/lidarrquarantine/service.go create mode 100644 internal/quarantine/service.go rename internal/{lidarrquarantine => quarantine}/service_test.go (55%) delete mode 100644 internal/tracks/merge_test.go delete mode 100644 web/src/lib/components/admin/LidarrSettings.svelte delete mode 100644 web/src/lib/components/admin/LidarrSettings.test.ts diff --git a/README.md b/README.md index 5e00f972..db06f14b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Minstrel -A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence. +A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence. > State and intelligence belong on the server, not the client. @@ -11,21 +11,21 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes, - **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration. - **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices. - **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. -- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key. +- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client. - **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. - **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track). ## Scope and responsible use -**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from. +**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients. -Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem. +Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind. -The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself. +**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself. -**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect. +**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect. -Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic. +Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic. ## Quickstart @@ -91,9 +91,9 @@ With the stack up, a handful of in-app steps get you to a working library. Use y The Admin page, where the library scan runs and reports progress -**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`. -ListenBrainz and Lidarr integration cards in admin Settings +Search services, download clients and searching in admin Acquisition **4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server. @@ -115,7 +115,7 @@ Most operators only need the env vars in the quickstart above. A few extras wort - `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there. - `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`). -ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. +ListenBrainz (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. Most operational keys have a `MINSTREL_
_` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt index f038163c..523d5dad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt @@ -27,8 +27,6 @@ class AdminQuarantineRepository @Inject constructor( suspend fun resolve(trackId: String) = api.resolve(trackId) suspend fun deleteFile(trackId: String) = api.deleteFile(trackId) - - suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId) } // ── Mappers ── @@ -49,7 +47,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef = artistName = artistName, albumTitle = albumTitle, albumId = albumId, - lidarrAlbumMbid = lidarrAlbumMbid, reportCount = reportCount, latestAt = latestAt, reasonCounts = reasonCounts, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt index a3c0b9cf..9fd6b7dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) { SectionCard( icon = Lucide.Inbox, title = "Requests", - subtitle = "Approve or reject Lidarr requests", + subtitle = "Approve or reject requests for new music", count = counts.requests, onClick = { navController.navigate(AdminRequests) }, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt index 586c4ce5..16873f6f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AssistChip -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -85,7 +84,6 @@ fun AdminQuarantineScreen( rows = s.rows, onResolve = viewModel::resolve, onDeleteFile = viewModel::deleteFile, - onDeleteViaLidarr = viewModel::deleteViaLidarr, ) } } @@ -97,7 +95,6 @@ private fun QueueList( rows: List, onResolve: (String) -> Unit, onDeleteFile: (String) -> Unit, - onDeleteViaLidarr: (String) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(items = rows, key = { it.trackId }) { row -> @@ -105,7 +102,6 @@ private fun QueueList( row = row, onResolve = { onResolve(row.trackId) }, onDeleteFile = { onDeleteFile(row.trackId) }, - onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) }, ) HorizontalDivider() } @@ -117,7 +113,6 @@ private fun QuarantineRow( row: AdminQuarantineItemRef, onResolve: () -> Unit, onDeleteFile: () -> Unit, - onDeleteViaLidarr: () -> Unit, ) { Column( modifier = Modifier @@ -166,7 +161,6 @@ private fun QuarantineRow( ActionButtons( onResolve = onResolve, onDeleteFile = onDeleteFile, - onDeleteViaLidarr = onDeleteViaLidarr, ) } } @@ -175,7 +169,6 @@ private fun QuarantineRow( private fun ActionButtons( onResolve: () -> Unit, onDeleteFile: () -> Unit, - onDeleteViaLidarr: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -183,6 +176,5 @@ private fun ActionButtons( ) { TextButton(onClick = onResolve) { Text("Resolve") } OutlinedButton(onClick = onDeleteFile) { Text("Delete file") } - Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt index 85e83b8e..510c9c83 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt @@ -82,7 +82,6 @@ class AdminQuarantineViewModel @Inject constructor( fun resolve(trackId: String) = act(trackId) { repository.resolve(it) } fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) } - fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) } private fun act(trackId: String, action: suspend (String) -> Unit) { val before = internal.value diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt index 114102df..d3be7555 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt @@ -61,7 +61,7 @@ fun AdminRequestsScreen( AdminRequestsUiState.Loading -> LoadingCentered() AdminRequestsUiState.Empty -> EmptyState( title = "No requests waiting", - body = "When users ask Lidarr for new music, their pending " + + body = "When users ask for new music, their pending " + "requests show up here for approval.", ) is AdminRequestsUiState.Error -> ErrorRetry( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt index 172d767b..3365e5cf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/AuthCookieInterceptor.kt @@ -19,7 +19,7 @@ import javax.inject.Singleton * leaks here: (1) the session cookie was being attached to every * external request the shared OkHttpClient services — including * Coil image fetches to artwork.musicbrainz.org / coverartarchive.org - * / Lidarr's /MediaCover endpoints — exposing the session + * — exposing the session * identifier to third-party logging; (2) a 401 from any of those * external hosts silently wiped the user's Minstrel session. * Restricting both attach + clear to placeholder-host requests diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt index 42d38e51..7269cd2a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/BaseUrlInterceptor.kt @@ -21,7 +21,7 @@ import javax.inject.Singleton * server URL in Settings without an app relaunch (Phase 11 wiring). * * Only requests whose host is the [PLACEHOLDER_HOST] sentinel are - * rewritten. Absolute external URLs — Lidarr-surfaced artwork from + * rewritten. Absolute external URLs — artwork from * artwork.musicbrainz.org / coverartarchive.org, for example — must * reach their authored host unchanged; rewriting them to the * Minstrel host produced 404s the user saw as missing Discover diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 6a795d74..7cdc8cf4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -105,19 +105,6 @@ object ErrorCopy { "cleartext_public" to "This server is on the internet, so its URL must start with https://. " + "Plain http:// only works on your home network.", - "lidarr_unreachable" to - "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.", - "lidarr_disabled" to "Lidarr integration is not enabled.", - "lidarr_auth_failed" to "Lidarr authentication failed.", - "lidarr_defaults_incomplete" to - "Lidarr is missing a default quality profile or root folder. " + - "Set them in Admin › Acquisition.", - "lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.", - "lidarr_rejected" to - "Lidarr rejected the request. Check the server logs for the field-level reason.", - "lidarr_album_lookup_failed" to - "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", - "album_mbid_missing" to "This track has no Lidarr album to remove.", "request_not_pending" to "This request is no longer pending.", "request_not_found" to "That request no longer exists.", "not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt index 1e8f168a..cde7fba6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt @@ -8,10 +8,9 @@ import retrofit2.http.Path /** * Retrofit interface for `/api/admin/quarantine`. * - * Three resolution endpoints: - * - `resolve` → admin reviewed, no action taken (clears flags). - * - `delete-file` → drop the local file; Lidarr keeps the track. - * - `delete-via-lidarr` → ask Lidarr to remove the track upstream too. + * Two resolution endpoints: + * - `resolve` → admin reviewed, no action taken (clears flags). + * - `delete-file` → delete the track's file and row. */ interface AdminQuarantineApi { @GET("api/admin/quarantine") @@ -22,7 +21,4 @@ interface AdminQuarantineApi { @POST("api/admin/quarantine/{trackId}/delete-file") suspend fun deleteFile(@Path("trackId") trackId: String) - - @POST("api/admin/quarantine/{trackId}/delete-via-lidarr") - suspend fun deleteViaLidarr(@Path("trackId") trackId: String) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt index 9d46b41e..6e4bddc6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt @@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire import com.fabledsword.minstrel.models.wire.CreateRequestBody -import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire +import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire import retrofit2.http.Body @@ -30,7 +30,7 @@ interface DiscoverApi { suspend fun search( @Query("q") query: String, @Query("kind") kind: String, - ): List + ): List @POST("api/requests") suspend fun createRequest(@Body body: CreateRequestBody) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 07898135..a19c7cb2 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -75,8 +75,8 @@ data class LikeTogglePayload( ) /** - * Persisted payload for `MutationKind.REQUEST_CREATE` (Discover → - * Lidarr request that landed during a connectivity hiccup). Optional + * Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover + * request that landed during a connectivity hiccup). Optional * fields stay null when not applicable; the replayer rebuilds the * `CreateRequestBody` from these. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt index 3a0db0e4..c9fcedbf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/data/DiscoverRepository.kt @@ -4,13 +4,13 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi import com.fabledsword.minstrel.cache.mutations.MutationQueue import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload import com.fabledsword.minstrel.models.ArtistSuggestionRef -import com.fabledsword.minstrel.models.LidarrRequestKind -import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.models.RequestKind +import com.fabledsword.minstrel.models.CatalogueResultRef import com.fabledsword.minstrel.models.SeedContributionRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire import com.fabledsword.minstrel.models.wire.CreateRequestBody -import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire +import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire import com.fabledsword.minstrel.models.wire.SeedContributionWire import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire @@ -28,7 +28,7 @@ import javax.inject.Singleton enum class RequestOutcome { ACCEPTED, QUEUED } /** - * Read-through Discover surface: Lidarr search, suggestion feed, and + * Read-through Discover surface: catalogue search, suggestion feed, and * the request-creation write path. Mirrors the Flutter * `DiscoverApi` + `mutationQueueProvider` integration — * `feedback_offline_first_for_server_writes` says writes never go @@ -113,7 +113,7 @@ class DiscoverRepository @Inject constructor( code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX && code != HTTP_TIMEOUT && code != HTTP_TOO_MANY - suspend fun search(query: String, kind: LidarrRequestKind): List = + suspend fun search(query: String, kind: RequestKind): List = api.search(query = query, kind = kind.wire).map { it.toDomain() } /** @@ -123,7 +123,7 @@ class DiscoverRepository @Inject constructor( * 2xx, [RequestOutcome.QUEUED] when the call was buffered. */ suspend fun createRequest( - kind: LidarrRequestKind, + kind: RequestKind, artistMbid: String, artistName: String, albumMbid: String? = null, @@ -163,7 +163,7 @@ class DiscoverRepository @Inject constructor( // ── Mappers (internal — wire types stay out of UI) ── -private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef( +private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef( mbid = mbid, name = name, secondaryText = secondaryText, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt index f62d06a6..ce27c2b7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Search as LucideSearch import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.models.ArtistSuggestionRef -import com.fabledsword.minstrel.models.LidarrRequestKind -import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.models.RequestKind +import com.fabledsword.minstrel.models.CatalogueResultRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef import com.fabledsword.minstrel.nav.Discover import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets @@ -161,7 +161,7 @@ private fun SearchBar( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), singleLine = true, - placeholder = { Text("Search Lidarr for new music") }, + placeholder = { Text("Search MusicBrainz for new music") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSubmit() }), trailingIcon = { @@ -173,7 +173,7 @@ private fun SearchBar( } @Composable -private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) { +private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -181,13 +181,13 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FilterChip( - selected = kind == LidarrRequestKind.ARTIST, - onClick = { onChange(LidarrRequestKind.ARTIST) }, + selected = kind == RequestKind.ARTIST, + onClick = { onChange(RequestKind.ARTIST) }, label = { Text("Artists") }, ) FilterChip( - selected = kind == LidarrRequestKind.ALBUM, - onClick = { onChange(LidarrRequestKind.ALBUM) }, + selected = kind == RequestKind.ALBUM, + onClick = { onChange(RequestKind.ALBUM) }, label = { Text("Albums") }, ) } @@ -316,9 +316,9 @@ private fun SuggestionsHeader() { @Composable private fun ResultsList( - rows: List, + rows: List, locallyRequestedMbids: Set, - onRequest: (LidarrSearchResultRef) -> Unit, + onRequest: (CatalogueResultRef) -> Unit, ) { if (rows.isEmpty()) { CenteredMessage("Nothing to add for that search yet.") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt index 46b74506..f5201266 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverTiles.kt @@ -30,7 +30,7 @@ import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.User import com.fabledsword.minstrel.models.ArtistSuggestionRef -import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.models.CatalogueResultRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef import com.fabledsword.minstrel.shared.widgets.ServerImage @@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) { @Composable internal fun ResultTile( - row: LidarrSearchResultRef, + row: CatalogueResultRef, locallyRequested: Boolean, onRequest: () -> Unit, ) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt index f26011c3..1e05471a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverViewModel.kt @@ -8,8 +8,8 @@ import com.fabledsword.minstrel.connectivity.recoveries import com.fabledsword.minstrel.discover.data.DiscoverRepository import com.fabledsword.minstrel.discover.data.RequestOutcome import com.fabledsword.minstrel.models.ArtistSuggestionRef -import com.fabledsword.minstrel.models.LidarrRequestKind -import com.fabledsword.minstrel.models.LidarrSearchResultRef +import com.fabledsword.minstrel.models.RequestKind +import com.fabledsword.minstrel.models.CatalogueResultRef import com.fabledsword.minstrel.models.SuggestionSnoozeRef import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -24,7 +24,7 @@ import javax.inject.Inject data class DiscoverState( val query: String = "", - val kind: LidarrRequestKind = LidarrRequestKind.ARTIST, + val kind: RequestKind = RequestKind.ARTIST, val suggestions: SuggestionState = SuggestionState.Loading, val results: ResultsState = ResultsState.Idle, val locallyRequestedMbids: Set = emptySet(), @@ -51,7 +51,7 @@ sealed interface ResultsState { /** No active search — the screen shows the suggestion feed. */ data object Idle : ResultsState data object Loading : ResultsState - data class Loaded(val items: List) : ResultsState + data class Loaded(val items: List) : ResultsState data class Error(val message: String) : ResultsState } @@ -87,7 +87,7 @@ class DiscoverViewModel @Inject constructor( } } - fun setKind(kind: LidarrRequestKind) { + fun setKind(kind: RequestKind) { internal.update { it.copy(kind = kind) } if (internal.value.query.isNotBlank()) runSearch() } @@ -177,14 +177,14 @@ class DiscoverViewModel @Inject constructor( * QUEUED) so the screen can pick the snackbar wording and update * the local "requested" overlay. */ - suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome { + suspend fun requestRow(row: CatalogueResultRef): RequestOutcome { val kind = internal.value.kind val outcome = repository.createRequest( kind = kind, - artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid, - artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText, - albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null, - albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null, + artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid, + artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText, + albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null, + albumTitle = if (kind == RequestKind.ALBUM) row.name else null, ) markRequested(row.mbid) return outcome @@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor( suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome { val outcome = repository.createRequest( - kind = LidarrRequestKind.ARTIST, + kind = RequestKind.ARTIST, artistMbid = s.mbid, artistName = s.name, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt index 03b1b09c..5ca7071b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt @@ -25,7 +25,6 @@ data class AdminQuarantineItemRef( val artistName: String, val albumTitle: String, val albumId: String, - val lidarrAlbumMbid: String? = null, val reportCount: Int = 0, val latestAt: String = "", val reasonCounts: Map = emptyMap(), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt index c89d7ad5..b51729b9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt @@ -4,25 +4,24 @@ import kotlinx.datetime.Instant import kotlin.math.roundToInt /** - * Kind of Lidarr request being created. Wire form is the lowercase + * Kind of request being created. Wire form is the lowercase * enum name; the helper [wire] keeps that mapping in one place. */ -enum class LidarrRequestKind { +enum class RequestKind { ARTIST, ALBUM, TRACK; val wire: String get() = name.lowercase() } /** - * Lidarr search hit. Mirrors the Flutter client's - * `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid` + * Catalogue search hit — `mbid` is the result's own MBID; `artistMbid` * and `albumMbid` are filled when the row is an album/track and the * UI needs the parent IDs to build the request. * * `inLibrary` and `requested` together cover the "already-handled" * cases — UI greys the row + swaps the Request button for a pill. */ -data class LidarrSearchResultRef( +data class CatalogueResultRef( val mbid: String, val name: String, val secondaryText: String = "", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt index b18cef97..dac51d9c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/QuarantineRef.kt @@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models /** * One quarantined track owned by the caller. Mirrors the Flutter - * `QuarantineMineRow` (web `LidarrQuarantineMineRow`). + * `QuarantineMineRow` (web `QuarantineMineRow`). * * `reasonLabel` provides the display string mapped from the raw * server-side reason key — UI renders it as a pill on the row. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt index b4cdbe0e..bd11ea50 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt @@ -1,7 +1,7 @@ package com.fabledsword.minstrel.models /** - * Status of a Lidarr request. Stored on the wire as a plain string; + * Status of a request. Stored on the wire as a plain string; * the typed enum keeps switch / when blocks exhaustive and lets the * UI map directly to a status pill color without raw-string matches. */ @@ -23,7 +23,7 @@ enum class RequestStatus { } /** - * One Lidarr request the user has submitted. Mirrors + * One request the user has submitted. Mirrors * the Flutter client's `AdminRequest` — * shared between the user-side `/api/requests` view and the admin * cross-user view since the wire shape is identical. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt index f41d4bd8..3a62bd77 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt @@ -29,7 +29,6 @@ data class AdminQuarantineItemWire( @SerialName("artist_name") val artistName: String = "", @SerialName("album_title") val albumTitle: String = "", @SerialName("album_id") val albumId: String = "", - @SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null, @SerialName("report_count") val reportCount: Int = 0, @SerialName("latest_at") val latestAt: String = "", @SerialName("reason_counts") val reasonCounts: Map = emptyMap(), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt index 1897092c..03a4fcbf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * One row of `GET /api/catalogue/search` (the MusicBrainz search that replaced - * Lidarr's, M502 #5519). Mirrors `web/src/lib/api/types.ts + * One row of `GET /api/catalogue/search` (the MusicBrainz search, M502 + * #5519). Mirrors `web/src/lib/api/types.ts * CatalogueSearchResult`. `imageUrl` is the server's own art endpoint, * relative, so it goes through ServerImage. * @@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable * empty so missing keys don't throw on decode. */ @Serializable -data class LidarrSearchResultWire( +data class CatalogueSearchResultWire( val mbid: String = "", val name: String = "", @SerialName("secondary_text") val secondaryText: String = "", @@ -27,8 +27,7 @@ data class LidarrSearchResultWire( /** * `GET /api/discover/suggestions` row — an out-of-library artist - * surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved - * on-demand from Lidarr server-side and may be empty. + * surfaced from ListenBrainz-derived seeds. `imageUrl` may be empty. */ @Serializable data class ArtistSuggestionWire( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt index 2a9fde15..6ec6bebf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable /** * One row of `GET /api/quarantine/mine`. Mirrors * the Flutter client's `QuarantineMineRow` - * (web `LidarrQuarantineMineRow`). + * (web `QuarantineMineRow`). * * Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate` * / `other`. Stored as a plain string on the wire; the UI does the diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt index 5aa09172..7e7eb9be 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt @@ -18,7 +18,7 @@ import javax.inject.Singleton enum class CancelOutcome { Synced, Queued } /** - * Read-through accessor for the caller's own Lidarr requests + + * Read-through accessor for the caller's own requests + * cancel-while-pending write. No Room caching for v1 — same rationale * as HistoryRepository; the offline-snapshot mirror lands with a * later slice. Cancel is offline-first: direct REST on the happy diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index e0db9edc..ab5c721f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -78,7 +78,7 @@ fun RequestsScreen( UiState.Loading -> LoadingCentered() UiState.Empty -> EmptyState( title = "Nothing requested yet", - body = "Use Discover to ask Lidarr for new music; your " + + body = "Use Discover to ask for new music; your " + "requests show up here.", ) is UiState.Error -> ErrorRetry( @@ -218,7 +218,7 @@ private fun CancelConfirmDialog( AlertDialog( onDismissRequest = onDismiss, title = { Text("Cancel request?") }, - text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") }, + text = { Text("Cancel “$displayName”? Your request is withdrawn.") }, confirmButton = { Button(onClick = onConfirm) { Text("Cancel request") } }, diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt index 5b91d267..c198776b 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -135,9 +135,8 @@ class AuthCookieInterceptorTest { } // Drift #569 regression guard: a 401 from an external host must - // NOT wipe the user's Minstrel session. A misbehaving CDN or a - // Lidarr that's 401-ing on /MediaCover URLs used to silently sign - // the user out. + // NOT wipe the user's Minstrel session. A misbehaving CDN that's + // 401-ing on artwork URLs used to silently sign the user out. @Test fun `does NOT clear cookie on 401 from non-placeholder host`() { authStore.setSessionCookie("session=keep") diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt index d818c9e8..0fc5e568 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt @@ -67,7 +67,7 @@ class BaseUrlInterceptorTest { @Test fun `passes external absolute URL through unchanged`() { - // Lidarr-surfaced artwork hosts (artwork.musicbrainz.org, + // External artwork hosts (artwork.musicbrainz.org, // coverartarchive.org) are absolute external URLs. The // interceptor must NOT rewrite them to the Minstrel base — // doing so produces 404s the user saw as missing Discover diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index e8b4e4d7..e7bae99c 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -297,7 +297,7 @@ func run() error { go gcWorker.Run(ctx) // The request reconciler completes approved requests once the library - // holds what they asked for (M502 #5520: wants, not Lidarr adds). + // holds what they asked for (M502 #5520). // Live-event bus shared between SSE subscribers (api.Mount) and // background workers that publish (reconciler today; scanner later). // Constructed before any service that publishes so they all share the diff --git a/config.example.yaml b/config.example.yaml index d56e1d40..b1b58ddf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -41,7 +41,7 @@ library: # Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION branding: app_name: "Minstrel" - description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration." + description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition." subsonic: # Gate for the legacy `p=` plaintext password parameter on /rest/*. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index d333d32d..c7306c6f 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -11,7 +11,7 @@ across all shots for a consistent look. | `home.png` | `/` | The home page with a populated library (system playlists / recently added) | | `register.png` | `/register` | The account-creation form on a fresh instance | | `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) | -| `integrations.png` | `/admin/acquisition` | Indexers, download clients, searching and Lidarr | +| `integrations.png` | `/admin/acquisition` | Indexers, download clients and searching | | `android-download.png` | `/settings` | The "Install the Android app" download card | | `invite-users.png` | `/admin/users` | The invite-token generation / users list | diff --git a/docs/smart-music-client-spec.md b/docs/smart-music-client-spec.md index d74f8a86..24be8daf 100644 --- a/docs/smart-music-client-spec.md +++ b/docs/smart-music-client-spec.md @@ -1,5 +1,7 @@ # Project Spec: Smart Music Client (companion app) +> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502). + ## 1. Overview A mobile music player client built as the intentional companion to the Smart Music Server (see `smart-music-server-spec.md`). This is not a general-purpose Subsonic client — it is the client that knows how to consume the server's extended API and surface its novel features (contextual likes, session-aware radio, Lidarr integration, server-canonical state) as first-class UX. diff --git a/docs/smart-music-server-spec.md b/docs/smart-music-server-spec.md index def6d03f..75e0fca6 100644 --- a/docs/smart-music-server-spec.md +++ b/docs/smart-music-server-spec.md @@ -1,5 +1,7 @@ # Project Spec: Smart Music Server (working name) +> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502). + ## 1. Overview A self-hosted music server and streaming backend built to close a real gap in the self-hosted music ecosystem: **state and intelligence belong on the server, not the client.** Existing Subsonic-compatible servers (Navidrome, Gonic, Airsonic-Advanced) defer all of the "smart" behavior — shuffle logic, radio, play count semantics, like management — to clients. The result is fragmented experience across devices: a song liked on one client is unknown to another; shuffle quality depends on which app you opened; play counts diverge. This project centralizes those decisions on the server so every client sees the same canonical state. diff --git a/internal/api/admin_duplicates.go b/internal/api/admin_duplicates.go index a21bd08b..8e730ad3 100644 --- a/internal/api/admin_duplicates.go +++ b/internal/api/admin_duplicates.go @@ -165,7 +165,7 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request) } // resolvedActions are the audit actions the "Resolved automatically" tab lists. -var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)} +var resolvedActions = []string{string(audit.ActionDuplicateMerge)} func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) { var c duplicateViewCounts @@ -180,9 +180,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, return c, err } -// resolvedActivityView is one thing the resolver did on its own: a merge -// (action duplicate_merge) or a Lidarr release change (lidarr_release_change). -// Details is the audit row's metadata, which names the files or releases. +// resolvedActivityView is one merge the resolver made on its own (action +// duplicate_merge). Details is the audit row's metadata, which names the files. type resolvedActivityView struct { ID string `json:"id"` Action string `json:"action"` @@ -370,18 +369,16 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i // keeps the report's proposal. type mergeDuplicateRequest struct { SurvivorTrackID string `json:"survivor_track_id"` - Unmonitor bool `json:"unmonitor"` } // mergeDuplicateResponse reports what the merge removed. RemovedPaths are files // deleted from disk; the operator reads them to know exactly what went. type mergeDuplicateResponse struct { - SurvivorTrackID string `json:"survivor_track_id"` - RemovedPaths []string `json:"removed_paths"` - LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"` + SurvivorTrackID string `json:"survivor_track_id"` + RemovedPaths []string `json:"removed_paths"` } -// mergeRequestBodyLimit bounds the request body. It holds one id and a flag. +// mergeRequestBodyLimit bounds the request body. It holds one id. const mergeRequestBodyLimit = 1 << 16 // handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge @@ -416,7 +413,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ } } - res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor) + res, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID) if err != nil { if apiErr, ok := fileRemoveAPIError(err); ok { logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID)) @@ -442,9 +439,5 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ for _, c := range res.Removed { resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath) } - if body.Unmonitor && unmonitorFailed { - failed := true - resp.LidarrUnmonitorFailed = &failed - } writeJSON(w, http.StatusOK, resp) } diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go deleted file mode 100644 index 7eea6dfc..00000000 --- a/internal/api/admin_lidarr.go +++ /dev/null @@ -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":""} 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}) -} diff --git a/internal/api/admin_lidarr_test.go b/internal/api/admin_lidarr_test.go deleted file mode 100644 index 5db71172..00000000 --- a/internal/api/admin_lidarr_test.go +++ /dev/null @@ -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"]) - } - }) - } -} diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go index bd372d3f..6949d234 100644 --- a/internal/api/admin_quarantine.go +++ b/internal/api/admin_quarantine.go @@ -10,22 +10,20 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" + "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine" ) // adminQueueRowView is a single aggregated row in the admin queue response. type adminQueueRowView struct { - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle string `json:"album_title"` - AlbumID string `json:"album_id"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - ReportCount int32 `json:"report_count"` - LatestAt string `json:"latest_at"` - ReasonCounts map[string]int `json:"reason_counts"` - Reports []adminQueueReportView `json:"reports"` + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle string `json:"album_title"` + AlbumID string `json:"album_id"` + ReportCount int32 `json:"report_count"` + LatestAt string `json:"latest_at"` + ReasonCounts map[string]int `json:"reason_counts"` + Reports []adminQueueReportView `json:"reports"` } // adminQueueReportView is one user's report under an aggregated admin row. @@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string { // handleListAdminQuarantine implements GET /api/admin/quarantine. func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { - rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) + rows, err := h.quarantine.ListAdminQueue(r.Context()) if err != nil { h.logger.Error("admin: list quarantine", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") @@ -66,27 +64,25 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ }) } out = append(out, adminQueueRowView{ - TrackID: uuidToString(row.TrackID), - TrackTitle: row.TrackTitle, - ArtistName: row.ArtistName, - AlbumTitle: row.AlbumTitle, - AlbumID: uuidToString(row.AlbumID), - LidarrAlbumMBID: row.LidarrAlbumMBID, - ReportCount: row.ReportCount, - LatestAt: formatTimestamp(row.LatestAt), - ReasonCounts: row.ReasonCounts, - Reports: reports, + TrackID: uuidToString(row.TrackID), + TrackTitle: row.TrackTitle, + ArtistName: row.ArtistName, + AlbumTitle: row.AlbumTitle, + AlbumID: uuidToString(row.AlbumID), + ReportCount: row.ReportCount, + LatestAt: formatTimestamp(row.LatestAt), + ReasonCounts: row.ReasonCounts, + Reports: reports, }) } writeJSON(w, http.StatusOK, out) } -// actionResultView is the response shape for the three admin destructive -// actions (resolve, delete-file, delete-via-lidarr). +// actionResultView is the response shape for the admin actions (resolve, +// delete-file). type actionResultView struct { - ActionID string `json:"action_id"` - AffectedUsers int32 `json:"affected_users"` - DeletedTrackCount *int `json:"deleted_track_count,omitempty"` + ActionID string `json:"action_id"` + AffectedUsers int32 `json:"affected_users"` } // handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve. @@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } - action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) + action, err := h.quarantine.Resolve(r.Context(), id, admin.ID) if err != nil { - if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { + if errors.Is(err, quarantine.ErrTrackNotFound) { writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") return } @@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } - action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) + action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID) if err != nil { // Written in the enveloped shape, not writeAdminJSONErr's bare code: the // message is the part that tells the operator which directory and uid. @@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req return } switch { - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + case errors.Is(err, quarantine.ErrTrackNotFound): writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") default: h.logger.Error("admin: delete file", "err", err) @@ -158,60 +154,19 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req }) } -// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr. -func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) { - admin, ok := auth.UserFromContext(r.Context()) - if !ok { - writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized") - return - } - id, ok := parseUUID(chi.URLParam(r, "track_id")) - if !ok { - writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") - return - } - action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) - if err != nil { - switch { - case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): - writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") - case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): - writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") - case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): - writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") - case errors.Is(err, lidarr.ErrUnreachable): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") - case errors.Is(err, lidarr.ErrAuthFailed): - writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") - default: - h.logger.Error("admin: delete via lidarr", "err", err) - writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") - } - return - } - // Broadcast: same rationale as delete-file — track row gone everywhere. - h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true) - writeJSON(w, http.StatusOK, actionResultView{ - ActionID: uuidToString(action.ID), - AffectedUsers: action.AffectedUsers, - DeletedTrackCount: &deleted, - }) -} - // actionLogView is the row shape returned by GET /api/admin/quarantine/actions. type actionLogView struct { - ID string `json:"id"` - TrackID string `json:"track_id"` - TrackTitle string `json:"track_title"` - ArtistName string `json:"artist_name"` - AlbumTitle *string `json:"album_title,omitempty"` - Action string `json:"action"` - AdminID *string `json:"admin_id,omitempty"` - LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` - AffectedUsers int32 `json:"affected_users"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + TrackID string `json:"track_id"` + TrackTitle string `json:"track_title"` + ArtistName string `json:"artist_name"` + AlbumTitle *string `json:"album_title,omitempty"` + Action string `json:"action"` + AdminID *string `json:"admin_id,omitempty"` + // AlbumMBID is set on the older deleted_album rows: the album removed. + AlbumMBID *string `json:"album_mbid,omitempty"` + AffectedUsers int32 `json:"affected_users"` + CreatedAt string `json:"created_at"` } // handleListQuarantineActions implements GET /api/admin/quarantine/actions. @@ -241,16 +196,16 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re adminID = &s } out = append(out, actionLogView{ - ID: uuidToString(row.ID), - TrackID: uuidToString(row.TrackID), - TrackTitle: row.TrackTitle, - ArtistName: row.ArtistName, - AlbumTitle: row.AlbumTitle, - Action: string(row.Action), - AdminID: adminID, - LidarrAlbumMBID: row.LidarrAlbumMbid, - AffectedUsers: row.AffectedUsers, - CreatedAt: formatTimestamp(row.CreatedAt), + ID: uuidToString(row.ID), + TrackID: uuidToString(row.TrackID), + TrackTitle: row.TrackTitle, + ArtistName: row.ArtistName, + AlbumTitle: row.AlbumTitle, + Action: string(row.Action), + AdminID: adminID, + AlbumMBID: row.AlbumMbid, + AffectedUsers: row.AffectedUsers, + CreatedAt: formatTimestamp(row.CreatedAt), }) } writeJSON(w, http.StatusOK, out) diff --git a/internal/api/admin_quarantine_test.go b/internal/api/admin_quarantine_test.go index 5318d5b3..e412adfd 100644 --- a/internal/api/admin_quarantine_test.go +++ b/internal/api/admin_quarantine_test.go @@ -15,9 +15,6 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" ) // newAdminQuarantineRouter builds a test chi router with the five admin @@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router { admin.Get("/quarantine", h.handleListAdminQuarantine) admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine) admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) - admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) admin.Get("/quarantine/actions", h.handleListQuarantineActions) }) return r @@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [ return w } -// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service -// with a real clientFn pointing at the (current) saved lidarr_config. This -// mirrors testHandlersWithClientFn for the requests test file. -func installQuarantineClientFn(t *testing.T, h *handlers) { - t.Helper() - cfg := lidarrconfig.New(h.pool) - clientFn := func() *lidarr.Client { - c, err := cfg.Get(context.Background()) - if err != nil || !c.Enabled || c.BaseURL == "" { - return nil - } - return lidarr.NewClient(c.BaseURL, c.APIKey) - } - h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir) -} - // flagDirect bypasses the HTTP handler to seed a quarantine row via the // Service for setup. Used to construct admin queue scenarios. func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) { t.Helper() - if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil { + if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil { t.Fatalf("flagDirect: %v", err) } } @@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin return tr, path } -// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is -// required for delete-via-lidarr scenarios. -func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) { - t.Helper() - artist := seedArtist(t, h.pool, "DvL Artist "+unique) - dir := t.TempDir() - path := filepath.Join(dir, "track-"+unique+".mp3") - if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - mbid := albumMBID - album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique, - ArtistID: artist.ID, Mbid: &mbid, - }) - if err != nil { - t.Fatalf("UpsertAlbum: %v", err) - } - one := int32(1) - tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "DvL Track " + unique, - AlbumID: album.ID, - ArtistID: artist.ID, - TrackNumber: &one, - DurationMs: 30000, - FilePath: path, - FileSize: 5, - FileFormat: "mp3", - }) - if err != nil { - t.Fatalf("UpsertTrack: %v", err) - } - return tr, path -} - // TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same // track with mixed reasons. Verifies one aggregated row with report_count=3, // reason_counts populated, and a Reports list of length 3. func TestListAdminQuarantine_AggregatedShape(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) bob := seedUser(t, pool, "bob", "pw", false) @@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) { func TestResolveQuarantine_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) bob := seedUser(t, pool, "bob", "pw", false) @@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) { if len(actions) != 1 { t.Fatalf("audit rows = %d, want 1", len(actions)) } - if actions[0].Action != dbq.LidarrQuarantineActionKindResolved { + if actions[0].Action != dbq.QuarantineActionKindResolved { t.Errorf("audit action = %v, want resolved", actions[0].Action) } } @@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) { func TestDeleteQuarantineFile_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) admin := seedUser(t, pool, "admin", "pw", true) @@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) { } } -// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns -// a single album for the lookup and 200 for the delete. Verifies the -// audit row is written and the local track row is deleted. -func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) - - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { - _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`)) - return - } - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - saveLidarrConfig(t, h, stub.URL, true) - installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up - - alice := seedUser(t, pool, "alice", "pw", false) - admin := seedUser(t, pool, "admin", "pw", true) - - track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl") - flagDirect(t, h, alice.ID, track.ID, "bad_rip") - - w := doAdminQuarantineReq(t, h, http.MethodPost, - "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) - } - var got actionResultView - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v; body = %s", err, w.Body.String()) - } - if got.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", got.AffectedUsers) - } - if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 { - t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount) - } - - // Track row should be gone. - if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil { - t.Error("track row still exists after delete-via-lidarr") - } - // Audit row written with the lidarr_album_mbid. - actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50) - if err != nil { - t.Fatalf("list actions: %v", err) - } - if len(actions) != 1 { - t.Fatalf("audit rows = %d, want 1", len(actions)) - } - if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr { - t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action) - } - if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" { - t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid) - } -} - -// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is -// not configured. -func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) - - alice := seedUser(t, pool, "alice", "pw", false) - admin := seedUser(t, pool, "admin", "pw", true) - - track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled") - flagDirect(t, h, alice.ID, track.ID, "bad_rip") - - // Lidarr config is reset (disabled). - w := doAdminQuarantineReq(t, h, http.MethodPost, - "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) - if w.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String()) - } - var resp map[string]string - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v; body = %s", err, w.Body.String()) - } - if resp["error"] != "lidarr_disabled" { - t.Errorf("error = %q, want lidarr_disabled", resp["error"]) - } -} - -// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing -// when the parent album has no mbid. -func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) - - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - saveLidarrConfig(t, h, stub.URL, true) - installQuarantineClientFn(t, h) - - alice := seedUser(t, pool, "alice", "pw", false) - admin := seedUser(t, pool, "admin", "pw", true) - - // Track whose album has NO mbid. - track, _ := seedTrackOnDisk(t, h, "no-mbid") - flagDirect(t, h, alice.ID, track.ID, "bad_rip") - - w := doAdminQuarantineReq(t, h, http.MethodPost, - "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin) - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String()) - } - var resp map[string]string - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v; body = %s", err, w.Body.String()) - } - if resp["error"] != "album_mbid_missing" { - t.Errorf("error = %q, want album_mbid_missing", resp["error"]) - } -} - // TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin // endpoints reject non-admin callers with 403. func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - resetLidarrState(t, h) - installQuarantineClientFn(t, h) + resetRequests(t, h) nonAdmin := seedUser(t, pool, "regular", "pw", false) fakeID := "00000000-0000-0000-0000-000000000001" @@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) { {http.MethodGet, "/api/admin/quarantine"}, {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"}, {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"}, - {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"}, {http.MethodGet, "/api/admin/quarantine/actions"}, } for _, tc := range cases { diff --git a/internal/api/admin_requests_test.go b/internal/api/admin_requests_test.go index 1fb001d9..2d6ff88a 100644 --- a/internal/api/admin_requests_test.go +++ b/internal/api/admin_requests_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" @@ -49,13 +48,7 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by return w } -// testHandlersWithClientFn is the request handlers' test fixture. -func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) { - t.Helper() - return testHandlers(t) -} - -// seedPendingArtistRequest creates a pending artist-kind lidarr request row +// seedPendingArtistRequest creates a pending artist-kind request row // for the given user and returns its parsed requestView. func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView { t.Helper() @@ -65,8 +58,8 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB // TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected // and verifies that GET without ?status returns only the 2 pending rows. func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -103,8 +96,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) { // TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns // only rejected rows. func TestHandleListAdminRequests_StatusFilter(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -137,8 +130,8 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) { // TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid // ?status returns 400. func TestHandleListAdminRequests_BadStatus_400(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -158,8 +151,8 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) { // TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies // POST /approve → 409 request_not_pending. func TestHandleApproveRequest_NotPending_409(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -188,8 +181,8 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) { // TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns // 200 and the row transitions to rejected with notes saved. func TestHandleRejectRequest_HappyPath(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -217,8 +210,8 @@ func TestHandleRejectRequest_HappyPath(t *testing.T) { // TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body // returns 200 (notes are optional). func TestHandleRejectRequest_NoNotes_OK(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -242,8 +235,8 @@ func TestHandleRejectRequest_NoNotes_OK(t *testing.T) { // TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that // all 3 admin request endpoints reject non-admin users with 403. func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) nonAdmin := seedUser(t, h.pool, "regular", "pw", false) @@ -281,8 +274,8 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) { // TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts // its release group on the wanted list, as the requester's. func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) @@ -309,8 +302,8 @@ func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) { // TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is // followed on the profile the admin picked; an unknown profile is a 400. func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "alice", "pw", false) admin := seedUser(t, h.pool, "admin", "pw", true) ctx := context.Background() diff --git a/internal/api/admin_summary_test.go b/internal/api/admin_summary_test.go index 6e92ed28..263dc999 100644 --- a/internal/api/admin_summary_test.go +++ b/internal/api/admin_summary_test.go @@ -34,7 +34,7 @@ func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummar // The summary counts each queue an admin acts on and says since when the // oldest item has waited; an empty queue has no since (#5605). func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) { - h, _ := testHandlersWithClientFn(t) + h, _ := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) listener := seedUser(t, h.pool, "listener", "pw", false) ctx := context.Background() diff --git a/internal/api/admin_tracks.go b/internal/api/admin_tracks.go index 7630fa27..4d312ab7 100644 --- a/internal/api/admin_tracks.go +++ b/internal/api/admin_tracks.go @@ -3,7 +3,6 @@ package api import ( "errors" "net/http" - "strconv" "github.com/go-chi/chi/v5" @@ -11,27 +10,22 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) -// removeTrackResponse is the wire shape from spec §5 (M7 #372). The three +// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two // optional fields are omitted unless the corresponding cleanup actually // happened, so the response stays minimal in the common case. type removeTrackResponse struct { - DeletedTrackID string `json:"deleted_track_id"` - DeletedAlbumID *string `json:"deleted_album_id,omitempty"` - DeletedArtistID *string `json:"deleted_artist_id,omitempty"` - LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"` + DeletedTrackID string `json:"deleted_track_id"` + DeletedAlbumID *string `json:"deleted_album_id,omitempty"` + DeletedArtistID *string `json:"deleted_artist_id,omitempty"` } -// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false. +// handleRemoveTrack implements DELETE /api/admin/tracks/{id}. // // Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the // file, then the DB row, and runs the album/artist cascade tidy-up — and deletes -// nothing at all when the file cannot be removed (#3918). When -// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack -// — failure there is non-fatal (the destructive part already completed) and -// surfaces as `lidarr_unmonitor_failed: true` in the success envelope. +// nothing at all when the file cannot be removed (#3918). // -// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to -// wire error codes. The codes this handler emits are not_found, +// The codes this handler emits are not_found, // library_not_writable (409) and file_delete_failed when the file could not be // removed, server_error, plus the auth codes the middleware emits upstream. func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) { @@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) { return } - unmonitor := false - if v := r.URL.Query().Get("unmonitor"); v != "" { - if parsed, err := strconv.ParseBool(v); err == nil { - unmonitor = parsed - } - } - // Defensive: RequireUser+RequireAdmin should have run upstream. If // we got here without a user in context the routing is broken. admin, ok := requireUser(w, r) @@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) { return } - deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack( - r.Context(), trackID, admin.ID, unmonitor, - ) + deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID) if err != nil { if errors.Is(err, tracks.ErrNotFound) { writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"}) @@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) { s := uuidToString(*deletedArtist) resp.DeletedArtistID = &s } - // Only emit the flag when unmonitor was requested AND it failed. - // Avoids "false" cluttering the wire when the operator didn't ask - // for an unmonitor in the first place. - if unmonitor && lidarrUnmonitorFailed { - t := true - resp.LidarrUnmonitorFailed = &t - } - writeJSON(w, http.StatusOK, resp) } diff --git a/internal/api/admin_tracks_test.go b/internal/api/admin_tracks_test.go index 36637850..7ee49da0 100644 --- a/internal/api/admin_tracks_test.go +++ b/internal/api/admin_tracks_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "os" @@ -15,41 +14,12 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) -// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets -// tests dictate the return error. Used to drive both the Lidarr-success and -// Lidarr-failure branches without spinning up a stub HTTP server. -type fakeLidarrUnmonitorer struct { - calls []fakeUnmonitorCall - err error -} - -type fakeUnmonitorCall struct { - trackMbid string - albumMbid string -} - -func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error { - f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid}) - return f.err -} - -// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose -// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can -// assert call counts and inject error returns. -func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer { - t.Helper() - fake := &fakeLidarrUnmonitorer{} - h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir) - return fake -} - // newAdminTracksRouter builds a test chi router with the admin-tracks // endpoint. RequireAdmin middleware is applied; tests inject the user into // context manually (RequireUser is bypassed). Mirrors the pattern from -// admin_lidarr_test.go and admin_quarantine_test.go. +// admin_quarantine_test.go. func newAdminTracksRouter(h *handlers) chi.Router { r := chi.NewRouter() r.Route("/api/admin", func(admin chi.Router) { @@ -90,8 +60,8 @@ func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam str } // seedTrackForRemoveTest creates a fresh artist+album+track on disk and -// returns the track row. The mbid string is set on both track and album -// when non-empty so the unmonitor branch has the data it needs. +// returns the track row. The mbids are set on the track and album when +// non-empty. func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) { t.Helper() artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique) @@ -206,11 +176,9 @@ func TestHandleRemoveTrack_UnknownID404(t *testing.T) { } } -// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope -// when the operator does NOT request unmonitor: 200 with deleted_track_id -// (and the optional cascade fields when they fired) but NO -// lidarr_unmonitor_failed key in the body. -func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) { +// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with +// deleted_track_id and the optional cascade fields when they fired. +func TestHandleRemoveTrack_HappyPath(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) admin := seedUser(t, pool, "admin", "pw", true) @@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) { if got.DeletedArtistID == nil { t.Error("deleted_artist_id missing; expected sole-track cascade") } - if got.LidarrUnmonitorFailed != nil { - t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v", - *got.LidarrUnmonitorFailed) - } - - // Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty - // on a *bool drops it, but we double-check the wire to catch - // regressions. - var raw map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { - t.Fatalf("decode raw: %v", err) - } - if _, ok := raw["lidarr_unmonitor_failed"]; ok { - t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested") - } // File is gone, DB row is gone. if _, err := os.Stat(path); !os.IsNotExist(err) { @@ -261,50 +214,3 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) { t.Error("track row still exists") } } - -// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the -// operator sends ?unmonitor=true AND the Lidarr call fails, the response -// is still 200 (file + DB delete completed) but carries -// lidarr_unmonitor_failed: true. -func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) { - h, pool := testHandlers(t) - truncateLibrary(t, pool) - admin := seedUser(t, pool, "admin", "pw", true) - - fake := installTracksLidarrStub(t, h) - fake.err = errors.New("lidarr exploded") - - tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x") - w := doAdminTracksReq(t, h, http.MethodDelete, - "/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) - } - - var got removeTrackResponse - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v; body = %s", err, w.Body.String()) - } - if got.DeletedTrackID != uuidToString(tr.ID) { - t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID)) - } - if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed { - t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed) - } - - // Lidarr was actually called with the right mbids. - if len(fake.calls) != 1 { - t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls)) - } - if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" { - t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0]) - } - - // Destructive part still happened. - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("file still exists: %v", err) - } - if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil { - t.Error("track row still exists") - } -} diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go index 7dab1834..d8642615 100644 --- a/internal/api/admin_wanted.go +++ b/internal/api/admin_wanted.go @@ -107,8 +107,7 @@ type wantsPage struct { } // wantArtistView is one artist on the Wanted view's main page (#5572): how -// many of their albums the list holds, of how many, like Lidarr's artist -// index. +// many of their albums the list holds, of how many. type wantArtistView struct { ArtistMBID string `json:"artist_mbid"` ArtistName string `json:"artist_name"` diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go index b883120d..ad6adf3c 100644 --- a/internal/api/admin_wanted_test.go +++ b/internal/api/admin_wanted_test.go @@ -71,7 +71,7 @@ func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage { // name where the library holds the artist, each artist's albums oldest // first; q narrows it to an artist or an album. func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) { - h, _ := testHandlersWithClientFn(t) + h, _ := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) ctx := context.Background() beatles := "a0000000-0000-4000-8000-000000000002" @@ -113,7 +113,7 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) { // with how many of their albums are held; an artist's page lists every album // of theirs, held or not, under that artist. func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) { - h, _ := testHandlersWithClientFn(t) + h, _ := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) ctx := context.Background() q := dbq.New(h.pool) @@ -226,7 +226,7 @@ func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantT func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) { const group = "g0000000-0000-4000-8000-000000000010" for _, down := range []bool{false, true} { - h, pool := testHandlersWithClientFn(t) + h, pool := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger) @@ -253,7 +253,7 @@ func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) { // A held album shows the library's own tracks, a lost file marked. func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) { - h, _ := testHandlersWithClientFn(t) + h, _ := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) ctx := context.Background() q := dbq.New(h.pool) @@ -296,7 +296,7 @@ func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) { // local band's album left off and put back, and a link that names no // MusicBrainz album refused before MusicBrainz is asked. func TestUnmatched_ListLeaveOffPutBack(t *testing.T) { - h, _ := testHandlersWithClientFn(t) + h, _ := testHandlers(t) admin := seedUser(t, h.pool, "admin", "pw", true) ctx := context.Background() q := dbq.New(h.pool) diff --git a/internal/api/api.go b/internal/api/api.go index 13b2f0de..1ffdaeef 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -21,14 +21,13 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/importer" "git.fabledsword.com/bvandeusen/minstrel/internal/library" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" @@ -41,7 +40,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, requestSvc *requests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) { rng := rand.New(rand.NewSource(rand.Int63())) setupToken, err := auth.NewSetupToken() if err != nil { @@ -55,9 +54,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev pool: pool, logger: logger, events: events, recCfg: recCfg, recSettings: recSettings, rng: rng.Float64, - lidarrCfg: lidarrCfg, requests: requestSvc, - lidarrQuarantine: lidarrQuar, + quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, coverart: coverEnricher, @@ -229,9 +227,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Route("/admin", func(admin chi.Router) { admin.Use(auth.RequireAdmin()) - admin.Get("/lidarr/config", h.handleGetLidarrConfig) - admin.Put("/lidarr/config", h.handlePutLidarrConfig) - admin.Post("/lidarr/test", h.handleTestLidarrConnection) admin.Get("/acquisition/services", h.handleListAcquisitionServices) admin.Post("/acquisition/services", h.handleCreateAcquisitionService) admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService) @@ -241,9 +236,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key")) admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password")) admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password")) - admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) - admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles) - admin.Get("/lidarr/root-folders", h.handleListRootFolders) // The queues that wait on an admin, for the rail and the Overview (#5605). admin.Get("/summary", h.handleGetAdminSummary) admin.Get("/requests", h.handleListAdminRequests) @@ -290,7 +282,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/playback-errors", h.handleListAdminPlaybackErrors) admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError) admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile) - admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr) admin.Get("/quarantine/actions", h.handleListQuarantineActions) admin.Delete("/tracks/{id}", h.handleRemoveTrack) @@ -402,20 +393,19 @@ type handlers struct { // librarySize memoises the track count that sizes the candidate pool // (#3880). Held here rather than counted per request: the count is a // full table scan, and library size only moves when a scan runs. - librarySize *recommendation.LibrarySize - lidarrCfg *lidarrconfig.Service - requests *requests.Service - lidarrQuarantine *lidarrquarantine.Service - tracks *tracks.Service - playlists *playlists.Service - coverart *coverart.Enricher - coverSettings *coverart.SettingsService - tagSettings *tags.SettingsService - scanner *library.Scanner - scanCfg library.RunScanConfig - dataDir string - mailer mailer.Sender - eventbus *eventbus.Bus + librarySize *recommendation.LibrarySize + requests *requests.Service + quarantine *quarantine.Service + tracks *tracks.Service + playlists *playlists.Service + coverart *coverart.Enricher + coverSettings *coverart.SettingsService + tagSettings *tags.SettingsService + scanner *library.Scanner + scanCfg library.RunScanConfig + dataDir string + mailer mailer.Sender + eventbus *eventbus.Bus // notifier writes the notifications inbox (M489). Nil-safe: a nil // notifier records nothing, which is what most handler tests want. notifier *notifications.Notifier diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index eaef533c..f7386567 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -24,12 +24,11 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" "git.fabledsword.com/bvandeusen/minstrel/internal/library" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/requests" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" @@ -66,17 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { if err != nil { t.Fatalf("recsettings: %v", err) } - lidarrCfg := lidarrconfig.New(pool) requestSvc := requests.NewService(pool) cat := catalogue.New(pool, nil, logger) - lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "") - // tracks.Service has no Lidarr unmonitorer in tests by default; the - // admin-tracks tests below override h.tracks via installTracksLidarrStub - // when they need a stubbed Lidarr. + quarantineSvc := quarantine.NewService(pool, "") dataDir := t.TempDir() - tracksSvc := tracks.NewService(pool, logger, nil, dataDir) + tracksSvc := tracks.NewService(pool, logger, dataDir) playlistsSvc := playlists.NewService(pool, logger, dataDir) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)} + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)} return h, pool } diff --git a/internal/api/catalogue.go b/internal/api/catalogue.go index 2a712613..cf4c0658 100644 --- a/internal/api/catalogue.go +++ b/internal/api/catalogue.go @@ -21,11 +21,10 @@ import ( // catalogueSearchLimit is how many results one search shows. const catalogueSearchLimit = 25 -// catalogueSearchResult is one row of GET /api/catalogue/search. The shape is -// the one the Lidarr search answered with, so the Discover page and request -// rows read it unchanged. artist_mbid and album_mbid are the parents a +// catalogueSearchResult is one row of GET /api/catalogue/search, read by the +// Discover page and request rows. artist_mbid and album_mbid are the parents a // request needs: empty on an artist, the artist on an album, both on a track. -// An album is a MusicBrainz release group, as Lidarr's albums were. +// An album is a MusicBrainz release group. type catalogueSearchResult struct { MBID string `json:"mbid"` Name string `json:"name"` diff --git a/internal/api/catalogue_test.go b/internal/api/catalogue_test.go index c019b29d..6495ebec 100644 --- a/internal/api/catalogue_test.go +++ b/internal/api/catalogue_test.go @@ -56,7 +56,7 @@ func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server { func catalogueHandlers(t *testing.T, down bool) *handlers { t.Helper() h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1} h.catalogue = catalogue.New(pool, mb, h.logger) return h diff --git a/internal/api/errors.go b/internal/api/errors.go index be7c0427..50bcc297 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } + +// writeAdminJSONErr writes a flat {"error":""} 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}) +} diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go index 2178606d..7c6eb69f 100644 --- a/internal/api/events_publish.go +++ b/internal/api/events_publish.go @@ -48,8 +48,8 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str } // publishQuarantineEvent broadcasts a quarantine action. User-side -// flag/unflag are scoped to the user; admin-side resolve / delete-file / -// delete-via-lidarr are broadcast (empty UserID) because a single admin +// flag/unflag are scoped to the user; admin-side resolve / delete-file +// are broadcast (empty UserID) because a single admin // action can affect every user who'd flagged that track — every client // invalidates and the non-affected ones no-op when the query result // matches their cache. @@ -103,7 +103,7 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) { }) } -// publishRequestStatusChanged broadcasts a Lidarr request status flip to +// publishRequestStatusChanged broadcasts a request status flip to // the request's original requester so their /requests page reflects the // new state without manual refresh. Admin actors (approve / reject) still // route to the requester's user_id, not the admin's — the original user diff --git a/internal/api/file_remove_error_test.go b/internal/api/file_remove_error_test.go index 1da062da..b4317f92 100644 --- a/internal/api/file_remove_error_test.go +++ b/internal/api/file_remove_error_test.go @@ -15,7 +15,7 @@ import ( const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3" // removeFailure builds the error a delete service returns when the file would -// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack +// not go, wrapped the way quarantine.DeleteFile and tracks.RemoveTrack // wrap it — the mapping has to see through that. func removeFailure(errno syscall.Errno) error { return fmt.Errorf("delete file: %w", &library.FileRemoveError{ diff --git a/internal/api/library_test.go b/internal/api/library_test.go index e3bd8f6c..7333664d 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.requests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil) paths := []string{ "/api/artists", @@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) { FileSize: 1, FileFormat: "mp3", }) if _, err := pool.Exec(context.Background(), - `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + `INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, user.ID, track.ID, ); err != nil { t.Fatalf("quarantine insert: %v", err) diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go deleted file mode 100644 index 5f0a71dc..00000000 --- a/internal/api/lidarr_test.go +++ /dev/null @@ -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 -} diff --git a/internal/api/me_history_test.go b/internal/api/me_history_test.go index 96e224c0..57bcfeb7 100644 --- a/internal/api/me_history_test.go +++ b/internal/api/me_history_test.go @@ -65,7 +65,7 @@ func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, start func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) { t.Helper() _, err := pool.Exec(context.Background(), - `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, + `INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`, userID, trackID, ) if err != nil { diff --git a/internal/api/notify_producers_test.go b/internal/api/notify_producers_test.go index d5f7d0e7..5994444d 100644 --- a/internal/api/notify_producers_test.go +++ b/internal/api/notify_producers_test.go @@ -53,27 +53,9 @@ func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications } } -// newApprovingLidarrStub answers every Lidarr call an approval makes. -func newApprovingLidarrStub(t *testing.T) *httptest.Server { - t.Helper() - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case strings.Contains(r.URL.Path, "/metadataprofile"): - _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`)) - case strings.Contains(r.URL.Path, "/qualityprofile"): - _, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`)) - default: - _, _ = w.Write([]byte(`{"id":1}`)) - } - })) - t.Cleanup(stub.Close) - return stub -} - func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "np-alice", "pw", false) admin := seedUser(t, pool, "np-admin", "pw", true) @@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) { } func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) - saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "np-auto", "pw", false) admin := seedUser(t, h.pool, "np-auto-admin", "pw", true) @@ -123,9 +104,8 @@ func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) { } func TestNotify_DecisionsReachTheRequester(t *testing.T) { - h, _ := testHandlersWithClientFn(t) - resetLidarrState(t, h) - saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true) + h, _ := testHandlers(t) + resetRequests(t, h) alice := seedUser(t, h.pool, "np-dec-alice", "pw", false) admin := seedUser(t, h.pool, "np-dec-admin", "pw", true) diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go index c5df1b22..de1332db 100644 --- a/internal/api/quarantine.go +++ b/internal/api/quarantine.go @@ -8,8 +8,8 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/notifications" + "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine" ) // quarantineView is the JSON shape returned by the user-facing endpoints @@ -22,7 +22,7 @@ type quarantineView struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } -func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView { +func quarantineViewFrom(row dbq.Quarantine) quarantineView { return quarantineView{ UserID: uuidToString(row.UserID), TrackID: uuidToString(row.TrackID), @@ -55,12 +55,12 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) { writeErr(w, apierror.BadRequest("bad_request", "invalid track id")) return } - row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes) + row, err := h.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes) if err != nil { switch { - case errors.Is(err, lidarrquarantine.ErrBadReason): + case errors.Is(err, quarantine.ErrBadReason): writeErr(w, apierror.BadRequest("bad_reason", "invalid reason")) - case errors.Is(err, lidarrquarantine.ErrTrackNotFound): + case errors.Is(err, quarantine.ErrTrackNotFound): writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"}) default: h.logger.Error("api: flag", "err", err) @@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) { if !ok { return } - if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil { - if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) { + if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil { + if errors.Is(err, quarantine.ErrQuarantineNotFound) { writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"}) return } @@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request if !ok { return } - rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID) + rows, err := h.quarantine.ListMine(r.Context(), user.ID) if err != nil { h.logger.Error("api: list mine", "err", err) writeErr(w, apierror.InternalMsg("list failed", err)) @@ -134,10 +134,10 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request out := make([]quarantineMineView, 0, len(rows)) for _, row := range rows { out = append(out, quarantineMineView{ - TrackID: uuidToString(row.LidarrQuarantine.TrackID), - Reason: string(row.LidarrQuarantine.Reason), - Notes: row.LidarrQuarantine.Notes, - CreatedAt: row.LidarrQuarantine.CreatedAt, + TrackID: uuidToString(row.Quarantine.TrackID), + Reason: string(row.Quarantine.Reason), + Notes: row.Quarantine.Notes, + CreatedAt: row.Quarantine.CreatedAt, TrackTitle: row.TrackTitle, TrackDurationMs: row.TrackDurationMs, AlbumID: uuidToString(row.AlbumID), diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go index 112f5d9c..4e72cf96 100644 --- a/internal/api/requests_test.go +++ b/internal/api/requests_test.go @@ -90,7 +90,7 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a // creation (#355 / #376 U2.5): an artist request follows the artist at once. func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) if _, err := pool.Exec(context.Background(), @@ -124,7 +124,7 @@ func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing. // returns 201 with kind=artist and status=pending. func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}` @@ -158,7 +158,7 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) { // kind=album without album_mbid returns 400 with mbid_required. func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) // Missing album_mbid and album_title. @@ -185,7 +185,7 @@ func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) { // with missing track_mbid/track_title returns 400 with mbid_required. func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) // Provides album fields but misses track_mbid and track_title. @@ -212,7 +212,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) { // requests, not bob's. func TestHandleListRequests_OnlyOwnRequests(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) bob := seedUser(t, pool, "bob", "pw", false) @@ -242,7 +242,7 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) { // TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request. func TestHandleGetRequest_OwnReturns200(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist") @@ -264,7 +264,7 @@ func TestHandleGetRequest_OwnReturns200(t *testing.T) { // cannot see another user's request. func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) bob := seedUser(t, pool, "bob", "pw", false) charlie := seedUser(t, pool, "charlie", "pw", false) @@ -293,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) { // user's request. func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) bob := seedUser(t, pool, "bob", "pw", false) admin := seedUser(t, pool, "admin", "pw", true) @@ -318,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) { // request returns 200 with status=rejected. func TestHandleCancelRequest_PendingHappyPath(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me") @@ -340,7 +340,7 @@ func TestHandleCancelRequest_PendingHappyPath(t *testing.T) { // rejected request returns 409 with request_not_pending. func TestHandleCancelRequest_NonPending_409(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice", "pw", false) rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice") @@ -376,7 +376,7 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) { // another user's request returns 409 (not_pending from the ownership miss). func TestHandleCancelRequest_WrongUser_409(t *testing.T) { h, pool := testHandlers(t) - resetLidarrState(t, h) + resetRequests(t, h) alice := seedUser(t, pool, "alice-cancel-owner", "pw", false) bob := seedUser(t, pool, "bob-cancel-other", "pw", false) diff --git a/internal/api/reset_test.go b/internal/api/reset_test.go new file mode 100644 index 00000000..fd4e4c82 --- /dev/null +++ b/internal/api/reset_test.go @@ -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) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index f574daf5..b9e92c5d 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -61,16 +61,6 @@ const ( // log can answer "where did that file go" long after the report is gone. ActionDuplicateMerge Action = "duplicate_merge" - // Lidarr release change (M498 #5437): the duplicate resolver moved an - // album's monitored release in Lidarr to one that lists each song once. - // Written with no actor; the metadata names the album and both releases. - ActionLidarrReleaseChange Action = "lidarr_release_change" - - // Lidarr rip search (M498 #5447): the resolver asked Lidarr to search an - // album whose only copy of a song is a video rip, for a better release. - // Written with no actor; the metadata names the album and how many rips. - ActionLidarrRipSearch Action = "lidarr_rip_search" - // Subsonic password (#5026): generated in Settings for t/s-only clients. ActionSubsonicPasswordSet Action = "subsonic_password_set" ActionSubsonicPasswordClear Action = "subsonic_password_clear" diff --git a/internal/config/config.go b/internal/config/config.go index dd2e4ac8..aec1f643 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,7 +106,7 @@ func Default() Config { Log: LogConfig{Level: "info", Format: "text"}, Branding: BrandingConfig{ AppName: "Minstrel", - Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration.", + Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition.", }, Events: EventsConfig{ SessionTimeoutMinutes: 30, diff --git a/internal/coverart/embedded.go b/internal/coverart/embedded.go index ce00e731..f49ef1a9 100644 --- a/internal/coverart/embedded.go +++ b/internal/coverart/embedded.go @@ -23,7 +23,7 @@ var ErrNoEmbeddedArt = errors.New("coverart: no embedded album art") // usable picture; returns a wrapped error for I/O or tag-parse // failures so callers can distinguish "no art" from "couldn't read". // -// Picard- and Lidarr-tagged libraries typically carry the canonical +// Picard-tagged libraries typically carry the canonical // art in this block already — extracting it beats every external API // guess for accuracy and avoids a network round-trip. func ExtractEmbeddedAlbumArt(path string) ([]byte, error) { diff --git a/internal/coverart/enricher.go b/internal/coverart/enricher.go index cb7dda3e..09e4e3d2 100644 --- a/internal/coverart/enricher.go +++ b/internal/coverart/enricher.go @@ -91,7 +91,7 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error { // Embedded layer: read the picture stored inside the audio file's // tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr). - // Picard/Lidarr-tagged libraries typically carry the canonical art + // Picard-tagged libraries typically carry the canonical art // here, so this beats every external provider for accuracy and // avoids the network round-trip. if row.TrackFilePath != nil && *row.TrackFilePath != "" { diff --git a/internal/db/dbq/admin_summary.sql.go b/internal/db/dbq/admin_summary.sql.go index c8c0ed27..6f682bdc 100644 --- a/internal/db/dbq/admin_summary.sql.go +++ b/internal/db/dbq/admin_summary.sql.go @@ -19,11 +19,11 @@ SELECT -- it, or it failed to play (#5606). Counted by track, as the Reports -- page lists them. (SELECT count(*) FROM ( - SELECT track_id FROM lidarr_quarantine + SELECT track_id FROM quarantine UNION SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports, (SELECT min(at) FROM ( - SELECT created_at AS at FROM lidarr_quarantine + SELECT created_at AS at FROM quarantine UNION ALL SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since, (SELECT count(*) FROM duplicate_groups g diff --git a/internal/db/dbq/audit_log.sql.go b/internal/db/dbq/audit_log.sql.go index 1a69e07b..6c0bf6f4 100644 --- a/internal/db/dbq/audit_log.sql.go +++ b/internal/db/dbq/audit_log.sql.go @@ -84,8 +84,8 @@ type ListAuditLogByActionsParams struct { } // One page of the given actions, newest first: the duplicates report's -// "resolved automatically" activity (M498) reads the resolver's merges and -// Lidarr release changes from here. +// "resolved automatically" activity (M498) reads the resolver's merges from +// here. func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) { rows, err := q.db.Query(ctx, listAuditLogByActions, arg.Actions, diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go index 3689aeba..3c87b264 100644 --- a/internal/db/dbq/discover.sql.go +++ b/internal/db/dbq/discover.sql.go @@ -28,7 +28,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE ml.user_id = $1 AND ml.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) GROUP BY t.id, t.album_id, t.artist_id @@ -108,7 +108,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE gl.user_id = $1 AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -173,7 +173,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE gl.user_id = $1 AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -237,7 +237,7 @@ WITH scored AS ( WHERE gl.user_id = $3 AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $3 AND q.track_id = t.id ) GROUP BY t.id, t.album_id, t.artist_id diff --git a/internal/db/dbq/history.sql.go b/internal/db/dbq/history.sql.go index 32943d28..8c9cd61e 100644 --- a/internal/db/dbq/history.sql.go +++ b/internal/db/dbq/history.sql.go @@ -24,7 +24,7 @@ SELECT pe.id AS event_id, WHERE pe.user_id = $1 AND pe.was_skipped = false AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY pe.started_at DESC diff --git a/internal/db/dbq/lidarr_config.sql.go b/internal/db/dbq/lidarr_config.sql.go deleted file mode 100644 index 0f849d4a..00000000 --- a/internal/db/dbq/lidarr_config.sql.go +++ /dev/null @@ -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 -} diff --git a/internal/db/dbq/merge.sql.go b/internal/db/dbq/merge.sql.go index c73176f4..666795eb 100644 --- a/internal/db/dbq/merge.sql.go +++ b/internal/db/dbq/merge.sql.go @@ -12,11 +12,9 @@ import ( ) const listDuplicateGroupMergeMembers = `-- name: ListDuplicateGroupMergeMembers :many -SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id, - t.mbid, albums.mbid AS album_mbid +SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id FROM duplicate_group_members m - JOIN tracks t ON t.id = m.track_id - JOIN albums ON albums.id = t.album_id + JOIN tracks t ON t.id = m.track_id WHERE m.group_id = $1 ORDER BY t.id ` @@ -28,8 +26,6 @@ type ListDuplicateGroupMergeMembersRow struct { FileSize int64 AddedAt pgtype.Timestamptz AlbumID pgtype.UUID - Mbid *string - AlbumMbid *string } func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupMergeMembersRow, error) { @@ -48,8 +44,6 @@ func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pg &i.FileSize, &i.AddedAt, &i.AlbumID, - &i.Mbid, - &i.AlbumMbid, ); err != nil { return nil, err } diff --git a/internal/db/dbq/missing_pairs.sql.go b/internal/db/dbq/missing_pairs.sql.go index 07042637..d03ce2f0 100644 --- a/internal/db/dbq/missing_pairs.sql.go +++ b/internal/db/dbq/missing_pairs.sql.go @@ -63,8 +63,8 @@ type ListMissingTrackPairsRow struct { } // Missing-pair pass (M485). A track marked missing whose replacement is already -// on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at -// a new path — is folded into that replacement, so its history moves across and +// on disk under another row — most often a quality upgrade, mp3 → flac at a new +// path — is folded into that replacement, so its history moves across and // re-acquisition stops chasing music the library already has. // // Move adoption (#2528) cannot catch these: it links a new file to a missing row diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index abfd23f9..a159c56f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -11,92 +11,92 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type LidarrQuarantineActionKind string +type QuarantineActionKind string const ( - LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved" - LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file" - LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr" + QuarantineActionKindResolved QuarantineActionKind = "resolved" + QuarantineActionKindDeletedFile QuarantineActionKind = "deleted_file" + QuarantineActionKindDeletedAlbum QuarantineActionKind = "deleted_album" ) -func (e *LidarrQuarantineActionKind) Scan(src interface{}) error { +func (e *QuarantineActionKind) Scan(src interface{}) error { switch s := src.(type) { case []byte: - *e = LidarrQuarantineActionKind(s) + *e = QuarantineActionKind(s) case string: - *e = LidarrQuarantineActionKind(s) + *e = QuarantineActionKind(s) default: - return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src) + return fmt.Errorf("unsupported scan type for QuarantineActionKind: %T", src) } return nil } -type NullLidarrQuarantineActionKind struct { - LidarrQuarantineActionKind LidarrQuarantineActionKind - Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL +type NullQuarantineActionKind struct { + QuarantineActionKind QuarantineActionKind + Valid bool // Valid is true if QuarantineActionKind is not NULL } // Scan implements the Scanner interface. -func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error { +func (ns *NullQuarantineActionKind) Scan(value interface{}) error { if value == nil { - ns.LidarrQuarantineActionKind, ns.Valid = "", false + ns.QuarantineActionKind, ns.Valid = "", false return nil } ns.Valid = true - return ns.LidarrQuarantineActionKind.Scan(value) + return ns.QuarantineActionKind.Scan(value) } // Value implements the driver Valuer interface. -func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) { +func (ns NullQuarantineActionKind) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } - return string(ns.LidarrQuarantineActionKind), nil + return string(ns.QuarantineActionKind), nil } -type LidarrQuarantineReason string +type QuarantineReason string const ( - LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip" - LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file" - LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags" - LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate" - LidarrQuarantineReasonOther LidarrQuarantineReason = "other" + QuarantineReasonBadRip QuarantineReason = "bad_rip" + QuarantineReasonWrongFile QuarantineReason = "wrong_file" + QuarantineReasonWrongTags QuarantineReason = "wrong_tags" + QuarantineReasonDuplicate QuarantineReason = "duplicate" + QuarantineReasonOther QuarantineReason = "other" ) -func (e *LidarrQuarantineReason) Scan(src interface{}) error { +func (e *QuarantineReason) Scan(src interface{}) error { switch s := src.(type) { case []byte: - *e = LidarrQuarantineReason(s) + *e = QuarantineReason(s) case string: - *e = LidarrQuarantineReason(s) + *e = QuarantineReason(s) default: - return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src) + return fmt.Errorf("unsupported scan type for QuarantineReason: %T", src) } return nil } -type NullLidarrQuarantineReason struct { - LidarrQuarantineReason LidarrQuarantineReason - Valid bool // Valid is true if LidarrQuarantineReason is not NULL +type NullQuarantineReason struct { + QuarantineReason QuarantineReason + Valid bool // Valid is true if QuarantineReason is not NULL } // Scan implements the Scanner interface. -func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error { +func (ns *NullQuarantineReason) Scan(value interface{}) error { if value == nil { - ns.LidarrQuarantineReason, ns.Valid = "", false + ns.QuarantineReason, ns.Valid = "", false return nil } ns.Valid = true - return ns.LidarrQuarantineReason.Scan(value) + return ns.QuarantineReason.Scan(value) } // Value implements the driver Valuer interface. -func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) { +func (ns NullQuarantineReason) Value() (driver.Value, error) { if !ns.Valid { return nil, nil } - return string(ns.LidarrQuarantineReason), nil + return string(ns.QuarantineReason), nil } type RequestKind string @@ -506,39 +506,6 @@ type LibraryChange struct { ChangedAt pgtype.Timestamptz } -type LidarrConfig struct { - ID int16 - Enabled bool - BaseUrl *string - ApiKey *string - DefaultQualityProfileID *int32 - DefaultRootFolderPath *string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz - DefaultMetadataProfileID *int32 -} - -type LidarrQuarantine struct { - UserID pgtype.UUID - TrackID pgtype.UUID - Reason LidarrQuarantineReason - Notes *string - CreatedAt pgtype.Timestamptz -} - -type LidarrQuarantineAction struct { - ID pgtype.UUID - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle *string - Action LidarrQuarantineActionKind - AdminID pgtype.UUID - LidarrAlbumMbid *string - AffectedUsers int32 - CreatedAt pgtype.Timestamptz -} - type ListenbrainzSimilarRecording struct { SeedTrackID pgtype.UUID RecordingMbid string @@ -696,6 +663,27 @@ type PlaylistTrack struct { PickKind *string } +type Quarantine struct { + UserID pgtype.UUID + TrackID pgtype.UUID + Reason QuarantineReason + Notes *string + CreatedAt pgtype.Timestamptz +} + +type QuarantineAction struct { + ID pgtype.UUID + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action QuarantineActionKind + AdminID pgtype.UUID + AlbumMbid *string + AffectedUsers int32 + CreatedAt pgtype.Timestamptz +} + type ReacquisitionSetting struct { ID bool Enabled bool diff --git a/internal/db/dbq/lidarr_quarantine.sql.go b/internal/db/dbq/quarantine.sql.go similarity index 81% rename from internal/db/dbq/lidarr_quarantine.sql.go rename to internal/db/dbq/quarantine.sql.go index eac2ac6d..2e814f34 100644 --- a/internal/db/dbq/lidarr_quarantine.sql.go +++ b/internal/db/dbq/quarantine.sql.go @@ -1,7 +1,7 @@ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.31.1 -// source: lidarr_quarantine.sql +// source: quarantine.sql package dbq @@ -12,7 +12,7 @@ import ( ) const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one -SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1 +SELECT count(*)::int FROM quarantine WHERE track_id = $1 ` // Reads affected_users for the audit row before the delete fires. @@ -24,7 +24,7 @@ func (q *Queries) CountQuarantineForTrack(ctx context.Context, trackID pgtype.UU } const deleteQuarantine = `-- name: DeleteQuarantine :one -DELETE FROM lidarr_quarantine +DELETE FROM quarantine WHERE user_id = $1 AND track_id = $2 RETURNING user_id, track_id, reason, notes, created_at ` @@ -36,9 +36,9 @@ type DeleteQuarantineParams struct { // Removes the caller's row. Returns the deleted row so the handler can // distinguish "no row existed" (zero rows -> ErrNoRows) from success. -func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (LidarrQuarantine, error) { +func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (Quarantine, error) { row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID) - var i LidarrQuarantine + var i Quarantine err := row.Scan( &i.UserID, &i.TrackID, @@ -50,7 +50,7 @@ func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantinePara } const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec -DELETE FROM lidarr_quarantine WHERE track_id = $1 +DELETE FROM quarantine WHERE track_id = $1 ` // Clears all per-user rows for a given track. Used by Resolve and the @@ -68,26 +68,24 @@ SELECT ar.name AS artist_name, al.title AS album_title, al.id AS album_id, - al.mbid AS lidarr_album_mbid, count(q.user_id)::int AS report_count, max(q.created_at)::timestamptz AS latest_at -FROM lidarr_quarantine q +FROM quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id -GROUP BY t.id, ar.name, al.title, al.id, al.mbid +GROUP BY t.id, ar.name, al.title, al.id ORDER BY max(q.created_at) DESC ` type ListAdminQuarantineQueueRow struct { - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle string - AlbumID pgtype.UUID - LidarrAlbumMbid *string - ReportCount int32 - LatestAt pgtype.Timestamptz + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle string + AlbumID pgtype.UUID + ReportCount int32 + LatestAt pgtype.Timestamptz } // Aggregated admin queue. One row per track. The handler post-processes @@ -108,7 +106,6 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar &i.ArtistName, &i.AlbumTitle, &i.AlbumID, - &i.LidarrAlbumMbid, &i.ReportCount, &i.LatestAt, ); err != nil { @@ -123,20 +120,20 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar } const listQuarantineActions = `-- name: ListQuarantineActions :many -SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at FROM lidarr_quarantine_actions +SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at FROM quarantine_actions ORDER BY created_at DESC LIMIT $1 ` -func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]LidarrQuarantineAction, error) { +func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]QuarantineAction, error) { rows, err := q.db.Query(ctx, listQuarantineActions, limit) if err != nil { return nil, err } defer rows.Close() - var items []LidarrQuarantineAction + var items []QuarantineAction for rows.Next() { - var i LidarrQuarantineAction + var i QuarantineAction if err := rows.Scan( &i.ID, &i.TrackID, @@ -145,7 +142,7 @@ func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]Lid &i.AlbumTitle, &i.Action, &i.AdminID, - &i.LidarrAlbumMbid, + &i.AlbumMbid, &i.AffectedUsers, &i.CreatedAt, ); err != nil { @@ -170,7 +167,7 @@ SELECT al.cover_art_path AS album_cover_art_path, ar.id AS artist_id, ar.name AS artist_name -FROM lidarr_quarantine q +FROM quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id @@ -179,7 +176,7 @@ ORDER BY q.created_at DESC ` type ListQuarantineForUserRow struct { - LidarrQuarantine LidarrQuarantine + Quarantine Quarantine TrackIDJoin pgtype.UUID TrackTitle string TrackDurationMs int32 @@ -204,11 +201,11 @@ func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID) for rows.Next() { var i ListQuarantineForUserRow if err := rows.Scan( - &i.LidarrQuarantine.UserID, - &i.LidarrQuarantine.TrackID, - &i.LidarrQuarantine.Reason, - &i.LidarrQuarantine.Notes, - &i.LidarrQuarantine.CreatedAt, + &i.Quarantine.UserID, + &i.Quarantine.TrackID, + &i.Quarantine.Reason, + &i.Quarantine.Notes, + &i.Quarantine.CreatedAt, &i.TrackIDJoin, &i.TrackTitle, &i.TrackDurationMs, @@ -235,7 +232,7 @@ SELECT q.reason, q.notes, q.created_at -FROM lidarr_quarantine q +FROM quarantine q JOIN users u ON u.id = q.user_id WHERE q.track_id = $1 ORDER BY q.created_at DESC @@ -244,7 +241,7 @@ ORDER BY q.created_at DESC type ListQuarantineReportsForTrackRow struct { UserID pgtype.UUID Username string - Reason LidarrQuarantineReason + Reason QuarantineReason Notes *string CreatedAt pgtype.Timestamptz } @@ -278,7 +275,7 @@ func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgt } const upsertQuarantine = `-- name: UpsertQuarantine :one -INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +INSERT INTO quarantine (user_id, track_id, reason, notes) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, track_id) DO UPDATE SET reason = EXCLUDED.reason, @@ -290,20 +287,20 @@ RETURNING user_id, track_id, reason, notes, created_at type UpsertQuarantineParams struct { UserID pgtype.UUID TrackID pgtype.UUID - Reason LidarrQuarantineReason + Reason QuarantineReason Notes *string } // Insert a new quarantine row, or update reason/notes if the user has // already flagged this track. -func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (LidarrQuarantine, error) { +func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (Quarantine, error) { row := q.db.QueryRow(ctx, upsertQuarantine, arg.UserID, arg.TrackID, arg.Reason, arg.Notes, ) - var i LidarrQuarantine + var i Quarantine err := row.Scan( &i.UserID, &i.TrackID, @@ -315,26 +312,25 @@ func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantinePara } const writeQuarantineAction = `-- name: WriteQuarantineAction :one -INSERT INTO lidarr_quarantine_actions ( +INSERT INTO quarantine_actions ( track_id, track_title, artist_name, album_title, - action, admin_id, lidarr_album_mbid, affected_users -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at + action, admin_id, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at ` type WriteQuarantineActionParams struct { - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle *string - Action LidarrQuarantineActionKind - AdminID pgtype.UUID - LidarrAlbumMbid *string - AffectedUsers int32 + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action QuarantineActionKind + AdminID pgtype.UUID + AffectedUsers int32 } // Audit row for an admin destructive action. -func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (LidarrQuarantineAction, error) { +func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (QuarantineAction, error) { row := q.db.QueryRow(ctx, writeQuarantineAction, arg.TrackID, arg.TrackTitle, @@ -342,10 +338,9 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine arg.AlbumTitle, arg.Action, arg.AdminID, - arg.LidarrAlbumMbid, arg.AffectedUsers, ) - var i LidarrQuarantineAction + var i QuarantineAction err := row.Scan( &i.ID, &i.TrackID, @@ -354,7 +349,7 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine &i.AlbumTitle, &i.Action, &i.AdminID, - &i.LidarrAlbumMbid, + &i.AlbumMbid, &i.AffectedUsers, &i.CreatedAt, ) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 9e1687b6..09bd0b99 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -218,7 +218,7 @@ JOIN artists ON artists.id = t.artist_id WHERE t.artist_id = $1 AND t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = t.id ) ORDER BY p.cnt DESC, t.id @@ -304,7 +304,7 @@ JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY p.cnt DESC, t.id @@ -728,7 +728,7 @@ WHERE t.id <> $2 AND started_at > now() - $3 * interval '1 hour' ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ` @@ -827,7 +827,7 @@ excluded_ids AS ( WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' UNION ALL SELECT q.track_id AS id - FROM lidarr_quarantine q + FROM quarantine q WHERE q.user_id = $1 ), lb_similar AS ( diff --git a/internal/db/dbq/system_mixes.sql.go b/internal/db/dbq/system_mixes.sql.go index d8046f1b..da852842 100644 --- a/internal/db/dbq/system_mixes.sql.go +++ b/internal/db/dbq/system_mixes.sql.go @@ -43,7 +43,7 @@ SELECT t.id, t.album_id, t.artist_id AND COALESCE(pc.c, 0) <= 2 AND COALESCE(sc.c, 0) < 2 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -129,7 +129,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier AND alt.tier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY @@ -229,7 +229,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier JOIN albums_tiered alt ON alt.album_id = t.album_id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY alt.tier, alt.created_at DESC, t.album_id, @@ -308,7 +308,7 @@ SELECT t.id, t.album_id, t.artist_id JOIN windowed w ON w.track_id = t.id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY w.c DESC, md5(t.id::text || $2::text) @@ -375,7 +375,7 @@ SELECT t.id, t.album_id, t.artist_id AND s.c >= 3 AND s.last_at <= now() - interval '30 days' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) -- Declined-resurfacing cooldown (#1258): a skip on a diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go index 473af444..28ac1866 100644 --- a/internal/db/dbq/taste_profile.sql.go +++ b/internal/db/dbq/taste_profile.sql.go @@ -197,7 +197,7 @@ JOIN albums a ON a.id = t.album_id WHERE pe.user_id = $1 AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ` diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index a26f2f9a..1280fbda 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -46,8 +46,8 @@ UPDATE tracks AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text ` -// A held-back track whose name no longer carries a marker (renamed, retagged -// by Lidarr) is released again. +// A held-back track whose name no longer carries a marker (renamed or +// retagged) is released again. func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) { result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern) if err != nil { @@ -130,7 +130,7 @@ const countTracksMatching = `-- name: CountTracksMatching :one SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ) ` @@ -162,8 +162,8 @@ type DeleteTrackRow struct { } // M7 #372: hard delete with FK cascade. The CASCADE on track_id from -// play_events / general_likes_tracks / lidarr_quarantine / -// lidarr_quarantine_actions handles their cleanup. RETURNING gives us +// play_events / general_likes_tracks / quarantine / +// quarantine_actions handles their cleanup. RETURNING gives us // album_id + artist_id for the album-empty / artist-empty cascade // checks the service does next. func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) { @@ -414,7 +414,7 @@ JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE t.artist_id = $1 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = t.id ) ORDER BY albums.release_date NULLS LAST, albums.sort_title, @@ -433,7 +433,7 @@ type ListArtistTracksForUserRow struct { } // M6a: every track for the artist across their albums, with album_title -// and artist_name joined. Honors per-user lidarr_quarantine. Used by +// and artist_name joined. Honors per-user quarantine. Used by // /api/artists/{id}/tracks for the artist-card play affordance, which // shuffles client-side. Ordering matches album/track natural order so // the shuffle has a deterministic input. @@ -578,7 +578,7 @@ FROM tracks t JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY random() @@ -786,7 +786,7 @@ const listTracksByAlbum = `-- name: ListTracksByAlbum :many SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE album_id = $1 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ) ORDER BY disc_number NULLS LAST, track_number NULLS LAST @@ -910,7 +910,7 @@ const searchTracks = `-- name: SearchTracks :many SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ) ORDER BY title diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 21b6bcac..2dd5632f 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -205,7 +205,7 @@ DELETE FROM users WHERE id = $1 // Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys // handle plays/likes/sessions/etc. Caller must enforce the last-admin // guard at the HTTP layer (count admins, refuse if target is the only -// admin). The lidarr_quarantine, general_likes*, play_events, +// admin). The quarantine, general_likes*, play_events, // sessions, and other user-FK tables all have ON DELETE CASCADE so // this is a clean drop. func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { diff --git a/internal/db/migrations/0091_remove_lidarr.down.sql b/internal/db/migrations/0091_remove_lidarr.down.sql new file mode 100644 index 00000000..dceb707f --- /dev/null +++ b/internal/db/migrations/0091_remove_lidarr.down.sql @@ -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); diff --git a/internal/db/migrations/0091_remove_lidarr.up.sql b/internal/db/migrations/0091_remove_lidarr.up.sql new file mode 100644 index 00000000..6d41e6fc --- /dev/null +++ b/internal/db/migrations/0091_remove_lidarr.up.sql @@ -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; diff --git a/internal/db/queries/admin_summary.sql b/internal/db/queries/admin_summary.sql index 14ff1f24..97d91f86 100644 --- a/internal/db/queries/admin_summary.sql +++ b/internal/db/queries/admin_summary.sql @@ -11,11 +11,11 @@ SELECT -- it, or it failed to play (#5606). Counted by track, as the Reports -- page lists them. (SELECT count(*) FROM ( - SELECT track_id FROM lidarr_quarantine + SELECT track_id FROM quarantine UNION SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports, (SELECT min(at) FROM ( - SELECT created_at AS at FROM lidarr_quarantine + SELECT created_at AS at FROM quarantine UNION ALL SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since, (SELECT count(*) FROM duplicate_groups g diff --git a/internal/db/queries/audit_log.sql b/internal/db/queries/audit_log.sql index 153a5cb6..6322ec70 100644 --- a/internal/db/queries/audit_log.sql +++ b/internal/db/queries/audit_log.sql @@ -15,8 +15,8 @@ SELECT count(*)::bigint FROM audit_log -- name: ListAuditLogByActions :many -- One page of the given actions, newest first: the duplicates report's --- "resolved automatically" activity (M498) reads the resolver's merges and --- Lidarr release changes from here. +-- "resolved automatically" activity (M498) reads the resolver's merges from +-- here. SELECT * FROM audit_log WHERE action = ANY(sqlc.arg(actions)::text[]) AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL) diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql index 2c297610..ac9e057f 100644 --- a/internal/db/queries/discover.sql +++ b/internal/db/queries/discover.sql @@ -41,7 +41,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE gl.user_id = $1 AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -74,7 +74,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE ml.user_id = $1 AND ml.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) GROUP BY t.id, t.album_id, t.artist_id @@ -100,7 +100,7 @@ SELECT t.id, t.album_id, t.artist_id WHERE gl.user_id = $1 AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -144,7 +144,7 @@ WITH scored AS ( WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id ) GROUP BY t.id, t.album_id, t.artist_id diff --git a/internal/db/queries/history.sql b/internal/db/queries/history.sql index dd6176e4..bd56e828 100644 --- a/internal/db/queries/history.sql +++ b/internal/db/queries/history.sql @@ -16,7 +16,7 @@ SELECT pe.id AS event_id, WHERE pe.user_id = $1 AND pe.was_skipped = false AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY pe.started_at DESC diff --git a/internal/db/queries/lidarr_config.sql b/internal/db/queries/lidarr_config.sql deleted file mode 100644 index 5b31c633..00000000 --- a/internal/db/queries/lidarr_config.sql +++ /dev/null @@ -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; diff --git a/internal/db/queries/merge.sql b/internal/db/queries/merge.sql index 4ea902c6..914f81fd 100644 --- a/internal/db/queries/merge.sql +++ b/internal/db/queries/merge.sql @@ -13,11 +13,9 @@ SELECT id, tier, status FOR UPDATE; -- name: ListDuplicateGroupMergeMembers :many -SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id, - t.mbid, albums.mbid AS album_mbid +SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id FROM duplicate_group_members m - JOIN tracks t ON t.id = m.track_id - JOIN albums ON albums.id = t.album_id + JOIN tracks t ON t.id = m.track_id WHERE m.group_id = sqlc.arg(group_id) ORDER BY t.id; diff --git a/internal/db/queries/missing_pairs.sql b/internal/db/queries/missing_pairs.sql index 322a5dfb..ef8e3eb0 100644 --- a/internal/db/queries/missing_pairs.sql +++ b/internal/db/queries/missing_pairs.sql @@ -1,6 +1,6 @@ -- Missing-pair pass (M485). A track marked missing whose replacement is already --- on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at --- a new path — is folded into that replacement, so its history moves across and +-- on disk under another row — most often a quality upgrade, mp3 → flac at a new +-- path — is folded into that replacement, so its history moves across and -- re-acquisition stops chasing music the library already has. -- -- Move adoption (#2528) cannot catch these: it links a new file to a missing row diff --git a/internal/db/queries/lidarr_quarantine.sql b/internal/db/queries/quarantine.sql similarity index 84% rename from internal/db/queries/lidarr_quarantine.sql rename to internal/db/queries/quarantine.sql index 8c43fc9c..85cb8c92 100644 --- a/internal/db/queries/lidarr_quarantine.sql +++ b/internal/db/queries/quarantine.sql @@ -1,7 +1,7 @@ -- name: UpsertQuarantine :one -- Insert a new quarantine row, or update reason/notes if the user has -- already flagged this track. -INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +INSERT INTO quarantine (user_id, track_id, reason, notes) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, track_id) DO UPDATE SET reason = EXCLUDED.reason, @@ -12,7 +12,7 @@ RETURNING user_id, track_id, reason, notes, created_at; -- name: DeleteQuarantine :one -- Removes the caller's row. Returns the deleted row so the handler can -- distinguish "no row existed" (zero rows -> ErrNoRows) from success. -DELETE FROM lidarr_quarantine +DELETE FROM quarantine WHERE user_id = $1 AND track_id = $2 RETURNING user_id, track_id, reason, notes, created_at; @@ -31,7 +31,7 @@ SELECT al.cover_art_path AS album_cover_art_path, ar.id AS artist_id, ar.name AS artist_name -FROM lidarr_quarantine q +FROM quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id @@ -48,14 +48,13 @@ SELECT ar.name AS artist_name, al.title AS album_title, al.id AS album_id, - al.mbid AS lidarr_album_mbid, count(q.user_id)::int AS report_count, max(q.created_at)::timestamptz AS latest_at -FROM lidarr_quarantine q +FROM quarantine q JOIN tracks t ON t.id = q.track_id JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id -GROUP BY t.id, ar.name, al.title, al.id, al.mbid +GROUP BY t.id, ar.name, al.title, al.id ORDER BY max(q.created_at) DESC; -- name: ListQuarantineReportsForTrack :many @@ -67,7 +66,7 @@ SELECT q.reason, q.notes, q.created_at -FROM lidarr_quarantine q +FROM quarantine q JOIN users u ON u.id = q.user_id WHERE q.track_id = $1 ORDER BY q.created_at DESC; @@ -76,21 +75,21 @@ ORDER BY q.created_at DESC; -- Clears all per-user rows for a given track. Used by Resolve and the -- two delete actions. Caller writes the audit row separately before -- this fires (so we can capture the affected_users count). -DELETE FROM lidarr_quarantine WHERE track_id = $1; +DELETE FROM quarantine WHERE track_id = $1; -- name: CountQuarantineForTrack :one -- Reads affected_users for the audit row before the delete fires. -SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; +SELECT count(*)::int FROM quarantine WHERE track_id = $1; -- name: WriteQuarantineAction :one -- Audit row for an admin destructive action. -INSERT INTO lidarr_quarantine_actions ( +INSERT INTO quarantine_actions ( track_id, track_title, artist_name, album_title, - action, admin_id, lidarr_album_mbid, affected_users -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + action, admin_id, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: ListQuarantineActions :many -SELECT * FROM lidarr_quarantine_actions +SELECT * FROM quarantine_actions ORDER BY created_at DESC LIMIT $1; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index f4a6e63c..3fd2a34e 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -31,7 +31,7 @@ WHERE t.id <> $2 AND started_at > now() - $3 * interval '1 hour' ) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ); @@ -83,7 +83,7 @@ excluded_ids AS ( WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour' UNION ALL SELECT q.track_id AS id - FROM lidarr_quarantine q + FROM quarantine q WHERE q.user_id = $1 ), lb_similar AS ( @@ -416,7 +416,7 @@ JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY p.cnt DESC, t.id @@ -443,7 +443,7 @@ JOIN artists ON artists.id = t.artist_id WHERE t.artist_id = sqlc.arg(artist_id) AND t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id ) ORDER BY p.cnt DESC, t.id diff --git a/internal/db/queries/system_mixes.sql b/internal/db/queries/system_mixes.sql index 6469ae06..bd5126da 100644 --- a/internal/db/queries/system_mixes.sql +++ b/internal/db/queries/system_mixes.sql @@ -44,7 +44,7 @@ SELECT t.id, t.album_id, t.artist_id AND COALESCE(pc.c, 0) <= 2 AND COALESCE(sc.c, 0) < 2 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY md5(t.id::text || $2::text) @@ -75,7 +75,7 @@ SELECT t.id, t.album_id, t.artist_id AND s.c >= 3 AND s.last_at <= now() - interval '30 days' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) -- Declined-resurfacing cooldown (#1258): a skip on a @@ -153,7 +153,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier JOIN albums_tiered alt ON alt.album_id = t.album_id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY alt.tier, alt.created_at DESC, t.album_id, @@ -192,7 +192,7 @@ SELECT t.id, t.album_id, t.artist_id JOIN windowed w ON w.track_id = t.id WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY w.c DESC, md5(t.id::text || $2::text) @@ -248,7 +248,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier AND alt.tier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id) AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql index d9ddb987..77746987 100644 --- a/internal/db/queries/taste_profile.sql +++ b/internal/db/queries/taste_profile.sql @@ -24,7 +24,7 @@ JOIN albums a ON a.id = t.album_id WHERE pe.user_id = $1 AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ); diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index df0a7c91..275125ca 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -62,7 +62,7 @@ SELECT * FROM tracks WHERE file_path = $1; SELECT * FROM tracks WHERE album_id = $1 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ) ORDER BY disc_number NULLS LAST, track_number NULLS LAST; @@ -76,7 +76,7 @@ SELECT count(*) FROM tracks WHERE album_id = $1; SELECT * FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ) ORDER BY title @@ -87,13 +87,13 @@ LIMIT $3 OFFSET $4; SELECT COUNT(*) FROM tracks WHERE title ILIKE '%' || $1::text || '%' AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = tracks.id ); -- name: ListArtistTracksForUser :many -- M6a: every track for the artist across their albums, with album_title --- and artist_name joined. Honors per-user lidarr_quarantine. Used by +-- and artist_name joined. Honors per-user quarantine. Used by -- /api/artists/{id}/tracks for the artist-card play affordance, which -- shuffles client-side. Ordering matches album/track natural order so -- the shuffle has a deterministic input. @@ -105,7 +105,7 @@ JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE t.artist_id = $1 AND NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $2 AND q.track_id = t.id ) ORDER BY albums.release_date NULLS LAST, albums.sort_title, @@ -122,7 +122,7 @@ FROM tracks t JOIN albums ON albums.id = t.album_id JOIN artists ON artists.id = t.artist_id WHERE NOT EXISTS ( - SELECT 1 FROM lidarr_quarantine q + SELECT 1 FROM quarantine q WHERE q.user_id = $1 AND q.track_id = t.id ) ORDER BY random() @@ -136,8 +136,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1; -- name: DeleteTrack :one -- M7 #372: hard delete with FK cascade. The CASCADE on track_id from --- play_events / general_likes_tracks / lidarr_quarantine / --- lidarr_quarantine_actions handles their cleanup. RETURNING gives us +-- play_events / general_likes_tracks / quarantine / +-- quarantine_actions handles their cleanup. RETURNING gives us -- album_id + artist_id for the album-empty / artist-empty cascade -- checks the service does next. DELETE FROM tracks WHERE id = $1 @@ -321,8 +321,8 @@ UPDATE tracks AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text; -- name: ClearStaleSuspectSourceTracks :execrows --- A held-back track whose name no longer carries a marker (renamed, retagged --- by Lidarr) is released again. +-- A held-back track whose name no longer carries a marker (renamed or +-- retagged) is released again. UPDATE tracks SET source_verdict = NULL, source_verdict_at = NULL WHERE source_verdict = 'suspect' diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 534fb0d1..a3dd36b3 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -96,7 +96,7 @@ RETURNING *; -- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys -- handle plays/likes/sessions/etc. Caller must enforce the last-admin -- guard at the HTTP layer (count admins, refuse if target is the only --- admin). The lidarr_quarantine, general_likes*, play_events, +-- admin). The quarantine, general_likes*, play_events, -- sessions, and other user-FK tables all have ON DELETE CASCADE so -- this is a clean drop. DELETE FROM users WHERE id = $1; diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index b066fb04..d6cffc58 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -53,8 +53,8 @@ var dataTables = []string{ "skip_events", "play_sessions", "sessions", - "lidarr_quarantine_actions", - "lidarr_quarantine", + "quarantine_actions", + "quarantine", // #2374. Keyed by (user_id, candidate_mbid) with no FK to artists — // candidates are out-of-library — so the CASCADE from artists/users // does NOT reach it for a leftover row whose user survived. Truncate @@ -108,7 +108,7 @@ var dataTables = []string{ "mb_artist_release_groups", // M502 #5519 "mb_release_groups", // M502 #5519 "mb_artists", // M502 #5519 - "requests", // M502 #5520 (was lidarr_requests) + "requests", // M502 #5520 "download_interventions", // M502 #5523 "library_album_matches", // #5569 "release_blocks", // M502 #5521 diff --git a/internal/importer/name.go b/internal/importer/name.go index c9342fef..60bc9f4d 100644 --- a/internal/importer/name.go +++ b/internal/importer/name.go @@ -20,9 +20,8 @@ type Placement struct { Recording string } -// Plan names every file's destination under root the way Lidarr named the -// library Minstrel took over (note #5527), so new arrivals sit beside what -// is there: +// Plan names every file's destination under root the way the library is +// already named (note #5527), so new arrivals sit beside what is there: // // {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext // @@ -83,11 +82,11 @@ const segmentMax = 200 var illegal = strings.NewReplacer( `\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "", - // Lidarr's default "smart" colon: "Title: Subtitle" reads "Title - Subtitle". + // The "smart" colon: "Title: Subtitle" reads "Title - Subtitle". ": ", " - ", ":", "-", ) -// Segment makes a name safe as one path segment, the way Lidarr and Sonarr +// Segment makes a name safe as one path segment, the way the *arr programs // clean file names: their illegal-character mapping, no leading or trailing // spaces or dots, at most segmentMax bytes, never "" or "..". func Segment(s string) string { diff --git a/internal/importer/verify_test.go b/internal/importer/verify_test.go index 94ec335f..82565a79 100644 --- a/internal/importer/verify_test.go +++ b/internal/importer/verify_test.go @@ -161,7 +161,7 @@ func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) { } } -func TestPlan_LidarrsNaming(t *testing.T) { +func TestPlan_LibraryNaming(t *testing.T) { e := edition("rel-cd", 2) e.Tracks[1].Title = "Within: Part 2/3?" pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs) diff --git a/internal/library/duplicate_merge.go b/internal/library/duplicate_merge.go index fc016620..2ba26079 100644 --- a/internal/library/duplicate_merge.go +++ b/internal/library/duplicate_merge.go @@ -27,10 +27,8 @@ var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the // MergedCopy is one copy a merge kept or removed. type MergedCopy struct { - TrackID pgtype.UUID - FilePath string - TrackMbid *string - AlbumMbid *string + TrackID pgtype.UUID + FilePath string } // MergeResult says what a merge did. @@ -296,7 +294,7 @@ func splitSurvivor( } func mergedCopyOf(m dbq.ListDuplicateGroupMergeMembersRow) MergedCopy { - return MergedCopy{TrackID: m.ID, FilePath: m.FilePath, TrackMbid: m.Mbid, AlbumMbid: m.AlbumMbid} + return MergedCopy{TrackID: m.ID, FilePath: m.FilePath} } // mergeChanges collects the sync-log entries a merge owes clients' caches, each diff --git a/internal/library/duplicate_resolve.go b/internal/library/duplicate_resolve.go index 8a81d80d..bc39bc35 100644 --- a/internal/library/duplicate_resolve.go +++ b/internal/library/duplicate_resolve.go @@ -17,7 +17,7 @@ import ( syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) -// Duplicate resolver (M498 #5435, #5437; without Lidarr since M502 #5517). +// Duplicate resolver (M498 #5435, #5437; M502 #5517). // // The sweep proposes duplicate groups; the resolver decides what each one is // and settles the ones that are safe to settle without asking. Minstrel is the diff --git a/internal/library/duplicate_resolve_test.go b/internal/library/duplicate_resolve_test.go index 6f184056..82d52f77 100644 --- a/internal/library/duplicate_resolve_test.go +++ b/internal/library/duplicate_resolve_test.go @@ -93,9 +93,8 @@ func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n in } // One album holding a song twice: the extra copy goes, the copy whose name and -// tags fit the album stays, and the merge is recorded as the resolver's. No -// Lidarr is asked (M502 #5517): Minstrel is the only program writing the -// library. +// tags fit the album stays, and the merge is recorded as the resolver's +// (M502 #5517): Minstrel is the only program writing the library. func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) { f := newResolveFixture(t) res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true) @@ -192,8 +191,7 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture { } // A video rip on one release with the clean copy on another: the rip is no -// release's copy, so it goes and the album cut stays (#5439, now without -// Lidarr). +// release's copy, so it goes and the album cut stays (#5439). func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) { f := newCrossReleaseRipFixture(t) res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true) diff --git a/internal/library/fingerprint_settings.go b/internal/library/fingerprint_settings.go index b451441a..69f94c7d 100644 --- a/internal/library/fingerprint_settings.go +++ b/internal/library/fingerprint_settings.go @@ -56,8 +56,8 @@ type FingerprintSettings struct { BackfillConcurrency int32 SweepIntervalHours int32 // AutoResolve lets the duplicate resolver (M498) act on its own: merge - // copies Lidarr does not need and change a Lidarr release that lists songs - // twice. Off, it only classifies, and every group waits for the operator. + // copies no release needs. Off, it only classifies, and every group waits + // for the operator. AutoResolve bool // UpdatedAt is when the settings were last saved. Set by the database; // ignored by Set. diff --git a/internal/library/mbids.go b/internal/library/mbids.go index e63550b7..f6ef768e 100644 --- a/internal/library/mbids.go +++ b/internal/library/mbids.go @@ -44,7 +44,7 @@ func extractRecordingMBID(m tag.Metadata) string { // extractReleaseGroupMBID reads the MusicBrainz *release-group* ID — // Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as // mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one -// edition. Lidarr names albums by release group, so re-acquisition and +// edition. Wants name albums by release group, so re-acquisition and // request completion key on this one (#5241). Separate from extractMBIDs for // the same reason extractRecordingMBID is. func extractReleaseGroupMBID(m tag.Metadata) string { diff --git a/internal/library/missing_pairs.go b/internal/library/missing_pairs.go index 9d91d624..0764572c 100644 --- a/internal/library/missing_pairs.go +++ b/internal/library/missing_pairs.go @@ -18,8 +18,8 @@ import ( // A track marked missing whose replacement already sits on disk under another // row is folded into that replacement: its likes, plays, playlist entries and // tags move across and the missing row goes. On the operator's library this was -// 355 of 451 missing tracks (#5274), nearly all Lidarr quality upgrades — the -// mp3 removed, a flac imported at a new path. +// 355 of 451 missing tracks (#5274), nearly all quality upgrades — the mp3 +// removed, a flac imported at a new path. // // Move adoption (moved.go) can't catch these. It links a new file to a missing // row when the file is first inserted, matching on the recording MBID or the diff --git a/internal/library/missing_pairs_test.go b/internal/library/missing_pairs_test.go index 01126f9f..a356f995 100644 --- a/internal/library/missing_pairs_test.go +++ b/internal/library/missing_pairs_test.go @@ -231,7 +231,7 @@ func TestPairMissingTracks_Integration(t *testing.T) { } } -// The case that made M485: a Lidarr quality upgrade. The mp3 is removed and a +// The case that made M485: a quality upgrade. The mp3 is removed and a // re-encode of it arrives at a new path with no recording MBID, so move adoption // has nothing to match. The scan must still end with one row, carrying the old // row's history. diff --git a/internal/library/scanner.go b/internal/library/scanner.go index 2c986caa..53b25e9e 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -57,7 +57,7 @@ var audioExtensions = map[string]bool{ // to new files only because taste_profile.sql reads tracks.genre directly, // so a half-repaired library would carry both spellings as separate tags. // 3: the MusicBrainz release-group id is read into albums.release_group_mbid -// (#5241). Lidarr names albums by release group, not by the release id +// (#5241). Wants name albums by release group, not by the release id // albums.mbid holds, so re-acquisition and request completion need it for // the albums already in the library, not only for files added from now on. // 4: every value of a repeated genre field is read for FLAC, Ogg Vorbis, Opus diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go deleted file mode 100644 index 9d90cc51..00000000 --- a/internal/lidarr/client.go +++ /dev/null @@ -1,511 +0,0 @@ -package lidarr - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance -// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings. -type Client struct { - BaseURL string - APIKey string - HTTP *http.Client -} - -// NewClient builds a Client with sensible HTTP defaults (30s timeout, -// matching the pattern in internal/scrobble/listenbrainz). Call sites -// that need a custom client can construct the Client struct directly. -func NewClient(baseURL, apiKey string) *Client { - return &Client{ - BaseURL: baseURL, - APIKey: apiKey, - HTTP: &http.Client{Timeout: 30 * time.Second}, - } -} - -// get executes an authenticated GET request and maps HTTP status codes to -// typed errors. The caller is responsible for closing the returned body. -func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = strings.TrimRight(u.Path, "/") + path - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - body := readBodySnippet(resp.Body) - _ = resp.Body.Close() - return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body) - } - if resp.StatusCode >= 400 { - body := readBodySnippet(resp.Body) - _ = resp.Body.Close() - return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body) - } - return resp, nil -} - -// readBodySnippet reads up to 512 bytes from an error response body so the -// wrapped error message can include Lidarr's actual complaint (e.g. -// `{"errorMessage":"Artist already exists in your library"}`). Without this, -// callers see only the status-code bucket and can't diagnose without -// cross-referencing Lidarr's own logs. -func readBodySnippet(r io.Reader) string { - buf, _ := io.ReadAll(io.LimitReader(r, 512)) - return strings.TrimSpace(string(buf)) -} - -// post is the shared POST helper. body must be marshaled JSON. It maps HTTP -// status codes to typed errors; the caller is responsible for closing the -// returned body. -func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) { - return c.bodyRequest(ctx, http.MethodPost, path, body) -} - -// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() — -// same status-code mapping, same caller-closes-body contract. -func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) { - return c.bodyRequest(ctx, http.MethodPut, path, body) -} - -// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle -// is PUT-shaped, so factoring it out keeps the post/put helpers from -// duplicating the status-code mapping. -func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = strings.TrimRight(u.Path, "/") + path - req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - body := readBodySnippet(resp.Body) - _ = resp.Body.Close() - return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body) - } - if resp.StatusCode >= 400 { - body := readBodySnippet(resp.Body) - _ = resp.Body.Close() - return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body) - } - return resp, nil -} - -// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized -// LookupResults; Secondary is "{first genre} · {albumCount} albums" with -// missing parts skipped. -func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignArtistID string `json:"foreignArtistId"` - ArtistName string `json:"artistName"` - Genres []string `json:"genres"` - AlbumCount int `json:"albumCount"` - Images []lidarrImage `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := "" - if len(r.Genres) > 0 { - secondary = r.Genres[0] - } - if r.AlbumCount > 0 { - if secondary != "" { - secondary += " · " - } - secondary += strconv.Itoa(r.AlbumCount) + " albums" - } - out = append(out, LookupResult{ - MBID: r.ForeignArtistID, - Name: r.ArtistName, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized -// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks". -func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - ArtistName string `json:"artistName"` - ReleaseDate string `json:"releaseDate"` - TrackCount int `json:"trackCount"` - Images []lidarrImage `json:"images"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - year := "" - if len(r.ReleaseDate) >= 4 { - year = r.ReleaseDate[:4] - } - secondary := "" - if r.ArtistName != "" { - secondary = r.ArtistName - } - if year != "" { - if secondary != "" { - secondary += " · " - } - secondary += year - } - if r.TrackCount > 0 { - if secondary != "" { - secondary += " · " - } - secondary += strconv.Itoa(r.TrackCount) + " tracks" - } - out = append(out, LookupResult{ - MBID: r.ForeignAlbumID, - ArtistMBID: r.ForeignArtistID, - Name: r.Title, - Secondary: secondary, - ImageURL: pickPosterImage(r.Images), - }) - } - return out, nil -} - -// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is -// per-album under the hood. Secondary is "{albumTitle} · {artistName}". -func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) { - resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}}) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ForeignTrackID string `json:"foreignTrackId"` - ForeignAlbumID string `json:"foreignAlbumId"` - ForeignArtistID string `json:"foreignArtistId"` - Title string `json:"title"` - AlbumTitle string `json:"albumTitle"` - ArtistName string `json:"artistName"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]LookupResult, 0, len(raw)) - for _, r := range raw { - secondary := r.AlbumTitle - if r.ArtistName != "" { - if secondary != "" { - secondary += " · " - } - secondary += r.ArtistName - } - out = append(out, LookupResult{ - MBID: r.ForeignTrackID, - ArtistMBID: r.ForeignArtistID, - AlbumMBID: r.ForeignAlbumID, - Name: r.Title, - Secondary: secondary, - }) - } - return out, nil -} - -// AddArtist posts to POST /api/v1/artist. MonitorAll=true monitors every -// album ("all"); false monitors only releases from now on ("future"). New -// releases are monitored either way. Returns nil on 2xx; typed error -// otherwise. -// -// All four of artistName, foreignArtistId, qualityProfileId, and -// metadataProfileId are required by Lidarr. Omitting any one produces a -// 400 with field-level validation messages (e.g. "'Metadata Profile Id' -// must be greater than '0'"). -// -// The monitor choice belongs in addOptions (AddArtistOptions, a -// MonitoringOptions). ArtistResource has no top-level `monitor`, so a -// choice sent there is dropped, AddOptions.Monitor stays Unknown and -// Lidarr skips applying it (#5239). -func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error { - monitor := "future" - if p.MonitorAll { - monitor = "all" - } - body, err := json.Marshal(map[string]any{ - "foreignArtistId": p.ForeignArtistID, - "artistName": p.ArtistName, - "qualityProfileId": p.QualityProfileID, - "metadataProfileId": p.MetadataProfileID, - "rootFolderPath": p.RootFolderPath, - "monitored": true, - "monitorNewItems": "all", - "addOptions": map[string]any{ - "monitor": monitor, - "monitored": true, - "searchForMissingAlbums": true, - }, - }) - if err != nil { - return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err) - } - resp, err := c.post(ctx, "/api/v1/artist", body) - if err != nil { - if isAlreadyExists(err) { - return ErrAlreadyExists - } - return err - } - _ = resp.Body.Close() - return nil -} - -// isAlreadyExists detects Lidarr's 400 "already in your library" rejection -// from an add endpoint. The post() helper buckets it as ErrLookupFailed -// but embeds Lidarr's errorMessage body snippet; matching the phrase lets -// the idempotent retry path treat a re-add as success instead of error. -func isAlreadyExists(err error) bool { - if !errors.Is(err, ErrLookupFailed) { - return false - } - m := strings.ToLower(err.Error()) - return strings.Contains(m, "already exists") || - strings.Contains(m, "already been added") || - strings.Contains(m, "already in your library") -} - -// AddAlbum adds one album to Lidarr the way Lidarr's own UI does: look the -// album up by MBID, then POST that album resource back with `monitored`, -// `addOptions` and — when Lidarr doesn't have the artist yet — the -// artist's profiles, root folder and monitoring filled in. -// -// POST /api/v1/album validates `artist` as a nested resource (#5234: a -// flat payload is refused with "'Artist' must not be empty"), and the -// lookup is what supplies it: the existing Lidarr artist when there is one, -// otherwise a new one carrying its metadata. A new artist monitors only -// this album (monitor "none" with albumsToMonitor, which Lidarr applies in -// preference to the monitor type) and no future releases, so approving one -// album never subscribes the library to the artist's whole catalogue. -// -// Returns nil on 2xx, ErrAlreadyExists when Lidarr already has the album, -// ErrNotFound when Lidarr's metadata has no album under that MBID. -func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error { - album, err := c.lookupAlbumResource(ctx, p.ForeignAlbumID) - if err != nil { - return err - } - album["monitored"] = true - album["addOptions"] = map[string]any{"searchForNewAlbum": true} - - artist, _ := album["artist"].(map[string]any) - if artist == nil { - artist = map[string]any{ - "foreignArtistId": p.ForeignArtistID, - "artistName": p.ArtistName, - } - album["artist"] = artist - } - if id, _ := artist["id"].(float64); id == 0 { - artist["qualityProfileId"] = p.QualityProfileID - artist["metadataProfileId"] = p.MetadataProfileID - artist["rootFolderPath"] = p.RootFolderPath - artist["monitored"] = true - artist["monitorNewItems"] = "none" - artist["addOptions"] = map[string]any{ - "monitor": "none", - "albumsToMonitor": []string{p.ForeignAlbumID}, - "monitored": true, - "searchForMissingAlbums": false, - } - } - - body, err := json.Marshal(album) - if err != nil { - return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err) - } - resp, err := c.post(ctx, "/api/v1/album", body) - if err != nil { - if isAlreadyExists(err) { - return ErrAlreadyExists - } - return err - } - _ = resp.Body.Close() - return nil -} - -// ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the -// list of metadata profiles configured in Lidarr. The Approve flow uses -// the first profile as a default when the operator hasn't pinned one. -func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) { - resp, err := c.get(ctx, "/api/v1/metadataprofile", nil) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ID int `json:"id"` - Name string `json:"name"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]MetadataProfile, len(raw)) - for i, r := range raw { - out[i] = MetadataProfile{ID: r.ID, Name: r.Name} - } - return out, nil -} - -// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full -// list of quality profiles configured in Lidarr. -func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) { - resp, err := c.get(ctx, "/api/v1/qualityprofile", nil) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - ID int `json:"id"` - Name string `json:"name"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]QualityProfile, len(raw)) - for i, r := range raw { - out[i] = QualityProfile{ID: r.ID, Name: r.Name} - } - return out, nil -} - -// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root -// folders configured in Lidarr. -func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) { - resp, err := c.get(ctx, "/api/v1/rootfolder", nil) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw []struct { - Path string `json:"path"` - Accessible bool `json:"accessible"` - FreeSpace int64 `json:"freeSpace"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - out := make([]RootFolder, len(raw)) - for i, r := range raw { - out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace} - } - return out, nil -} - -// Ping hits GET /api/v1/system/status to verify connectivity and returns -// the Lidarr version string. -func (c *Client) Ping(ctx context.Context) (PingResult, error) { - resp, err := c.get(ctx, "/api/v1/system/status", nil) - if err != nil { - return PingResult{}, err - } - defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) - if err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - var raw struct { - Version string `json:"version"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err) - } - return PingResult{Version: raw.Version}, nil -} - -// pickPosterImage returns the remote URL (preferred) or local URL for the -// first image with coverType=="poster". Returns "" when none match. -func pickPosterImage(imgs []lidarrImage) string { - for _, img := range imgs { - if img.CoverType == "poster" { - if img.RemoteURL != "" { - return img.RemoteURL - } - return img.URL - } - } - return "" -} diff --git a/internal/lidarr/client_test.go b/internal/lidarr/client_test.go deleted file mode 100644 index 43e7b0af..00000000 --- a/internal/lidarr/client_test.go +++ /dev/null @@ -1,892 +0,0 @@ -package lidarr - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -// newTestClient creates an httptest.Server and a Client wired to it. -func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) { - srv := httptest.NewServer(handler) - return &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}, srv -} - -// mustReadFixture reads a file from testdata/ or fatals the test. -func mustReadFixture(t *testing.T, name string) []byte { - t.Helper() - b, err := os.ReadFile("testdata/" + name) - if err != nil { - t.Fatalf("read fixture %q: %v", name, err) - } - return b -} - -// --- LookupArtist --- - -func TestLookupArtist_HappyPath(t *testing.T) { - body := mustReadFixture(t, "lookup_artist.json") - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Api-Key"); got != "key123" { - t.Errorf("X-Api-Key = %q, want key123", got) - } - if r.URL.Path != "/api/v1/artist/lookup" { - t.Errorf("path = %q, want /api/v1/artist/lookup", r.URL.Path) - } - if got := r.URL.Query().Get("term"); got != "boards" { - t.Errorf("term = %q, want boards", got) - } - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupArtist(context.Background(), "boards") - if err != nil { - t.Fatalf("LookupArtist: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - - // First result — rich data. - if got[0].MBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("MBID = %q", got[0].MBID) - } - if got[0].Name != "Boards of Canada" { - t.Errorf("Name = %q", got[0].Name) - } - if got[0].Secondary != "Electronic · 18 albums" { - t.Errorf("Secondary = %q, want %q", got[0].Secondary, "Electronic · 18 albums") - } - if got[0].ImageURL != "https://example.invalid/boc.jpg" { - t.Errorf("ImageURL = %q", got[0].ImageURL) - } - - // Second result — empty genres and zero album count → empty secondary. - if got[1].Secondary != "" { - t.Errorf("expected empty secondary for no genres + 0 albums; got %q", got[1].Secondary) - } - if got[1].ImageURL != "" { - t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL) - } -} - -func TestLookupArtist_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupArtist_Forbidden(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - }) - defer srv.Close() - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed (403 must map to auth)", err) - } -} - -func TestLookupArtist_ServerError(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError", err) - } -} - -func TestLookupArtist_Unreachable(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } -} - -func TestLookupArtist_BadJSON(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`not json`)) - }) - defer srv.Close() - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrInvalidPayload) { - t.Fatalf("err = %v, want ErrInvalidPayload", err) - } -} - -// --- LookupAlbum --- - -func TestLookupAlbum_HappyPath(t *testing.T) { - body := mustReadFixture(t, "lookup_album.json") - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/album/lookup" { - t.Errorf("path = %q, want /api/v1/album/lookup", r.URL.Path) - } - if got := r.URL.Query().Get("term"); got != "geogaddi" { - t.Errorf("term = %q, want geogaddi", got) - } - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupAlbum(context.Background(), "geogaddi") - if err != nil { - t.Fatalf("LookupAlbum: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - - // First result: year from releaseDate, trackCount in secondary. - if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { - t.Errorf("MBID = %q", got[0].MBID) - } - if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID) - } - if got[0].AlbumMBID != "" { - t.Errorf("AlbumMBID = %q, want empty on album result", got[0].AlbumMBID) - } - if got[0].Name != "Music Has the Right to Children" { - t.Errorf("Name = %q", got[0].Name) - } - want0 := "Boards of Canada · 1998 · 18 tracks" - if got[0].Secondary != want0 { - t.Errorf("Secondary = %q, want %q", got[0].Secondary, want0) - } - // Poster image from second image entry. - if got[0].ImageURL != "https://example.invalid/mhtrtc-poster.jpg" { - t.Errorf("ImageURL = %q", got[0].ImageURL) - } - - // Second result. - want1 := "Boards of Canada · 2002 · 23 tracks" - if got[1].Secondary != want1 { - t.Errorf("Secondary = %q, want %q", got[1].Secondary, want1) - } - if got[1].ImageURL != "" { - t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL) - } -} - -func TestLookupAlbum_BadJSON(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{}`)) - }) - defer srv.Close() - _, err := c.LookupAlbum(context.Background(), "x") - if !errors.Is(err, ErrInvalidPayload) { - t.Fatalf("err = %v, want ErrInvalidPayload (object, not array)", err) - } -} - -// --- LookupTrack --- - -func TestLookupTrack_HappyPath(t *testing.T) { - body := mustReadFixture(t, "lookup_track.json") - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/track/lookup" { - t.Errorf("path = %q, want /api/v1/track/lookup", r.URL.Path) - } - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupTrack(context.Background(), "roygbiv") - if err != nil { - t.Fatalf("LookupTrack: %v", err) - } - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].Name != "Roygbiv" { - t.Errorf("Name = %q", got[0].Name) - } - want := "Music Has the Right to Children · Boards of Canada" - if got[0].Secondary != want { - t.Errorf("Secondary = %q, want %q", got[0].Secondary, want) - } - if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID) - } - if got[0].AlbumMBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" { - t.Errorf("AlbumMBID = %q, want a1b2c3d4-e5f6-7890-abcd-ef1234567890", got[0].AlbumMBID) - } - if got[0].ImageURL != "" { - t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL) - } -} - -func TestLookupTrack_NoArtistName(t *testing.T) { - // Verify secondary degrades gracefully when artistName is absent. - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`[{"foreignTrackId":"abc","title":"X","albumTitle":"AlbumY","artistName":""}]`)) - }) - defer srv.Close() - got, err := c.LookupTrack(context.Background(), "x") - if err != nil { - t.Fatalf("err: %v", err) - } - if got[0].Secondary != "AlbumY" { - t.Errorf("Secondary = %q, want %q", got[0].Secondary, "AlbumY") - } -} - -// --- AddArtist --- - -func TestAddArtist_PostsCorrectBody(t *testing.T) { - var decoded map[string]any - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("method = %q, want POST", r.Method) - } - if r.URL.Path != "/api/v1/artist" { - t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) - } - if ct := r.Header.Get("Content-Type"); ct != "application/json" { - t.Errorf("Content-Type = %q, want application/json", ct) - } - if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { - t.Errorf("decode body: %v", err) - } - w.WriteHeader(http.StatusCreated) - }) - defer srv.Close() - - err := c.AddArtist(context.Background(), AddArtistParams{ - ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - QualityProfileID: 2, - RootFolderPath: "/music", - MonitorAll: true, - }) - if err != nil { - t.Fatalf("AddArtist: %v", err) - } - - if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"]) - } - if decoded["qualityProfileId"] != float64(2) { - t.Errorf("qualityProfileId = %v", decoded["qualityProfileId"]) - } - if decoded["rootFolderPath"] != "/music" { - t.Errorf("rootFolderPath = %v", decoded["rootFolderPath"]) - } - if decoded["monitored"] != true { - t.Errorf("monitored = %v, want true", decoded["monitored"]) - } - if decoded["monitorNewItems"] != "all" { - t.Errorf("monitorNewItems = %v, want all", decoded["monitorNewItems"]) - } - // #5239: ArtistResource has no top-level monitor; Lidarr reads the - // choice from addOptions and silently drops it anywhere else. - if _, set := decoded["monitor"]; set { - t.Errorf("monitor sent at top level (%v), where Lidarr ignores it", decoded["monitor"]) - } - opts, ok := decoded["addOptions"].(map[string]any) - if !ok { - t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"]) - } - if opts["monitor"] != "all" { - t.Errorf("addOptions.monitor = %v, want all (MonitorAll=true)", opts["monitor"]) - } - if opts["monitored"] != true { - t.Errorf("addOptions.monitored = %v, want true", opts["monitored"]) - } - if opts["searchForMissingAlbums"] != true { - t.Errorf("addOptions.searchForMissingAlbums = %v, want true", opts["searchForMissingAlbums"]) - } -} - -func TestAddArtist_MonitorFuture(t *testing.T) { - var decoded map[string]any - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewDecoder(r.Body).Decode(&decoded) - w.WriteHeader(http.StatusCreated) - }) - defer srv.Close() - - _ = c.AddArtist(context.Background(), AddArtistParams{MonitorAll: false}) - if opts, _ := decoded["addOptions"].(map[string]any); opts["monitor"] != "future" { - t.Errorf("addOptions.monitor = %v, want future (MonitorAll=false)", opts["monitor"]) - } -} - -func TestAddArtist_ServerError(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - err := c.AddArtist(context.Background(), AddArtistParams{}) - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError", err) - } -} - -// --- AddAlbum --- - -const ( - addAlbumMBID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - addArtistMBID = "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" -) - -// albumLookupStub answers Lidarr's album lookup with lookupBody and records -// the body of the album POST that follows it. -func albumLookupStub(t *testing.T, lookupBody string, postStatus int, postBody string) (*Client, *httptest.Server, *map[string]any, *int) { - t.Helper() - var posted map[string]any - posts := 0 - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/album/lookup": - if got := r.URL.Query().Get("term"); got != "lidarr:"+addAlbumMBID { - t.Errorf("lookup term = %q, want lidarr:%s", got, addAlbumMBID) - } - _, _ = w.Write([]byte(lookupBody)) - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/album": - posts++ - _ = json.NewDecoder(r.Body).Decode(&posted) - w.WriteHeader(postStatus) - _, _ = w.Write([]byte(postBody)) - default: - t.Errorf("unexpected %s %s", r.Method, r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - }) - return c, srv, &posted, &posts -} - -var addAlbumParams = AddAlbumParams{ - ForeignAlbumID: addAlbumMBID, - ForeignArtistID: addArtistMBID, - ArtistName: "Boards of Canada", - QualityProfileID: 1, - MetadataProfileID: 3, - RootFolderPath: "/music", -} - -// #5234: Lidarr refuses a flat album add with "'Artist' must not be empty". -// The add has to be the looked-up album resource with its artist nested. -func TestAddAlbum_NewArtist_PostsTheLookedUpAlbumWithTheArtistNested(t *testing.T) { - lookup := `[ - {"foreignAlbumId": "someone-else", "title": "Other"}, - {"foreignAlbumId": "` + addAlbumMBID + `", "title": "Music Has the Right to Children", - "releases": [{"foreignReleaseId": "r1", "monitored": true}], - "artist": {"foreignArtistId": "` + addArtistMBID + `", "artistName": "Boards of Canada"}} - ]` - c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`) - defer srv.Close() - - if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil { - t.Fatalf("AddAlbum: %v", err) - } - body := *posted - if body["foreignAlbumId"] != addAlbumMBID || body["title"] != "Music Has the Right to Children" { - t.Errorf("posted album = %v / %v, want the looked-up album", body["foreignAlbumId"], body["title"]) - } - if rel, _ := body["releases"].([]any); len(rel) != 1 { - t.Errorf("releases = %v, want the lookup's releases passed through", body["releases"]) - } - if body["monitored"] != true { - t.Errorf("monitored = %v, want true", body["monitored"]) - } - if opts, _ := body["addOptions"].(map[string]any); opts["searchForNewAlbum"] != true { - t.Errorf("addOptions = %v, want searchForNewAlbum true", body["addOptions"]) - } - - artist, ok := body["artist"].(map[string]any) - if !ok { - t.Fatalf("artist = %T, want a nested object", body["artist"]) - } - for k, want := range map[string]any{ - "foreignArtistId": addArtistMBID, - "qualityProfileId": float64(1), - "metadataProfileId": float64(3), - "rootFolderPath": "/music", - "monitored": true, - "monitorNewItems": "none", - } { - if artist[k] != want { - t.Errorf("artist.%s = %v, want %v", k, artist[k], want) - } - } - // A new artist monitors this album only: albumsToMonitor wins over the - // monitor type in Lidarr's AlbumMonitoredService. - aOpts, _ := artist["addOptions"].(map[string]any) - if aOpts["monitor"] != "none" || aOpts["searchForMissingAlbums"] != false { - t.Errorf("artist.addOptions = %v, want monitor none, no catalogue search", aOpts) - } - if only, _ := aOpts["albumsToMonitor"].([]any); len(only) != 1 || only[0] != addAlbumMBID { - t.Errorf("artist.addOptions.albumsToMonitor = %v, want [%s]", aOpts["albumsToMonitor"], addAlbumMBID) - } -} - -// An artist Lidarr already has keeps its own profiles and monitoring. -func TestAddAlbum_ExistingArtist_IsLeftAsLidarrHasIt(t *testing.T) { - lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `", - "artist": {"id": 42, "foreignArtistId": "` + addArtistMBID + `", "qualityProfileId": 5, - "metadataProfileId": 2, "rootFolderPath": "/lib", "monitorNewItems": "all"}}]` - c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`) - defer srv.Close() - - if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil { - t.Fatalf("AddAlbum: %v", err) - } - artist, _ := (*posted)["artist"].(map[string]any) - if artist["qualityProfileId"] != float64(5) || artist["rootFolderPath"] != "/lib" || artist["monitorNewItems"] != "all" { - t.Errorf("existing artist was rewritten: %v", artist) - } - if _, set := artist["addOptions"]; set { - t.Errorf("existing artist got addOptions %v", artist["addOptions"]) - } -} - -func TestAddAlbum_NotInLidarrsMetadata_IsNotFoundAndPostsNothing(t *testing.T) { - c, srv, _, posts := albumLookupStub(t, `[{"foreignAlbumId": "someone-else"}]`, http.StatusCreated, "") - defer srv.Close() - - err := c.AddAlbum(context.Background(), addAlbumParams) - if !errors.Is(err, ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) - } - if *posts != 0 { - t.Errorf("posted %d times, want 0", *posts) - } -} - -func TestAddAlbum_AlreadyAdded_IsErrAlreadyExists(t *testing.T) { - lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `", "artist": {"id": 42}}]` - c, srv, _, _ := albumLookupStub(t, lookup, http.StatusBadRequest, - `[{"propertyName":"ForeignAlbumId","errorMessage":"This album has already been added."}]`) - defer srv.Close() - - if err := c.AddAlbum(context.Background(), addAlbumParams); !errors.Is(err, ErrAlreadyExists) { - t.Fatalf("err = %v, want ErrAlreadyExists", err) - } -} - -func TestAddAlbum_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - err := c.AddAlbum(context.Background(), addAlbumParams) - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -// --- ListQualityProfiles --- - -func TestListQualityProfiles_HappyPath(t *testing.T) { - body := mustReadFixture(t, "quality_profiles.json") - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/qualityprofile" { - t.Errorf("path = %q, want /api/v1/qualityprofile", r.URL.Path) - } - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.ListQualityProfiles(context.Background()) - if err != nil { - t.Fatalf("ListQualityProfiles: %v", err) - } - if len(got) != 3 { - t.Fatalf("len = %d, want 3", len(got)) - } - if got[0].ID != 1 || got[0].Name != "Any" { - t.Errorf("first profile = %+v, want {ID:1 Name:Any}", got[0]) - } - if got[1].ID != 2 || got[1].Name != "Lossless" { - t.Errorf("second profile = %+v", got[1]) - } -} - -func TestListQualityProfiles_BadJSON(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`not json`)) - }) - defer srv.Close() - _, err := c.ListQualityProfiles(context.Background()) - if !errors.Is(err, ErrInvalidPayload) { - t.Fatalf("err = %v, want ErrInvalidPayload", err) - } -} - -// --- LookupArtist (additional) --- - -func TestLookupArtist_LookupFailed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if !errors.Is(err, ErrLookupFailed) { - t.Fatalf("err = %v, want ErrLookupFailed", err) - } -} - -func TestGet_BaseURLWithTrailingSlash(t *testing.T) { - body, _ := os.ReadFile("testdata/lookup_artist.json") - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify path is /api/v1/artist/lookup, NOT //api/v1/artist/lookup - if r.URL.Path != "/api/v1/artist/lookup" { - t.Errorf("got path %q; trailing-slash BaseURL produced double-slash", r.URL.Path) - } - _, _ = w.Write(body) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL + "/", APIKey: "k", HTTP: srv.Client()} - _, err := c.LookupArtist(context.Background(), "boards") - if err != nil { - t.Fatalf("LookupArtist: %v", err) - } -} - -// --- AddArtist (additional) --- - -func TestAddArtist_PostFailed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - err := c.AddArtist(context.Background(), AddArtistParams{ForeignArtistID: "x", QualityProfileID: 1, RootFolderPath: "/m"}) - if !errors.Is(err, ErrLookupFailed) { - t.Fatalf("err = %v, want ErrLookupFailed", err) - } -} - -// --- ListRootFolders --- - -func TestListRootFolders_HappyPath(t *testing.T) { - body := mustReadFixture(t, "root_folders.json") - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/rootfolder" { - t.Errorf("path = %q, want /api/v1/rootfolder", r.URL.Path) - } - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.ListRootFolders(context.Background()) - if err != nil { - t.Fatalf("ListRootFolders: %v", err) - } - if len(got) != 3 { - t.Fatalf("len = %d, want 3", len(got)) - } - if got[0].Path != "/music" { - t.Errorf("Path = %q", got[0].Path) - } - if !got[0].Accessible { - t.Errorf("Accessible = false for /music, want true") - } - if got[0].FreeSpace != 107374182400 { - t.Errorf("FreeSpace = %d", got[0].FreeSpace) - } - if got[2].Accessible { - t.Errorf("expected /music-offline to be inaccessible") - } -} - -func TestListRootFolders_NullBody(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`null`)) - }) - defer srv.Close() - // null decodes to a nil slice, no error — but len check should yield 0. - got, err := c.ListRootFolders(context.Background()) - if err != nil { - t.Fatalf("unexpected error on null body: %v", err) - } - if len(got) != 0 { - t.Errorf("expected empty slice from null body, got %d", len(got)) - } -} - -func TestListRootFolders_BadJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("{not valid json")) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()} - _, err := c.ListRootFolders(context.Background()) - if !errors.Is(err, ErrInvalidPayload) { - t.Fatalf("err = %v, want ErrInvalidPayload", err) - } -} - -// --- Ping --- - -func TestPing_ReturnsVersion(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/system/status" { - t.Errorf("path = %q, want /api/v1/system/status", r.URL.Path) - } - _, _ = w.Write([]byte(`{"version":"2.4.3.3795","buildTime":"2024-01-15T00:00:00Z"}`)) - }) - defer srv.Close() - - res, err := c.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) - } - if res.Version != "2.4.3.3795" { - t.Errorf("Version = %q, want 2.4.3.3795", res.Version) - } -} - -func TestPing_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - _, err := c.Ping(context.Background()) - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -func TestPing_Unreachable(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}} - _, err := c.Ping(context.Background()) - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } -} - -func TestPing_BadJSON(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{bad`)) - }) - defer srv.Close() - _, err := c.Ping(context.Background()) - if !errors.Is(err, ErrInvalidPayload) { - t.Fatalf("err = %v, want ErrInvalidPayload", err) - } -} - -// --- pickPosterImage --- - -func TestPickPosterImage_PreferRemoteURL(t *testing.T) { - imgs := []lidarrImage{ - {CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"}, - } - got := pickPosterImage(imgs) - if got != "https://remote.example/img.jpg" { - t.Errorf("got %q, want remote URL", got) - } -} - -func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) { - imgs := []lidarrImage{ - {CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"}, - } - got := pickPosterImage(imgs) - if got != "https://local.example/img.jpg" { - t.Errorf("got %q, want local URL", got) - } -} - -func TestPickPosterImage_SkipsNonPoster(t *testing.T) { - imgs := []lidarrImage{ - {CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"}, - {CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"}, - } - got := pickPosterImage(imgs) - if got != "" { - t.Errorf("got %q, want empty (no poster)", got) - } -} - -// --- UnmonitorTrack --- - -// unmonitorMux is the three-endpoint stub UnmonitorTrack walks. The flags it -// captures are inspected by the individual tests. -type unmonitorMux struct { - t *testing.T - wantAlbumMbid string - albumStatus int // override status for album lookup; 0 → 200 with body - tracksStatus int // override status for tracks list; 0 → 200 with body - putStatus int // override status for PUT /track/monitor; 0 → 200 - albumBody []byte // when albumStatus is 0 - tracksBody []byte // when tracksStatus is 0 - putBody map[string]any - putHit bool -} - -func (m *unmonitorMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/album": - if got := r.URL.Query().Get("foreignAlbumId"); got != m.wantAlbumMbid { - m.t.Errorf("album lookup foreignAlbumId = %q, want %q", got, m.wantAlbumMbid) - } - if m.albumStatus != 0 { - w.WriteHeader(m.albumStatus) - return - } - _, _ = w.Write(m.albumBody) - case "/api/v1/track": - if got := r.URL.Query().Get("albumId"); got != "42" { - m.t.Errorf("track list albumId = %q, want 42", got) - } - if m.tracksStatus != 0 { - w.WriteHeader(m.tracksStatus) - return - } - _, _ = w.Write(m.tracksBody) - case "/api/v1/track/monitor": - if r.Method != http.MethodPut { - m.t.Errorf("monitor method = %q, want PUT", r.Method) - } - m.putHit = true - if m.putStatus != 0 { - w.WriteHeader(m.putStatus) - return - } - _ = json.NewDecoder(r.Body).Decode(&m.putBody) - w.WriteHeader(http.StatusAccepted) - default: - m.t.Errorf("unexpected path: %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } -} - -func TestUnmonitorTrack_HappyPath(t *testing.T) { - mux := &unmonitorMux{ - t: t, - wantAlbumMbid: "album-mbid", - albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`), - tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"},{"id":1002,"foreignTrackId":"track-mbid"}]`), - } - srv := httptest.NewServer(mux) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - - if err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid"); err != nil { - t.Fatalf("UnmonitorTrack: %v", err) - } - if !mux.putHit { - t.Fatal("PUT /api/v1/track/monitor was not called") - } - if mux.putBody["monitored"] != false { - t.Errorf("PUT body monitored = %v, want false", mux.putBody["monitored"]) - } - ids, ok := mux.putBody["trackIds"].([]any) - if !ok || len(ids) != 1 { - t.Fatalf("PUT body trackIds = %v, want [1002]", mux.putBody["trackIds"]) - } - if ids[0] != float64(1002) { - t.Errorf("PUT trackIds[0] = %v, want 1002 (the matching foreignTrackId)", ids[0]) - } -} - -func TestUnmonitorTrack_EmptyMbid(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}} - if err := c.UnmonitorTrack(context.Background(), "", "album-mbid"); err == nil { - t.Error("UnmonitorTrack with empty trackMbid: want error, got nil") - } - if err := c.UnmonitorTrack(context.Background(), "track-mbid", ""); err == nil { - t.Error("UnmonitorTrack with empty albumMbid: want error, got nil") - } -} - -func TestUnmonitorTrack_AlbumNotInLidarr(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/album" { - t.Errorf("unexpected path %q", r.URL.Path) - } - _, _ = w.Write([]byte(`[]`)) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) - } -} - -func TestUnmonitorTrack_TrackNotInAlbum(t *testing.T) { - mux := &unmonitorMux{ - t: t, - wantAlbumMbid: "album-mbid", - albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`), - // No track with foreignTrackId="track-mbid" - tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"}]`), - } - srv := httptest.NewServer(mux) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrNotFound) { - t.Fatalf("err = %v, want ErrNotFound", err) - } - if mux.putHit { - t.Error("PUT was called even though track wasn't found") - } -} - -func TestUnmonitorTrack_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError", err) - } -} - -func TestUnmonitorTrack_AuthFailed(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrAuthFailed) { - t.Fatalf("err = %v, want ErrAuthFailed", err) - } -} - -func TestUnmonitorTrack_Unreachable(t *testing.T) { - c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrUnreachable) { - t.Fatalf("err = %v, want ErrUnreachable", err) - } -} - -func TestUnmonitorTrack_PutServerError(t *testing.T) { - mux := &unmonitorMux{ - t: t, - wantAlbumMbid: "album-mbid", - albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`), - tracksBody: []byte(`[{"id":1002,"foreignTrackId":"track-mbid"}]`), - putStatus: http.StatusInternalServerError, - } - srv := httptest.NewServer(mux) - defer srv.Close() - c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()} - err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid") - if !errors.Is(err, ErrServerError) { - t.Fatalf("err = %v, want ErrServerError on PUT 5xx", err) - } -} diff --git a/internal/lidarr/delete.go b/internal/lidarr/delete.go deleted file mode 100644 index 9755c680..00000000 --- a/internal/lidarr/delete.go +++ /dev/null @@ -1,69 +0,0 @@ -package lidarr - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" -) - -// del is the shared DELETE helper. Mirrors get()/post() in client.go: builds -// the URL inline, sets the API key, maps status codes to typed errors. The -// caller is responsible for closing the returned body. -func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) { - u, err := url.Parse(c.BaseURL) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - u.Path = strings.TrimRight(u.Path, "/") + path - if q != nil { - u.RawQuery = q.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("X-Api-Key", c.APIKey) - - resp, err := c.HTTP.Do(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) - } - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - _ = resp.Body.Close() - return nil, ErrAuthFailed - } - if resp.StatusCode >= 500 { - _ = resp.Body.Close() - return nil, ErrServerError - } - if resp.StatusCode >= 400 { - _ = resp.Body.Close() - return nil, ErrLookupFailed - } - return resp, nil -} - -// DeleteAlbum removes an album from Lidarr's library. -// - deleteFiles=true also removes the audio files from disk. -// - addImportListExclusion=true tells Lidarr to never re-add this album -// via import-list scans. -// -// M5b's admin "delete via Lidarr" action always passes both `true`. -func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { - if lidarrAlbumID == 0 { - return fmt.Errorf("lidarr: zero album id") - } - q := url.Values{ - "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, - "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, - } - resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) - if err != nil { - return err - } - _ = resp.Body.Close() - return nil -} diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go deleted file mode 100644 index eb0edf0d..00000000 --- a/internal/lidarr/delete_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package lidarr - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestLookupAlbumByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/album" { - t.Errorf("path = %q, want /api/v1/album", r.URL.Path) - } - if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { - t.Errorf("foreignAlbumId = %q", got) - } - if got := r.Header.Get("X-Api-Key"); got != "key123" { - t.Errorf("X-Api-Key = %q, want key123", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") - if err != nil { - t.Fatalf("LookupAlbumByMBID: %v", err) - } - if got.ID != 42 || got.Title != "Music Has The Right To Children" { - t.Errorf("got = %+v", got) - } -} - -func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } -} - -func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrAuthFailed) { - t.Errorf("err = %v, want ErrAuthFailed", err) - } -} - -func TestLookupAlbumByMBID_5xxReturnsErrServerError(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrServerError) { - t.Errorf("err = %v, want ErrServerError", err) - } -} - -func TestLookupAlbumByMBID_BadJSON(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("not json")) - }) - defer srv.Close() - - _, err := c.LookupAlbumByMBID(context.Background(), "x") - if !errors.Is(err, ErrInvalidPayload) { - t.Errorf("err = %v, want ErrInvalidPayload", err) - } -} - -func TestLookupAlbumByMBID_EmptyMBIDRejected(t *testing.T) { - c, srv := newTestClient(func(http.ResponseWriter, *http.Request) { - t.Error("server should not be called with empty mbid") - }) - defer srv.Close() - - if _, err := c.LookupAlbumByMBID(context.Background(), ""); err == nil { - t.Error("err = nil, want empty-mbid rejection") - } -} - -func TestLookupArtistByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("[]")) - }) - defer srv.Close() - - _, err := c.LookupArtistByMBID(context.Background(), "x") - if !errors.Is(err, ErrNotFound) { - t.Errorf("err = %v, want ErrNotFound", err) - } -} - -func TestLookupArtistByMBID_EmptyMBIDRejected(t *testing.T) { - c, srv := newTestClient(func(http.ResponseWriter, *http.Request) { - t.Error("server should not be called with empty mbid") - }) - defer srv.Close() - - if _, err := c.LookupArtistByMBID(context.Background(), ""); err == nil { - t.Error("err = nil, want empty-mbid rejection") - } -} - -func TestLookupArtistByMBID_HappyPath(t *testing.T) { - body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/artist" { - t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) - } - if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { - t.Errorf("mbId = %q", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - }) - defer srv.Close() - - got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") - if err != nil { - t.Fatalf("LookupArtistByMBID: %v", err) - } - if got.ID != 7 || got.ArtistName != "Boards of Canada" { - t.Errorf("got = %+v", got) - } -} - -func TestDeleteAlbum_PassesBothFlags(t *testing.T) { - var captured *http.Request - c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { - captured = r - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { - t.Fatalf("DeleteAlbum: %v", err) - } - if captured == nil || captured.Method != http.MethodDelete { - t.Fatalf("method = %v, want DELETE", captured) - } - if captured.URL.Path != "/api/v1/album/42" { - t.Errorf("path = %q", captured.URL.Path) - } - if got := captured.URL.Query().Get("deleteFiles"); got != "true" { - t.Errorf("deleteFiles = %q", got) - } - if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { - t.Errorf("addImportListExclusion = %q", got) - } -} - -func TestDeleteAlbum_5xxReturnsErrServerError(t *testing.T) { - c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrServerError) { - t.Errorf("err = %v, want ErrServerError", err) - } -} - -func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - srv.Close() // server is closed; client should fail to connect - c := NewClient(srv.URL, "key123") - - err := c.DeleteAlbum(context.Background(), 42, true, true) - if !errors.Is(err, ErrUnreachable) { - t.Errorf("err = %v, want ErrUnreachable", err) - } -} - -func TestDeleteAlbum_ZeroIDRejected(t *testing.T) { - c, srv := newTestClient(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("server should not be called with zero id") - }) - defer srv.Close() - - if err := c.DeleteAlbum(context.Background(), 0, true, true); err == nil { - t.Error("err = nil, want zero-id rejection") - } -} diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go deleted file mode 100644 index fc27d359..00000000 --- a/internal/lidarr/errors.go +++ /dev/null @@ -1,33 +0,0 @@ -package lidarr - -import ( - "errors" - - "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" -) - -// Sentinel errors. Callers branch on these via errors.Is, not on -// HTTP status codes — the client maps codes to errors. -var ( - ErrUnreachable = errors.New("lidarr: unreachable") - ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403 - ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 - ErrServerError = errors.New("lidarr: server error") // 5xx - ErrInvalidPayload = errors.New("lidarr: invalid payload") - // ErrAlreadyExists is returned by AddArtist/AddAlbum when Lidarr - // rejects the add with a 400 because the artist/album is already in - // its library. This is the desired end state, not a failure — the - // idempotent retry path (reconciler re-sending an add after a partial - // success) treats it as confirmed rather than spinning forever. - ErrAlreadyExists = errors.New("lidarr: already exists") - // ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID - // when Lidarr returns 200 with an empty array — i.e., the MBID isn't in - // Lidarr's monitored set. Distinguished from network/auth errors so admin - // handlers can surface it as `lidarr_album_lookup_failed` (502) instead - // of `lidarr_unreachable` (503). - // - // Aliased to apierror.ErrNotFound so handler error mapping (errors.Is - // against the shared sentinel) and existing per-package callsites both - // resolve to the same pointer. - ErrNotFound = apierror.ErrNotFound -) diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go deleted file mode 100644 index f419597c..00000000 --- a/internal/lidarr/lookup_mbid.go +++ /dev/null @@ -1,81 +0,0 @@ -package lidarr - -import ( - "context" - "encoding/json" - "fmt" - "net/url" -) - -// LookupArtistByMBID returns the artist Lidarr has indexed under that -// MBID. Returns ErrNotFound if Lidarr returns an empty array. -func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { - if mbid == "" { - return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"mbId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/artist", q) - if err != nil { - return LidarrArtist{}, err - } - defer func() { _ = resp.Body.Close() }() - - var rows []LidarrArtist - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrArtist{}, fmt.Errorf("%w: decode artist: %v", ErrInvalidPayload, err) - } - if len(rows) == 0 { - return LidarrArtist{}, ErrNotFound - } - return rows[0], nil -} - -// LookupAlbumByMBID returns the album Lidarr has indexed under that -// MBID. Returns ErrNotFound on empty result. -func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { - if mbid == "" { - return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") - } - q := url.Values{"foreignAlbumId": []string{mbid}} - resp, err := c.get(ctx, "/api/v1/album", q) - if err != nil { - return LidarrAlbum{}, err - } - defer func() { _ = resp.Body.Close() }() - - var rows []LidarrAlbum - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return LidarrAlbum{}, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err) - } - if len(rows) == 0 { - return LidarrAlbum{}, ErrNotFound - } - return rows[0], nil -} - -// lookupAlbumResource fetches the album resource Lidarr's metadata holds -// under mbid (GET /api/v1/album/lookup?term=lidarr:), kept as raw -// JSON so AddAlbum can POST it back with every field Lidarr sent — the -// round trip Lidarr's own add-album UI makes. Returns ErrNotFound when no -// result carries that MBID. -func (c *Client) lookupAlbumResource(ctx context.Context, mbid string) (map[string]any, error) { - if mbid == "" { - return nil, fmt.Errorf("lidarr: empty album mbid") - } - resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {"lidarr:" + mbid}}) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - var rows []map[string]any - if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { - return nil, fmt.Errorf("%w: decode album lookup: %v", ErrInvalidPayload, err) - } - for _, row := range rows { - if id, _ := row["foreignAlbumId"].(string); id == mbid { - return row, nil - } - } - return nil, fmt.Errorf("%w: album %s in Lidarr's metadata", ErrNotFound, mbid) -} diff --git a/internal/lidarr/testdata/album_lookup_by_mbid.json b/internal/lidarr/testdata/album_lookup_by_mbid.json deleted file mode 100644 index 90d38049..00000000 --- a/internal/lidarr/testdata/album_lookup_by_mbid.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "id": 42, - "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", - "title": "Music Has The Right To Children", - "artistId": 7 - } -] diff --git a/internal/lidarr/testdata/artist_lookup_by_mbid.json b/internal/lidarr/testdata/artist_lookup_by_mbid.json deleted file mode 100644 index 74d78984..00000000 --- a/internal/lidarr/testdata/artist_lookup_by_mbid.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "id": 7, - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada" - } -] diff --git a/internal/lidarr/testdata/lookup_album.json b/internal/lidarr/testdata/lookup_album.json deleted file mode 100644 index b1bb6e35..00000000 --- a/internal/lidarr/testdata/lookup_album.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "title": "Music Has the Right to Children", - "artistName": "Boards of Canada", - "releaseDate": "1998-04-27", - "trackCount": 18, - "images": [ - {"coverType": "cover", "remoteUrl": "https://example.invalid/mhtrtc.jpg", "url": ""}, - {"coverType": "poster", "remoteUrl": "https://example.invalid/mhtrtc-poster.jpg", "url": ""} - ] - }, - { - "foreignAlbumId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "title": "Geogaddi", - "artistName": "Boards of Canada", - "releaseDate": "2002-02-11", - "trackCount": 23, - "images": [] - } -] diff --git a/internal/lidarr/testdata/lookup_artist.json b/internal/lidarr/testdata/lookup_artist.json deleted file mode 100644 index d3caab26..00000000 --- a/internal/lidarr/testdata/lookup_artist.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "artistName": "Boards of Canada", - "genres": ["Electronic", "IDM"], - "albumCount": 18, - "images": [ - {"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg", "url": ""}, - {"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg", "url": ""} - ] - }, - { - "foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c", - "artistName": "Bored of Education", - "genres": [], - "albumCount": 0, - "images": [] - } -] diff --git a/internal/lidarr/testdata/lookup_track.json b/internal/lidarr/testdata/lookup_track.json deleted file mode 100644 index 10751762..00000000 --- a/internal/lidarr/testdata/lookup_track.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "foreignTrackId": "t1a2b3c4-d5e6-7890-abcd-ef1234567890", - "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "title": "Roygbiv", - "albumTitle": "Music Has the Right to Children", - "artistName": "Boards of Canada" - }, - { - "foreignTrackId": "t2b3c4d5-e6f7-8901-bcde-f12345678901", - "foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", - "title": "Aquarius", - "albumTitle": "Music Has the Right to Children", - "artistName": "Boards of Canada" - } -] diff --git a/internal/lidarr/testdata/quality_profiles.json b/internal/lidarr/testdata/quality_profiles.json deleted file mode 100644 index b645820e..00000000 --- a/internal/lidarr/testdata/quality_profiles.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - {"id": 1, "name": "Any"}, - {"id": 2, "name": "Lossless"}, - {"id": 3, "name": "Standard"} -] diff --git a/internal/lidarr/testdata/root_folders.json b/internal/lidarr/testdata/root_folders.json deleted file mode 100644 index b75eef9e..00000000 --- a/internal/lidarr/testdata/root_folders.json +++ /dev/null @@ -1,5 +0,0 @@ -[ - {"path": "/music", "accessible": true, "freeSpace": 107374182400}, - {"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200}, - {"path": "/music-offline", "accessible": false, "freeSpace": 0} -] diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go deleted file mode 100644 index 8fdef82f..00000000 --- a/internal/lidarr/types.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the -// only place in the codebase that knows about Lidarr's wire format. -// Callers receive value structs, never raw JSON. -package lidarr - -// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}. -// MBID is the kind's primary identifier (foreignArtistId / foreignAlbumId / -// foreignTrackId from Lidarr). ArtistMBID and AlbumMBID carry parent -// identifiers when the kind has them — request creation and admin -// approval need them to construct the lidarr_requests row and the -// AddArtist/AddAlbum API calls. -type LookupResult struct { - MBID string // foreignArtistId / foreignAlbumId / foreignTrackId - ArtistMBID string // parent artist's foreignArtistId — set on album/track results, empty on artist results - AlbumMBID string // parent album's foreignAlbumId — set on track results only - Name string // artist name; album/track returns Title here too - Secondary string // genre + album count for artist; year for album; album for track - ImageURL string // cover-art URL Lidarr surfaced (may be empty) -} - -// lidarrImage is the wire shape for image entries in Lidarr's API responses. -type lidarrImage struct { - CoverType string `json:"coverType"` - RemoteURL string `json:"remoteUrl"` - URL string `json:"url"` -} - -// QualityProfile is the dropdown choice on Admin › Acquisition. -type QualityProfile struct { - ID int - Name string -} - -// MetadataProfile controls which release types Lidarr tracks for an artist -// (album / single / EP / compilation). It's a separate Lidarr concept from -// quality profile and is required on POST /api/v1/artist — Lidarr 4xx's -// the request with "'Metadata Profile Id' must be greater than '0'" if -// it's missing or zero. -type MetadataProfile struct { - ID int - Name string -} - -// RootFolder is the dropdown choice on Admin › Acquisition. -type RootFolder struct { - Path string - Accessible bool - FreeSpace int64 -} - -// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist. -// ArtistName and MetadataProfileID are both required; omitting either -// produces a 400 with field-validation errors. -type AddArtistParams struct { - ForeignArtistID string - ArtistName string - QualityProfileID int - MetadataProfileID int - RootFolderPath string - MonitorAll bool // true => addOptions.monitor="all"; false => "future" -} - -// AddAlbumParams identify the album to add and carry the settings Lidarr -// needs when it has to create the parent artist on the fly (an album by an -// artist not yet in Lidarr). The artist fields are a fallback only: the -// album lookup normally supplies the artist itself. -type AddAlbumParams struct { - ForeignAlbumID string - ForeignArtistID string // Lidarr requires the artist's foreign id too - ArtistName string - QualityProfileID int - MetadataProfileID int - RootFolderPath string -} - -// PingResult is the response shape from GET /api/v1/system/status. -type PingResult struct { - Version string -} - -// LidarrArtist is the subset of Lidarr's artist resource used by M5b -// admin actions. The "id" field is Lidarr's internal numeric ID — needed -// for DELETE /api/v1/artist/{id} calls. -type LidarrArtist struct { - ID int `json:"id"` - ForeignArtistID string `json:"foreignArtistId"` // MBID - ArtistName string `json:"artistName"` -} - -// LidarrAlbum is the subset of Lidarr's album resource used by M5b -// admin actions. -type LidarrAlbum struct { - ID int `json:"id"` - ForeignAlbumID string `json:"foreignAlbumId"` // MBID - Title string `json:"title"` - ArtistID int `json:"artistId"` -} diff --git a/internal/lidarr/unmonitor.go b/internal/lidarr/unmonitor.go deleted file mode 100644 index 81d541c7..00000000 --- a/internal/lidarr/unmonitor.go +++ /dev/null @@ -1,91 +0,0 @@ -package lidarr - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "strconv" -) - -// UnmonitorTrack flips Lidarr's `monitored` flag to false on the track -// matching trackMbid (a MusicBrainz id, aka Lidarr's `foreignTrackId`). -// Used by the M7 #372 admin "Remove from library" flow when the operator -// requests `?unmonitor=true` so Lidarr's monitor doesn't search for a -// replacement after we delete the local file + DB rows. -// -// Lidarr's monitor API takes its own internal numeric id, not the mbid, -// so this is a three-step walk: -// -// 1. GET /api/v1/album?foreignAlbumId={albumMbid} — find the album's -// internal Lidarr id. albumMbid is the parent album's mbid; the -// caller (tracks service) passes it because looking it up from the -// trackMbid alone would mean either a dedicated Lidarr endpoint -// that doesn't exist or scanning the entire library. -// 2. GET /api/v1/track?albumId={lidarr_album_id} — list the album's -// tracks, each with its internal id and foreignTrackId. -// 3. PUT /api/v1/track/monitor — body `{trackIds:[id], monitored:false}`. -// -// Returns ErrNotFound when the album or track isn't in Lidarr's library -// (already removed, or the track was never imported). Network / auth / -// 5xx errors propagate as typed sentinel errors so the tracks service -// can decide whether to surface a `lidarr_unmonitor_failed` flag. -func (c *Client) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error { - if trackMbid == "" { - return fmt.Errorf("lidarr: empty track mbid") - } - if albumMbid == "" { - return fmt.Errorf("lidarr: empty album mbid") - } - - // Step 1: resolve album mbid → Lidarr album id. - album, err := c.LookupAlbumByMBID(ctx, albumMbid) - if err != nil { - return err - } - if album.ID == 0 { - return ErrNotFound - } - - // Step 2: list tracks under that album, find the one whose - // foreignTrackId matches. - q := url.Values{"albumId": []string{strconv.Itoa(album.ID)}} - resp, err := c.get(ctx, "/api/v1/track", q) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - - var tracks []struct { - ID int `json:"id"` - ForeignTrackID string `json:"foreignTrackId"` - } - if err := json.NewDecoder(resp.Body).Decode(&tracks); err != nil { - return fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err) - } - var lidarrTrackID int - for _, t := range tracks { - if t.ForeignTrackID == trackMbid { - lidarrTrackID = t.ID - break - } - } - if lidarrTrackID == 0 { - return ErrNotFound - } - - // Step 3: PUT the monitor toggle. - body, err := json.Marshal(map[string]any{ - "trackIds": []int{lidarrTrackID}, - "monitored": false, - }) - if err != nil { - return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err) - } - putResp, err := c.put(ctx, "/api/v1/track/monitor", body) - if err != nil { - return err - } - _ = putResp.Body.Close() - return nil -} diff --git a/internal/lidarrconfig/service.go b/internal/lidarrconfig/service.go deleted file mode 100644 index 05e02693..00000000 --- a/internal/lidarrconfig/service.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package lidarrconfig is a thin wrapper over the singleton lidarr_config -// row. Get returns a typed Config; Save updates it. Callers branch on -// Config.Enabled — never on raw NULL fields. -package lidarrconfig - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Config is the typed projection of lidarr_config (no NULL fields exposed -// to callers — empty strings / zero ints carry the "unset" meaning). -type Config struct { - Enabled bool - BaseURL string - APIKey string - DefaultQualityProfileID int - DefaultMetadataProfileID int - DefaultRootFolderPath string -} - -// Service reads and writes the singleton. -type Service struct { - pool *pgxpool.Pool -} - -func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } - -func (s *Service) Get(ctx context.Context) (Config, error) { - row, err := dbq.New(s.pool).GetLidarrConfig(ctx) - if err != nil { - return Config{}, fmt.Errorf("lidarrconfig: %w", err) - } - cfg := Config{Enabled: row.Enabled} - if row.BaseUrl != nil { - cfg.BaseURL = *row.BaseUrl - } - if row.ApiKey != nil { - cfg.APIKey = *row.ApiKey - } - if row.DefaultQualityProfileID != nil { - cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) - } - if row.DefaultMetadataProfileID != nil { - cfg.DefaultMetadataProfileID = int(*row.DefaultMetadataProfileID) - } - if row.DefaultRootFolderPath != nil { - cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath - } - return cfg, nil -} - -// Save writes the entire row. Callers pass the full Config they want -// stored — this is not a partial update. -func (s *Service) Save(ctx context.Context, cfg Config) error { - var ( - baseURL = strPtr(cfg.BaseURL) - apiKey = strPtr(cfg.APIKey) - qpID = int32Ptr(cfg.DefaultQualityProfileID) - mpID = int32Ptr(cfg.DefaultMetadataProfileID) - rootPath = strPtr(cfg.DefaultRootFolderPath) - ) - _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ - Enabled: cfg.Enabled, - BaseUrl: baseURL, - ApiKey: apiKey, - DefaultQualityProfileID: qpID, - DefaultMetadataProfileID: mpID, - DefaultRootFolderPath: rootPath, - }) - if err != nil { - return fmt.Errorf("lidarrconfig: %w", err) - } - return nil -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} - -func int32Ptr(i int) *int32 { - if i == 0 { - return nil - } - v := int32(i) - return &v -} diff --git a/internal/lidarrconfig/service_test.go b/internal/lidarrconfig/service_test.go deleted file mode 100644 index 627e4be1..00000000 --- a/internal/lidarrconfig/service_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package lidarrconfig - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db" -) - -func newTestPool(t *testing.T) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping integration test in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { - t.Fatalf("migrate: %v", err) - } - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - // Reset singleton to default state before each test by replacing - // the row contents via UPDATE (TRUNCATE would violate the CHECK). - if _, err := pool.Exec(context.Background(), - "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", - ); err != nil { - t.Fatalf("reset: %v", err) - } - return pool -} - -func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { - pool := newTestPool(t) - cfg, err := New(pool).Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { - t.Errorf("expected zero-value Config, got %+v", cfg) - } -} - -func TestSaveThenGet_RoundTrip(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - want := Config{ - Enabled: true, - BaseURL: "http://lidarr.lan:8686", - APIKey: "secret", - DefaultQualityProfileID: 4, - DefaultRootFolderPath: "/music", - } - if err := s.Save(context.Background(), want); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got != want { - t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) - } -} - -func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { - pool := newTestPool(t) - s := New(pool) - if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { - t.Fatalf("Save: %v", err) - } - got, err := s.Get(context.Background()) - if err != nil { - t.Fatalf("Get: %v", err) - } - if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { - t.Errorf("expected empty strings on round-trip; got %+v", got) - } -} diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go deleted file mode 100644 index 54a97abe..00000000 --- a/internal/lidarrquarantine/service.go +++ /dev/null @@ -1,360 +0,0 @@ -// Package lidarrquarantine owns the per-user track quarantine workflow. -// Users flag a track as broken (Flag/Unflag), the SPA hides the track -// from their views, and admins resolve the resulting reports via the -// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). -package lidarrquarantine - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" -) - -// Public errors. Handlers map these to API codes. -var ( - ErrBadReason = errors.New("lidarrquarantine: invalid reason") - ErrTrackNotFound = errors.New("lidarrquarantine: track not found") - ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") - ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") - ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") - ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") -) - -// Service owns the request lifecycle. clientFn is a per-call factory so -// config changes in lidarrconfig take effect immediately. clientFn returns -// nil when Lidarr is disabled. -type Service struct { - // dataDir lets a Delete file that empties an artist clear that artist's - // cached art, the same as the admin remove-track path. - dataDir string - pool *pgxpool.Pool - lidarrCfg *lidarrconfig.Service - clientFn func() *lidarr.Client -} - -// NewService constructs a Service. Pass nil for clientFn to disable the -// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled). -func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, dataDir string) *Service { - if clientFn == nil { - clientFn = func() *lidarr.Client { return nil } - } - return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, dataDir: dataDir} -} - -// Flag inserts or updates a quarantine row for the caller. Re-flagging -// the same (user, track) overwrites reason+notes (and bumps created_at). -func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { - if !validReason(reason) { - return dbq.LidarrQuarantine{}, ErrBadReason - } - var notesPtr *string - if notes != "" { - notesPtr = ¬es - } - row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ - UserID: userID, - TrackID: trackID, - Reason: dbq.LidarrQuarantineReason(reason), - Notes: notesPtr, - }) - if err != nil { - // FK violation on track_id (no such track) → ErrTrackNotFound so the - // handler can return 404 instead of a generic 500. Code 23503 is the - // Postgres SQLSTATE for foreign_key_violation; the constraint check - // guards against future FKs (e.g. user_id) firing the same code. - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23503" && - (pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) { - return dbq.LidarrQuarantine{}, ErrTrackNotFound - } - return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) - } - return row, nil -} - -// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no -// row exists for that (user, track). -func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { - _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ - UserID: userID, TrackID: trackID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrQuarantineNotFound - } - return fmt.Errorf("delete: %w", err) - } - return nil -} - -// ListMine returns the caller's quarantines with track + album + artist -// detail. Drives /library/hidden. Ordered newest-first. -func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { - return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) -} - -// AdminQueueRow is the assembled aggregated row served by the admin -// queue endpoint. The handler post-processes the SQL result + a per-track -// ListQuarantineReports call to materialize reason_counts and the -// per-user reports list. -type AdminQueueRow struct { - TrackID pgtype.UUID - TrackTitle string - ArtistName string - AlbumTitle string - AlbumID pgtype.UUID - LidarrAlbumMBID *string - ReportCount int32 - LatestAt pgtype.Timestamptz - ReasonCounts map[string]int - Reports []UserReport -} - -// UserReport is the per-user detail under an aggregated admin row. -type UserReport struct { - UserID pgtype.UUID - Username string - Reason string - Notes *string - CreatedAt pgtype.Timestamptz -} - -// ListAdminQueue returns the aggregated admin queue. One row per track. -// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row) -// because materializing reason_counts in pure SQL is awkward; the queue -// is small in practice (≲100 tracks at household scale). Revisit with -// json_object_agg if the queue grows. -func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { - q := dbq.New(s.pool) - aggregated, err := q.ListAdminQuarantineQueue(ctx) - if err != nil { - return nil, fmt.Errorf("aggregate: %w", err) - } - out := make([]AdminQueueRow, 0, len(aggregated)) - for _, r := range aggregated { - reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) - if err != nil { - return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) - } - rc := make(map[string]int, len(reports)) - userReports := make([]UserReport, 0, len(reports)) - for _, rep := range reports { - rc[string(rep.Reason)]++ - userReports = append(userReports, UserReport{ - UserID: rep.UserID, - Username: rep.Username, - Reason: string(rep.Reason), - Notes: rep.Notes, - CreatedAt: rep.CreatedAt, - }) - } - out = append(out, AdminQueueRow{ - TrackID: r.TrackID, - TrackTitle: r.TrackTitle, - ArtistName: r.ArtistName, - AlbumTitle: r.AlbumTitle, - AlbumID: r.AlbumID, - LidarrAlbumMBID: r.LidarrAlbumMbid, - ReportCount: r.ReportCount, - LatestAt: r.LatestAt, - ReasonCounts: rc, - Reports: userReports, - }) - } - return out, nil -} - -func validReason(r string) bool { - switch r { - case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": - return true - } - return false -} - -// Resolve clears all per-user quarantine rows for a track and writes an -// audit log row. Idempotent — a track with no rows still writes an audit -// entry with affected_users=0 (so admin can see "I clicked resolve on a -// track that already had no reports"). -func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) - } - // LidarrAlbumMbid stays nil here: Lidarr was not contacted. The audit - // log uses the field as a "this row triggered Lidarr" marker, so only - // DeleteViaLidarr sets it. - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} - -// quarantineSnapshot is the audit-row-ready snapshot of track + album + -// artist text columns. Used by the three admin actions. -type quarantineSnapshot struct { - TrackTitle string - ArtistName string - AlbumTitle *string - LidarrAlbumMBID *string -} - -// snapshot pulls the audit-row text columns from track / album / artist. -// AlbumTitle is materialized as *string because WriteQuarantineActionParams -// expects nullable; we coerce the schema's NOT NULL value into a pointer. -func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { - album, err := q.GetAlbumByID(ctx, track.AlbumID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) - } - artist, err := q.GetArtistByID(ctx, track.ArtistID) - if err != nil { - return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) - } - titlePtr := album.Title - return quarantineSnapshot{ - TrackTitle: track.Title, - ArtistName: artist.Name, - AlbumTitle: &titlePtr, - LidarrAlbumMBID: album.Mbid, - }, nil -} - -// DeleteFile removes the track file from disk and the tracks row (tidying away -// an album or artist that leaves empty), then — via FK ON DELETE CASCADE — -// clears every per-user quarantine row for that track and writes an audit row. -// -// If the file cannot be removed, nothing is deleted and the per-user rows stay -// so the admin can retry: the error wraps a *library.FileRemoveError, which the -// handler turns into an answer naming the cause (#3918). No partial state. -func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, err - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) - } - if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil { - return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) - } - // tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id - // already cleared the per-user rows. We don't call - // DeleteQuarantineForTrack separately. LidarrAlbumMbid stays nil — - // Lidarr was not contacted. - return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile, - AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected, - }) -} - -// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove -// the parent album with deleteFiles=true + addImportListExclusion=true, -// then removes Minstrel rows for all tracks of that album. The cascade -// on lidarr_quarantine.track_id clears per-user rows automatically. -// -// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing -// changes locally. Admin retries. -// -// Returns the audit row + the count of tracks deleted from Minstrel. -func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { - cfg, err := s.lidarrCfg.Get(ctx) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) - } - client := s.clientFn() - if !cfg.Enabled || client == nil { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled - } - - q := dbq.New(s.pool) - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) - } - snap, err := s.snapshot(ctx, q, track) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, err - } - if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { - return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing - } - - affected, err := q.CountQuarantineForTrack(ctx, trackID) - if err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) - } - - // Lidarr lookup: MBID -> internal album ID. - album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) - if err != nil { - if errors.Is(err, lidarr.ErrNotFound) { - return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound - } - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) - } - - // Lidarr DELETE — both flags true. - if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) - } - - // Now remove the local rows. CASCADE on lidarr_quarantine.track_id - // handles per-user rows. - res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) - if err != nil { - // Lidarr is already done; we couldn't update locally. Operator- - // recoverable: re-running picks up where we left off. - return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) - } - deletedCount := int(res.RowsAffected()) - - action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ - TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, - AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr, - AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, - }) - return action, deletedCount, err -} diff --git a/internal/musicbrainz/entities.go b/internal/musicbrainz/entities.go index 6b6e4e04..b7acd786 100644 --- a/internal/musicbrainz/entities.go +++ b/internal/musicbrainz/entities.go @@ -18,7 +18,7 @@ type Artist struct { } // ReleaseGroup is what a person calls an album: one work, released any -// number of times. Lidarr's albums were release groups too. +// number of times. type ReleaseGroup struct { MBID string Title string diff --git a/internal/notifications/kinds.go b/internal/notifications/kinds.go index 13a22a6e..adcbf095 100644 --- a/internal/notifications/kinds.go +++ b/internal/notifications/kinds.go @@ -23,8 +23,8 @@ const ( KindScanFailed Kind = "scan_failed" KindTracksMissing Kind = "tracks_missing" KindDuplicatesFound Kind = "duplicates_found" - // KindDuplicatesResolved: the duplicate resolver (M498) merged copies or - // changed a Lidarr release on its own. The operator asked for that to + // KindDuplicatesResolved: the duplicate resolver (M498) merged copies on + // its own. The operator asked for that to // happen automatically; this is how it stays visible. KindDuplicatesResolved Kind = "duplicates_resolved" KindPlaybackErrors Kind = "playback_errors" diff --git a/internal/notifications/render.go b/internal/notifications/render.go index 186c8d22..77e52de9 100644 --- a/internal/notifications/render.go +++ b/internal/notifications/render.go @@ -38,7 +38,7 @@ type Payload struct { // Count is the coalesced kinds' running total. Count int64 `json:"count,omitempty"` // Detail is free text: scan_failed's error message, and what the duplicate - // resolver changed in Lidarr for duplicates_resolved. + // resolver merged for duplicates_resolved. Detail string `json:"detail,omitempty"` } @@ -117,7 +117,7 @@ func Render(kind Kind, payload []byte) Rendered { Link: "/admin/duplicates", } case KindDuplicatesResolved: - body := "Copies Lidarr does not need were merged into the one it tracks." + body := "Extra copies of a song were merged into the one the album keeps." if p.Detail != "" { body = p.Detail } diff --git a/internal/playlists/service_test.go b/internal/playlists/service_test.go index 1b8f6ec2..bc46d150 100644 --- a/internal/playlists/service_test.go +++ b/internal/playlists/service_test.go @@ -459,7 +459,7 @@ func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) { pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false) _ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID}) - // Delete the track row directly (simulating Lidarr re-import / file removal). + // Delete the track row directly (simulating a re-import / file removal). if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil { t.Fatalf("delete track: %v", err) } diff --git a/internal/playlists/system_test.go b/internal/playlists/system_test.go index 48a96719..b297fc24 100644 --- a/internal/playlists/system_test.go +++ b/internal/playlists/system_test.go @@ -42,11 +42,11 @@ func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID } } -// seedQuarantine inserts a lidarr_quarantine row for (user, track). +// seedQuarantine inserts a quarantine row for (user, track). func seedQuarantine(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) { t.Helper() _, err := pool.Exec(context.Background(), ` - INSERT INTO lidarr_quarantine (user_id, track_id, reason) + INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'other') `, userID, trackID) if err != nil { diff --git a/internal/quarantine/service.go b/internal/quarantine/service.go new file mode 100644 index 00000000..f7a4661a --- /dev/null +++ b/internal/quarantine/service.go @@ -0,0 +1,267 @@ +// Package quarantine owns the per-user track quarantine workflow. +// Users flag a track as broken (Flag/Unflag), the SPA hides the track +// from their views, and admins resolve the resulting reports via the +// Service's admin actions (Resolve / DeleteFile). +package quarantine + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// Public errors. Handlers map these to API codes. +var ( + ErrBadReason = errors.New("quarantine: invalid reason") + ErrTrackNotFound = errors.New("quarantine: track not found") + ErrQuarantineNotFound = errors.New("quarantine: quarantine row not found") +) + +// Service owns the quarantine lifecycle. +type Service struct { + // dataDir lets a Delete file that empties an artist clear that artist's + // cached art, the same as the admin remove-track path. + dataDir string + pool *pgxpool.Pool +} + +// NewService constructs a Service. +func NewService(pool *pgxpool.Pool, dataDir string) *Service { + return &Service{pool: pool, dataDir: dataDir} +} + +// Flag inserts or updates a quarantine row for the caller. Re-flagging +// the same (user, track) overwrites reason+notes (and bumps created_at). +func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.Quarantine, error) { + if !validReason(reason) { + return dbq.Quarantine{}, ErrBadReason + } + var notesPtr *string + if notes != "" { + notesPtr = ¬es + } + row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ + UserID: userID, + TrackID: trackID, + Reason: dbq.QuarantineReason(reason), + Notes: notesPtr, + }) + if err != nil { + // FK violation on track_id (no such track) → ErrTrackNotFound so the + // handler can return 404 instead of a generic 500. Code 23503 is the + // Postgres SQLSTATE for foreign_key_violation; the constraint check + // guards against future FKs (e.g. user_id) firing the same code. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" && + (pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) { + return dbq.Quarantine{}, ErrTrackNotFound + } + return dbq.Quarantine{}, fmt.Errorf("upsert: %w", err) + } + return row, nil +} + +// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no +// row exists for that (user, track). +func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { + _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ + UserID: userID, TrackID: trackID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuarantineNotFound + } + return fmt.Errorf("delete: %w", err) + } + return nil +} + +// ListMine returns the caller's quarantines with track + album + artist +// detail. Drives /library/hidden. Ordered newest-first. +func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { + return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) +} + +// AdminQueueRow is the assembled aggregated row served by the admin +// queue endpoint. The handler post-processes the SQL result + a per-track +// ListQuarantineReports call to materialize reason_counts and the +// per-user reports list. +type AdminQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle string + AlbumID pgtype.UUID + ReportCount int32 + LatestAt pgtype.Timestamptz + ReasonCounts map[string]int + Reports []UserReport +} + +// UserReport is the per-user detail under an aggregated admin row. +type UserReport struct { + UserID pgtype.UUID + Username string + Reason string + Notes *string + CreatedAt pgtype.Timestamptz +} + +// ListAdminQueue returns the aggregated admin queue. One row per track. +// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row) +// because materializing reason_counts in pure SQL is awkward; the queue +// is small in practice (≲100 tracks at household scale). Revisit with +// json_object_agg if the queue grows. +func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { + q := dbq.New(s.pool) + aggregated, err := q.ListAdminQuarantineQueue(ctx) + if err != nil { + return nil, fmt.Errorf("aggregate: %w", err) + } + out := make([]AdminQueueRow, 0, len(aggregated)) + for _, r := range aggregated { + reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) + if err != nil { + return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) + } + rc := make(map[string]int, len(reports)) + userReports := make([]UserReport, 0, len(reports)) + for _, rep := range reports { + rc[string(rep.Reason)]++ + userReports = append(userReports, UserReport{ + UserID: rep.UserID, + Username: rep.Username, + Reason: string(rep.Reason), + Notes: rep.Notes, + CreatedAt: rep.CreatedAt, + }) + } + out = append(out, AdminQueueRow{ + TrackID: r.TrackID, + TrackTitle: r.TrackTitle, + ArtistName: r.ArtistName, + AlbumTitle: r.AlbumTitle, + AlbumID: r.AlbumID, + ReportCount: r.ReportCount, + LatestAt: r.LatestAt, + ReasonCounts: rc, + Reports: userReports, + }) + } + return out, nil +} + +func validReason(r string) bool { + switch r { + case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": + return true + } + return false +} + +// Resolve clears all per-user quarantine rows for a track and writes an +// audit log row. Idempotent — a track with no rows still writes an audit +// entry with affected_users=0 (so admin can see "I clicked resolve on a +// track that already had no reports"). +func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.QuarantineAction{}, ErrTrackNotFound + } + return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.QuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { + return dbq.QuarantineAction{}, fmt.Errorf("delete rows: %w", err) + } + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindResolved, + AdminID: adminID, AffectedUsers: affected, + }) +} + +// quarantineSnapshot is the audit-row-ready snapshot of track + album + +// artist text columns. Used by the admin actions. +type quarantineSnapshot struct { + TrackTitle string + ArtistName string + AlbumTitle *string +} + +// snapshot pulls the audit-row text columns from track / album / artist. +// AlbumTitle is materialized as *string because WriteQuarantineActionParams +// expects nullable; we coerce the schema's NOT NULL value into a pointer. +func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { + album, err := q.GetAlbumByID(ctx, track.AlbumID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) + } + artist, err := q.GetArtistByID(ctx, track.ArtistID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) + } + titlePtr := album.Title + return quarantineSnapshot{ + TrackTitle: track.Title, + ArtistName: artist.Name, + AlbumTitle: &titlePtr, + }, nil +} + +// DeleteFile removes the track file from disk and the tracks row (tidying away +// an album or artist that leaves empty), then — via FK ON DELETE CASCADE — +// clears every per-user quarantine row for that track and writes an audit row. +// +// If the file cannot be removed, nothing is deleted and the per-user rows stay +// so the admin can retry: the error wraps a *library.FileRemoveError, which the +// handler turns into an answer naming the cause (#3918). No partial state. +func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.QuarantineAction{}, ErrTrackNotFound + } + return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.QuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err) + } + if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil { + return dbq.QuarantineAction{}, fmt.Errorf("delete file: %w", err) + } + // tracks row is gone; ON DELETE CASCADE on quarantine.track_id already + // cleared the per-user rows. We don't call DeleteQuarantineForTrack + // separately. + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindDeletedFile, + AdminID: adminID, AffectedUsers: affected, + }) +} diff --git a/internal/lidarrquarantine/service_test.go b/internal/quarantine/service_test.go similarity index 55% rename from internal/lidarrquarantine/service_test.go rename to internal/quarantine/service_test.go index cfa21359..6bb302fb 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/quarantine/service_test.go @@ -1,12 +1,10 @@ -package lidarrquarantine +package quarantine import ( "context" "errors" "io" "log/slog" - "net/http" - "net/http/httptest" "os" "path/filepath" "testing" @@ -17,8 +15,6 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) func newPool(t *testing.T) *pgxpool.Pool { @@ -87,7 +83,7 @@ func TestFlag_HappyPath(t *testing.T) { user := seedUser(t, pool, "alice") track, _, _ := seedTrack(t, pool, "Bad Track", "abc") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") if err != nil { t.Fatalf("Flag: %v", err) @@ -105,7 +101,7 @@ func TestFlag_UpsertOnSecondFlag(t *testing.T) { user := seedUser(t, pool, "alice") track, _, _ := seedTrack(t, pool, "T", "x") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { t.Fatalf("first flag: %v", err) } @@ -129,7 +125,7 @@ func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) { bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} bogus.Valid = true - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") _, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "") if !errors.Is(err, ErrTrackNotFound) { t.Errorf("err = %v, want ErrTrackNotFound", err) @@ -141,7 +137,7 @@ func TestFlag_BadReasonRejected(t *testing.T) { user := seedUser(t, pool, "alice") track, _, _ := seedTrack(t, pool, "T", "x") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") _, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "") if !errors.Is(err, ErrBadReason) { t.Errorf("err = %v, want ErrBadReason", err) @@ -153,7 +149,7 @@ func TestUnflag_DeletesRow(t *testing.T) { user := seedUser(t, pool, "alice") track, _, _ := seedTrack(t, pool, "T", "x") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { t.Fatalf("Flag: %v", err) } @@ -171,7 +167,7 @@ func TestListMine_OrderedNewestFirst(t *testing.T) { t1, _, _ := seedTrack(t, pool, "T1", "x") t2, _, _ := seedTrack(t, pool, "T2", "y") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil { t.Fatalf("Flag t1: %v", err) } @@ -187,8 +183,8 @@ func TestListMine_OrderedNewestFirst(t *testing.T) { t.Fatalf("len = %d, want 2", len(rows)) } // T2 was flagged second — newest first. - if rows[0].LidarrQuarantine.TrackID != t2.ID { - t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) + if rows[0].Quarantine.TrackID != t2.ID { + t.Errorf("first row track = %v, want T2 (%v)", rows[0].Quarantine.TrackID, t2.ID) } } @@ -199,7 +195,7 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { carol := seedUser(t, pool, "carol") track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil { t.Fatalf("alice flag: %v", err) } @@ -235,7 +231,7 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { bob := seedUser(t, pool, "bob") track, _, _ := seedTrack(t, pool, "T", "x") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil { t.Fatalf("alice flag: %v", err) } @@ -250,12 +246,9 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { if audit.AffectedUsers != 2 { t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) } - if audit.Action != dbq.LidarrQuarantineActionKindResolved { + if audit.Action != dbq.QuarantineActionKindResolved { t.Errorf("action = %v, want resolved", audit.Action) } - if audit.LidarrAlbumMbid != nil { - t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid) - } n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) if n != 0 { t.Errorf("rows after resolve = %d, want 0", n) @@ -267,7 +260,7 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) { user := seedUser(t, pool, "alice") track, _, _ := seedTrack(t, pool, "T", "x") - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") // No flags applied — resolve a track with zero quarantine rows. audit, err := svc.Resolve(context.Background(), track.ID, user.ID) if err != nil { @@ -276,12 +269,9 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) { if audit.AffectedUsers != 0 { t.Errorf("affected_users = %d, want 0", audit.AffectedUsers) } - if audit.Action != dbq.LidarrQuarantineActionKindResolved { + if audit.Action != dbq.QuarantineActionKindResolved { t.Errorf("action = %v, want resolved", audit.Action) } - if audit.LidarrAlbumMbid != nil { - t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid) - } } func TestResolve_TrackNotFound(t *testing.T) { @@ -291,7 +281,7 @@ func TestResolve_TrackNotFound(t *testing.T) { bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} bogus.Valid = true - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") _, err := svc.Resolve(context.Background(), bogus, user.ID) if !errors.Is(err, ErrTrackNotFound) { t.Errorf("err = %v, want ErrTrackNotFound", err) @@ -315,7 +305,7 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", }) - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { t.Fatalf("Flag: %v", err) } @@ -327,12 +317,9 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { if audit.AffectedUsers != 1 { t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) } - if audit.Action != dbq.LidarrQuarantineActionKindDeletedFile { + if audit.Action != dbq.QuarantineActionKindDeletedFile { t.Errorf("action = %v, want deleted_file", audit.Action) } - if audit.LidarrAlbumMbid != nil { - t.Errorf("lidarr_album_mbid = %v, want nil for delete_file", audit.LidarrAlbumMbid) - } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("file still exists: %v", err) } @@ -342,156 +329,6 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { } } -func TestDeleteViaLidarr_FullCascade(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - var captured []string - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) - 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","title":"Al","artistId":7}]`)) - return - } - // DELETE /api/v1/album/42 -> 200. - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - - cfg := lidarrconfig.New(pool) - if err := cfg.Save(context.Background(), lidarrconfig.Config{ - Enabled: true, BaseURL: stub.URL, APIKey: "k", - }); err != nil { - t.Fatalf("save config: %v", err) - } - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn, "") - - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - albumMBID := "al-mbid" - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, - }) - dir := t.TempDir() - path := filepath.Join(dir, "T.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { - t.Fatalf("Flag: %v", err) - } - - audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if err != nil { - t.Fatalf("DeleteViaLidarr: %v", err) - } - if deleted != 1 { - t.Errorf("deleted = %d, want 1 track removed", deleted) - } - if audit.Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr { - t.Errorf("action = %v", audit.Action) - } - if audit.AffectedUsers != 1 { - t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) - } - if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { - t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) - } - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still exists") - } - // Verify Lidarr received DELETE with both flags true (query order may vary). - foundDelete := false - for _, c := range captured { - if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || - c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { - foundDelete = true - } - } - if !foundDelete { - t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) - } -} - -func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - track, _, _ := seedTrack(t, pool, "T", "x") - - svc := NewService(pool, lidarrconfig.New(pool), nil, "") - _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if !errors.Is(err, ErrLidarrDisabled) { - t.Errorf("err = %v, want ErrLidarrDisabled", err) - } -} - -func TestDeleteViaLidarr_AlbumMBIDMissing(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - q := dbq.New(pool) - artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) - // Album with NO mbid set. - album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Al", SortTitle: "Al", ArtistID: artist.ID, // Mbid: nil - }) - dir := t.TempDir() - path := filepath.Join(dir, "T.mp3") - _ = os.WriteFile(path, []byte("x"), 0o644) - track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - }) - - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - cfg := lidarrconfig.New(pool) - _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn, "") - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if !errors.Is(err, ErrAlbumMBIDMissing) { - t.Errorf("err = %v, want ErrAlbumMBIDMissing", err) - } -} - -func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - 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("[]")) // Lidarr returns empty -> ErrNotFound - return - } - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - cfg := lidarrconfig.New(pool) - _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn, "") - - track, _, _ := seedTrack(t, pool, "T", "x") - _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") - - _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) - if !errors.Is(err, ErrLidarrAlbumNotFound) { - t.Errorf("err = %v, want ErrLidarrAlbumNotFound", err) - } -} - func TestDeleteFile_TrackNotFound(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "alice") @@ -499,36 +336,9 @@ func TestDeleteFile_TrackNotFound(t *testing.T) { bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} bogus.Valid = true - svc := NewService(pool, lidarrconfig.New(pool), nil, "") + svc := NewService(pool, "") _, err := svc.DeleteFile(context.Background(), bogus, user.ID) if !errors.Is(err, ErrTrackNotFound) { t.Errorf("err = %v, want ErrTrackNotFound", err) } } - -func TestDeleteViaLidarr_TrackNotFound(t *testing.T) { - pool := newPool(t) - user := seedUser(t, pool, "alice") - - stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(stub.Close) - cfg := lidarrconfig.New(pool) - if err := cfg.Save(context.Background(), lidarrconfig.Config{ - Enabled: true, BaseURL: stub.URL, APIKey: "k", - }); err != nil { - t.Fatalf("save config: %v", err) - } - clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } - svc := NewService(pool, cfg, clientFn, "") - - var bogus pgtype.UUID - bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} - bogus.Valid = true - - _, _, err := svc.DeleteViaLidarr(context.Background(), bogus, user.ID) - if !errors.Is(err, ErrTrackNotFound) { - t.Errorf("err = %v, want ErrTrackNotFound", err) - } -} diff --git a/internal/reacquisition/settings.go b/internal/reacquisition/settings.go index f465ddd4..466ec07f 100644 --- a/internal/reacquisition/settings.go +++ b/internal/reacquisition/settings.go @@ -1,6 +1,6 @@ // Package reacquisition wants an album back when its files go missing, -// without anyone pressing anything (milestone #290; a want rather than a -// Lidarr request since M502 #5520). +// without anyone pressing anything (milestone #290; a want since M502 +// #5520). // // The unit of work is the ALBUM, not the track. Music is acquired as // releases; there is no meaningful "fetch me one track", and a recording diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index c4353f28..02b94e8f 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -158,7 +158,7 @@ func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) { insertPlayEvent(t, pool, user.ID, tQuar.ID, now) } 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, tQuar.ID, ); err != nil { t.Fatalf("insert quarantine: %v", err) diff --git a/internal/release/quality.go b/internal/release/quality.go index bfde8fc1..055cc773 100644 --- a/internal/release/quality.go +++ b/internal/release/quality.go @@ -6,7 +6,7 @@ import ( "strings" ) -// Tier is a rung of the quality ladder, Lidarr's names for them. +// Tier is a rung of the quality ladder. type Tier string // The ladder, best first. @@ -43,8 +43,8 @@ var Tiers = []TierInfo{ {TierUnknown, "Unknown", "Nothing says what format it is"}, } -// DefaultTiers is what a new install allows: Lidarr's "Any" profile, every -// format from trash lossy to lossless (note #5527). Unknown is left out; a +// DefaultTiers is what a new install allows: every format from trash lossy +// to lossless (note #5527). Unknown is left out; a // release that names no format has its .torrent read to find out. var DefaultTiers = []Tier{ TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard, @@ -182,7 +182,7 @@ type HeldFormat struct { } // HeldCopy judges an album's copy from its tracks grouped by format: the -// lowest tier among them, as Lidarr judged an album by its worst file. +// lowest tier among them: an album is as good as its worst file. // Summing a format's tracks first keeps a near-silent VBR interlude from // dragging the album down. ok is false when no format can be placed. func HeldCopy(formats []HeldFormat) (Held, bool) { diff --git a/internal/release/release_test.go b/internal/release/release_test.go index 91ea3843..5f7ae12c 100644 --- a/internal/release/release_test.go +++ b/internal/release/release_test.go @@ -16,7 +16,7 @@ func TestParse_ReadsWhatTheNameSays(t *testing.T) { rate float64 tier Tier }{ - // The wrong edition Lidarr grabbed on 2026-10-09 (note #5527). + // The wrong edition grabbed on 2026-10-09 (note #5527). {"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes}, {"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless}, {"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh}, diff --git a/internal/server/server.go b/internal/server/server.go index 8fcc1fbe..b96c420a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,7 +3,6 @@ package server import ( "context" "encoding/json" - "errors" "log/slog" "net/http" "strings" @@ -20,13 +19,11 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "git.fabledsword.com/bvandeusen/minstrel/internal/library" - "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" - "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/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/requests" @@ -36,27 +33,6 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/web" ) -// lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern -// (used elsewhere in this package so admin Lidarr config edits take effect -// without restart) into the tracks.LidarrUnmonitorer interface that -// internal/tracks expects. When the factory returns nil (Lidarr disabled) -// we surface a sentinel error — tracks.Service treats UnmonitorTrack -// failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed` -// flag in the response and the destructive part still runs. -type lidarrUnmonitorAdapter struct { - fn func() *lidarr.Client -} - -var errLidarrDisabled = errors.New("lidarr disabled") - -func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error { - c := a.fn() - if c == nil { - return errLidarrDisabled - } - return c.UnmonitorTrack(ctx, trackMbid, albumMbid) -} - // ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an // interface so tests can stub it without touching the DB. The progressCb // parameter (added in m7-scan-progress) lets the orchestrator drive partial- @@ -84,7 +60,7 @@ type Server struct { LibraryScanner *library.Scanner ScanCfg library.RunScanConfig // Bus is the live-event bus shared with background workers (the - // lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go. + // request reconciler, scan workers) constructed in cmd/minstrel/main.go. // When nil, Router() constructs a local fallback (test contexts). Bus *eventbus.Bus // PlaylistScheduler fires per-user daily system-playlist builds at @@ -159,18 +135,6 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxCompletionRatio, s.EventsCfg.SkipMaxDurationPlayedMs, ) - lidarrCfg := lidarrconfig.New(s.Pool) - // Per-call client factory: re-reads config so an admin save in - // Admin › Acquisition takes effect immediately without restart. - // Returns nil when Lidarr is disabled, which the Service-layer - // methods translate to ErrLidarrDisabled. - lidarrClientFn := func() *lidarr.Client { - cfg, err := lidarrCfg.Get(context.Background()) - if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" { - return nil - } - return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) - } requestSvc := requests.NewService(s.Pool) reacqSettings := s.ReacqSettings if reacqSettings == nil { @@ -185,13 +149,13 @@ func (s *Server) Router() http.Handler { s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr) } } - lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn, s.DataDir) - tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir) + quarantineSvc := quarantine.NewService(s.Pool, s.DataDir) + tracksSvc := tracks.NewService(s.Pool, s.Logger, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer")) // Live-event bus for SSE subscribers (#392). Constructed per-process; // producers in playevents / requests / scanner publish into - // the same instance. Background workers (lidarr reconciler, scan + // the same instance. Background workers (request reconciler, scan // scheduler) live in cmd/minstrel/main.go where they're constructed // before Router() runs; they read s.Bus directly so they share this // process's bus. When Router() runs before main set the bus (tests, @@ -237,7 +201,7 @@ func (s *Server) Router() http.Handler { } acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil) } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, requestSvc, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, requestSvc, quarantineSvc, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c949ed42..2e689688 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -155,7 +155,7 @@ func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { // in server.go was creating a second chi subtree at the same prefix as the // nested admin Route inside api.Mount. chi.Walk shows both subtrees as // registered, but runtime routing dispatch only matches one — every admin -// endpoint registered by api.Mount (Lidarr config, quarantine, etc.) +// endpoint registered by api.Mount (summary, quarantine, etc.) // silently 404'd for authenticated callers in production. // // chi.Walk enumeration would NOT have caught this (both branches are in @@ -212,7 +212,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) { ts := httptest.NewServer(s.Router()) defer ts.Close() - // Each path is registered by a different package: lidarr/config from + // Each path is registered by a different package: admin/summary from // api.Mount and admin/scan from server.Router. If chi runtime dispatch // only routes one /api/admin subtree, one of these returns 404 — that // IS the bug we shipped in 06a1fe1. @@ -221,9 +221,9 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) { path string owner string }{ - {http.MethodGet, "/api/admin/lidarr/config", "api.Mount"}, + {http.MethodGet, "/api/admin/summary", "api.Mount"}, // /api/admin/scan would actually trigger a real library scan via - // stubScanner; we don't include it. The lidarr/config path is the + // stubScanner; we don't include it. The admin/summary path is the // canonical regression check. } for _, tc := range cases { diff --git a/internal/similarity/worker.go b/internal/similarity/worker.go index c2d5078a..5123f2e8 100644 --- a/internal/similarity/worker.go +++ b/internal/similarity/worker.go @@ -6,8 +6,8 @@ // For recordings it keeps LB's whole answer in listenbrainz_similar_recordings // and derives the track_similarity edges from it in SQL, against whatever is // in the library at the time (#5296). Matching once at fetch time missed every -// recording that reached the library afterwards, through Lidarr or an MBID the -// AcoustID lookup filled in. For artists it stores the in-library matches in +// recording that reached the library afterwards, through an import or an MBID +// the AcoustID lookup filled in. For artists it stores the in-library matches in // artist_similarity and the rest in artist_similarity_unmatched, as before. // // Every answer is recorded as a fetch, an empty one included, and the queue @@ -85,7 +85,7 @@ func (w *Worker) tickOnce(ctx context.Context) error { return err } // Links recordings that reached the library since their seed was fetched: - // a scan, a Lidarr import, an MBID from the AcoustID lookup. + // a scan, an import, an MBID from the AcoustID lookup. if err := q.ResolveListenBrainzTrackEdges(ctx, nil); err != nil { return fmt.Errorf("resolve similarity edges: %w", err) } diff --git a/internal/tags/provider_musicbrainz.go b/internal/tags/provider_musicbrainz.go index 14aa424b..c066c7b5 100644 --- a/internal/tags/provider_musicbrainz.go +++ b/internal/tags/provider_musicbrainz.go @@ -160,7 +160,7 @@ func (p *musicbrainzProvider) TestConnection(ctx context.Context) error { // ReleaseGroupForRelease names the MusicBrainz release group a release // belongs to (#5241). Minstrel's albums.mbid is the release id from the tags, -// while Lidarr knows albums only by release group, so re-acquiring a lost +// while wants know albums only by release group, so re-acquiring a lost // album needs this translation when its files (and their release-group tag) // are already gone. // diff --git a/internal/tracks/merge_test.go b/internal/tracks/merge_test.go deleted file mode 100644 index c83d5ba7..00000000 --- a/internal/tracks/merge_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package tracks - -import ( - "testing" - - "git.fabledsword.com/bvandeusen/minstrel/internal/library" -) - -func mbids(track, album string) library.MergedCopy { - c := library.MergedCopy{} - if track != "" { - c.TrackMbid = &track - } - if album != "" { - c.AlbumMbid = &album - } - return c -} - -// Unmonitoring a second file of the kept copy's own album track would stop -// Lidarr managing the kept file too; that case must be recognised. -func TestSameLidarrTrack(t *testing.T) { - for _, tc := range []struct { - name string - kept library.MergedCopy - removed library.MergedCopy - wantSame bool - }{ - {"same recording on the same album", mbids("rec", "alb"), mbids("rec", "alb"), true}, - {"same recording on a compilation", mbids("rec", "alb"), mbids("rec", "comp"), false}, - {"different recordings on one album", mbids("rec", "alb"), mbids("rec-2", "alb"), false}, - {"kept copy has no mbids", mbids("", ""), mbids("rec", "alb"), false}, - {"removed copy has no album mbid", mbids("rec", "alb"), mbids("rec", ""), false}, - } { - if got := sameLidarrTrack(tc.kept, tc.removed); got != tc.wantSame { - t.Errorf("%s: sameLidarrTrack = %v, want %v", tc.name, got, tc.wantSame) - } - } -} diff --git a/internal/tracks/service.go b/internal/tracks/service.go index 1d576bd1..ba669460 100644 --- a/internal/tracks/service.go +++ b/internal/tracks/service.go @@ -1,17 +1,7 @@ // Package tracks owns the track-level admin actions behind DELETE -// /api/admin/tracks/{id}. Today that's RemoveTrack: the destructive part goes -// through library.DeleteTrackFile — the one path that deletes a track file — -// and when the operator opts in via `unmonitor=true` the service also tells -// Lidarr to flip the track's monitored flag off so Lidarr doesn't search for a -// replacement. -// -// History: an earlier shape (commit 50a231f, since rewritten) routed -// Lidarr-managed tracks through lidarrquarantine.DeleteViaLidarr — but -// that primitive deletes the entire **album** in Lidarr (Lidarr is -// album-granular and has no per-track delete API), which would silently -// drop sibling tracks the operator didn't ask to remove. The current -// shape per spec revision 723eee9 is "always direct delete; opt-in -// Lidarr unmonitor for replacement-suppression." +// /api/admin/tracks/{id} and the duplicate merge. RemoveTrack's destructive +// part goes through library.DeleteTrackFile — the one path that deletes a +// track file. // // The web track-kebab entry that called this was removed in f7278f24; the // endpoint was kept deliberately for a safer admin surface to rebind. @@ -23,13 +13,11 @@ import ( "fmt" "log/slog" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/library" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) @@ -40,55 +28,35 @@ import ( // same pointer. var ErrNotFound = apierror.ErrNotFound -// LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses. -// Defined as an interface so tests can stub without spinning up a Lidarr -// httptest server. UnmonitorTrack failures are non-fatal at the service -// layer — the file + DB are already gone — so any error returned here -// surfaces as a `lidarr_unmonitor_failed` flag in the response, not as -// a hard error. -type LidarrUnmonitorer interface { - UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error -} - -// Service owns RemoveTrack. lidarr may be nil — when it is, the -// unmonitor branch is skipped entirely (with `lidarrUnmonitorFailed` -// remaining false) regardless of the unmonitor query param. This is -// the right fallback when Lidarr isn't configured: file + DB delete -// still happen. +// Service owns RemoveTrack and MergeDuplicates. type Service struct { pool *pgxpool.Pool logger *slog.Logger - lidarr LidarrUnmonitorer dataDir string } // NewService constructs a Service. logger may be nil (defaults to -// slog.Default). lidarr may be nil to disable the unmonitor branch. -// dataDir is the on-disk root for cached artifacts; used to clean up -// artist-art on artist delete. Empty string disables the cleanup. -func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitorer, dataDir string) *Service { +// slog.Default). dataDir is the on-disk root for cached artifacts; used to +// clean up artist-art on artist delete. Empty string disables the cleanup. +func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service { if logger == nil { logger = slog.Default() } - return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir} + return &Service{pool: pool, logger: logger, dataDir: dataDir} } -// RemoveTrack deletes the track's file and then its row, tidies away an album -// or artist the delete empties, and (when unmonitor is true and the track is -// Lidarr-managed) tells Lidarr to flip the track's monitored flag off so it -// won't search for a replacement. +// RemoveTrack deletes the track's file and then its row, and tidies away an +// album or artist the delete empties. // // Returns: // - deletedAlbumID: non-nil when removing the track left the album // empty and the album row was deleted. // - deletedArtistID: non-nil when both album AND artist were left // empty (only set if deletedAlbumID is also set). -// - lidarrUnmonitorFailed: true when the operator requested unmonitor -// and the Lidarr call failed; the file + DB delete still succeeded. // - err: ErrNotFound, or a failure before anything was deleted. When the // file cannot be removed it is a *library.FileRemoveError and NOTHING was // deleted — see library.DeleteTrackFile for why that order is the contract -// (#3918). A failed Lidarr unmonitor is reflected in the bool, not here. +// (#3918). // // adminID is currently unused — the cascade audit-log line that would // reference it isn't wired in this slice. It's threaded through the @@ -97,92 +65,25 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitore func (s *Service) RemoveTrack( ctx context.Context, trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up - unmonitor bool, -) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, lidarrUnmonitorFailed bool, err error) { - q := dbq.New(s.pool) - - track, err := q.GetTrackByID(ctx, trackID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, false, ErrNotFound - } - return nil, nil, false, fmt.Errorf("get track: %w", err) - } - - // Capture the album's mbid *before* the delete. If removing this track - // empties the album, its row is gone afterwards and the unmonitor walk - // would have no album mbid to name. - var albumMbid string - if track.Mbid != nil && *track.Mbid != "" && unmonitor && s.lidarr != nil { - alb, alerr := q.GetAlbumByID(ctx, track.AlbumID) - if alerr == nil && alb.Mbid != nil { - albumMbid = *alb.Mbid - } - // Lookup failure is tolerated — handled below as - // "no albumMbid → can't unmonitor → flag failure." - } - +) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) { deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID) if err != nil { if errors.Is(err, library.ErrTrackNotFound) { - return nil, nil, false, ErrNotFound + return nil, nil, ErrNotFound } - return nil, nil, false, fmt.Errorf("delete track: %w", err) + return nil, nil, fmt.Errorf("delete track: %w", err) } - - // Lidarr unmonitor — non-fatal. The destructive part is done; any - // failure here is informational so the operator can retry manually. - if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil { - if albumMbid == "" { - // Couldn't capture the album mbid (album row had nil mbid - // or was missing somehow). The Lidarr walk needs it; mark - // failure rather than calling with an empty string. - s.logger.Warn("track delete: lidarr unmonitor skipped — no album mbid", - "track_id", trackID, "track_mbid", *track.Mbid) - lidarrUnmonitorFailed = true - } else if uerr := s.lidarr.UnmonitorTrack(ctx, *track.Mbid, albumMbid); uerr != nil { - s.logger.Warn("track delete: lidarr unmonitor failed", - "track_id", trackID, "track_mbid", *track.Mbid, "err", uerr) - lidarrUnmonitorFailed = true - } - } - - return deleted.AlbumID, deleted.ArtistID, lidarrUnmonitorFailed, nil + return deleted.AlbumID, deleted.ArtistID, nil } // MergeDuplicates merges a duplicate group into the copy to keep -// (library.MergeDuplicateGroup), then — when asked — tells Lidarr to stop -// monitoring the removed copies so it does not download them again, and records -// the merge in the audit log. -// -// lidarrUnmonitorFailed reports that unmonitoring was asked for and at least one -// removed copy could not be unmonitored. Like RemoveTrack, that never fails the -// merge: the files and rows are already gone. +// (library.MergeDuplicateGroup) and records the merge in the audit log. func (s *Service) MergeDuplicates( - ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool, -) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) { - res, err = library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID) + ctx context.Context, groupID, survivorID, actorID pgtype.UUID, +) (library.MergeResult, error) { + res, err := library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID) if err != nil { - return res, false, err - } - - if unmonitor && s.lidarr != nil { - for _, removed := range res.Removed { - if sameLidarrTrack(res.Survivor, removed) { - continue - } - if !hasLidarrIdentity(removed) { - s.logger.Warn("duplicate merge: lidarr unmonitor skipped — removed copy has no mbids", - "track_id", syncpkg.FormatUUID(removed.TrackID)) - lidarrUnmonitorFailed = true - continue - } - if uerr := s.lidarr.UnmonitorTrack(ctx, *removed.TrackMbid, *removed.AlbumMbid); uerr != nil { - s.logger.Warn("duplicate merge: lidarr unmonitor failed", - "track_id", syncpkg.FormatUUID(removed.TrackID), "err", uerr) - lidarrUnmonitorFailed = true - } - } + return res, err } removed := make([]map[string]string, 0, len(res.Removed)) @@ -200,19 +101,5 @@ func (s *Service) MergeDuplicates( "likes": res.Likes, "playlist_entries": res.PlaylistEntries, }, }) - return res, lidarrUnmonitorFailed, nil -} - -// sameLidarrTrack reports whether two copies are the same Lidarr track: one -// recording on one album. Lidarr monitors per album track, so when the removed -// copy is a second file of the kept copy's own album track — the #3885 case — -// unmonitoring it would also stop Lidarr managing the file the operator chose to -// keep. Those are skipped, and are not a failure. -func sameLidarrTrack(a, b library.MergedCopy) bool { - return hasLidarrIdentity(a) && hasLidarrIdentity(b) && - *a.TrackMbid == *b.TrackMbid && *a.AlbumMbid == *b.AlbumMbid -} - -func hasLidarrIdentity(c library.MergedCopy) bool { - return c.TrackMbid != nil && *c.TrackMbid != "" && c.AlbumMbid != nil && *c.AlbumMbid != "" + return res, nil } diff --git a/internal/tracks/service_test.go b/internal/tracks/service_test.go index 0456619a..0bd229b7 100644 --- a/internal/tracks/service_test.go +++ b/internal/tracks/service_test.go @@ -17,7 +17,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) -// --- test seed helpers (inlined; mirrors lidarrquarantine patterns) ----- +// --- test seed helpers (inlined; mirrors quarantine patterns) ----------- func newPool(t *testing.T) *pgxpool.Pool { t.Helper() @@ -95,23 +95,6 @@ func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePa return artist, album, track } -// --- fake LidarrUnmonitorer -------------------------------------------- - -// fakeLidarrUnmonitorer captures call args + a configurable error so -// tests can assert "called with mbid X" and "RemoveTrack tolerated this -// Lidarr error." -type fakeLidarrUnmonitorer struct { - calls []string // captured trackMbid per call - albumMbid string // last albumMbid received - err error -} - -func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error { - f.calls = append(f.calls, trackMbid) - f.albumMbid = albumMbid - return f.err -} - // --- tests -------------------------------------------------------------- func TestRemoveTrack_NotFound(t *testing.T) { @@ -122,8 +105,8 @@ func TestRemoveTrack_NotFound(t *testing.T) { bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} bogus.Valid = true - svc := NewService(pool, nil, nil, "") - _, _, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID, false) + svc := NewService(pool, nil, "") + _, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID) if !errors.Is(err, ErrNotFound) { t.Errorf("err = %v, want ErrNotFound", err) } @@ -138,18 +121,13 @@ func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) { if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { t.Fatal(err) } - // mbid empty → no Lidarr branch even with unmonitor=true. _, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path) - fake := &fakeLidarrUnmonitorer{} - svc := NewService(pool, nil, fake, "") - _, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false) + svc := NewService(pool, nil, "") + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) if err != nil { t.Fatalf("RemoveTrack: %v", err) } - if lidarrFailed { - t.Error("lidarrUnmonitorFailed = true; want false on local-file happy path") - } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Errorf("file still present: %v", err) } @@ -157,9 +135,6 @@ func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) { if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { t.Errorf("track row still exists") } - if len(fake.calls) != 0 { - t.Errorf("Lidarr called %d times; want 0 (no mbid, unmonitor=false)", len(fake.calls)) - } } func TestRemoveTrack_FileAlreadyMissing(t *testing.T) { @@ -170,8 +145,8 @@ func TestRemoveTrack_FileAlreadyMissing(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist.mp3") _, _, track := seedArtistAlbumTrack(t, pool, "Ghost", "", missing) - svc := NewService(pool, nil, nil, "") - _, _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false) + svc := NewService(pool, nil, "") + _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) if err != nil { t.Fatalf("RemoveTrack: %v", err) } @@ -226,8 +201,8 @@ func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) { t.Fatalf("trackB: %v", err) } - svc := NewService(pool, nil, nil, "") - delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID, false) + svc := NewService(pool, nil, "") + delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID) if err != nil { t.Fatalf("RemoveTrack: %v", err) } @@ -259,8 +234,8 @@ func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) { } artist, album, track := seedArtistAlbumTrack(t, pool, "Lone", "", path) - svc := NewService(pool, nil, nil, "") - delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false) + svc := NewService(pool, nil, "") + delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID) if err != nil { t.Fatalf("RemoveTrack: %v", err) } @@ -278,174 +253,3 @@ func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) { t.Errorf("artist still exists after cascade") } } - -// unmonitor=true AND mbid set → Lidarr called once; lidarrUnmonitorFailed=false. -// Uses two albums on the same artist so the album's row survives the -// cascade — that lets us assert the album mbid (captured pre-tx) is -// passed through correctly. -func TestRemoveTrack_Unmonitor_HappyPath(t *testing.T) { - pool := newPool(t) - admin := seedAdmin(t, pool, "alice") - - q := dbq.New(pool) - artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "Lidarr Artist", SortName: "Lidarr Artist", - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - albumMbid := "album-mbid-xyz" - album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "Lidarr Album", SortTitle: "Lidarr Album", - ArtistID: artist.ID, Mbid: &albumMbid, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - dir := t.TempDir() - pathA := filepath.Join(dir, "a.mp3") - pathB := filepath.Join(dir, "b.mp3") - _ = os.WriteFile(pathA, []byte("x"), 0o644) - _ = os.WriteFile(pathB, []byte("x"), 0o644) - trackMbid := "track-mbid-abc" - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T1", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3", - Mbid: &trackMbid, - }) - if err != nil { - t.Fatalf("track1: %v", err) - } - // Sibling track keeps the album alive after the delete. - if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T2", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3", - }); err != nil { - t.Fatalf("track2: %v", err) - } - - fake := &fakeLidarrUnmonitorer{} - svc := NewService(pool, nil, fake, "") - _, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if lidarrFailed { - t.Error("lidarrUnmonitorFailed = true; want false on happy path") - } - if len(fake.calls) != 1 { - t.Fatalf("Lidarr call count = %d, want 1", len(fake.calls)) - } - if fake.calls[0] != trackMbid { - t.Errorf("Lidarr called with mbid %q, want %q", fake.calls[0], trackMbid) - } - if fake.albumMbid != albumMbid { - t.Errorf("Lidarr called with albumMbid %q, want %q", fake.albumMbid, albumMbid) - } -} - -// unmonitor=true AND Lidarr fails → file + DB still deleted, flag set, -// service returns nil error. -func TestRemoveTrack_Unmonitor_LidarrErrorIsNonFatal(t *testing.T) { - pool := newPool(t) - admin := seedAdmin(t, pool, "alice") - - q := dbq.New(pool) - artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ - Name: "ErrFatal Artist", SortName: "ErrFatal Artist", - }) - if err != nil { - t.Fatalf("artist: %v", err) - } - albumMbid := "err-album-mbid" - album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ - Title: "ErrFatal Album", SortTitle: "ErrFatal Album", - ArtistID: artist.ID, Mbid: &albumMbid, - }) - if err != nil { - t.Fatalf("album: %v", err) - } - dir := t.TempDir() - path := filepath.Join(dir, "f.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - trackMbid := "err-track-mbid" - track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ - Title: "T", AlbumID: album.ID, ArtistID: artist.ID, - DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", - Mbid: &trackMbid, - }) - if err != nil { - t.Fatalf("track: %v", err) - } - - fake := &fakeLidarrUnmonitorer{err: errors.New("simulated lidarr 5xx")} - svc := NewService(pool, nil, fake, "") - _, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true) - if err != nil { - t.Fatalf("RemoveTrack: %v (want nil — Lidarr failure is non-fatal)", err) - } - if !lidarrFailed { - t.Error("lidarrUnmonitorFailed = false; want true after Lidarr error") - } - // File + DB still gone. - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Errorf("file still present after Lidarr error: %v", err) - } - if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { - t.Errorf("track row still present after Lidarr error") - } -} - -// unmonitor=true AND mbid empty (non-Lidarr track) → no Lidarr call. -func TestRemoveTrack_Unmonitor_NoMbidSkipsLidarr(t *testing.T) { - pool := newPool(t) - admin := seedAdmin(t, pool, "alice") - - dir := t.TempDir() - path := filepath.Join(dir, "local.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - _, _, track := seedArtistAlbumTrack(t, pool, "Local", "", path) - - fake := &fakeLidarrUnmonitorer{} - svc := NewService(pool, nil, fake, "") - _, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if lidarrFailed { - t.Error("lidarrUnmonitorFailed = true; want false (no mbid)") - } - if len(fake.calls) != 0 { - t.Errorf("Lidarr call count = %d, want 0 (track has no mbid)", len(fake.calls)) - } -} - -// unmonitor=false AND mbid set → no Lidarr call. -func TestRemoveTrack_NoUnmonitor_SkipsLidarr(t *testing.T) { - pool := newPool(t) - admin := seedAdmin(t, pool, "alice") - - dir := t.TempDir() - path := filepath.Join(dir, "lidarr.mp3") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - _, _, track := seedArtistAlbumTrack(t, pool, "NoMon", "track-mbid", path) - - fake := &fakeLidarrUnmonitorer{} - svc := NewService(pool, nil, fake, "") - _, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false) - if err != nil { - t.Fatalf("RemoveTrack: %v", err) - } - if lidarrFailed { - t.Error("lidarrUnmonitorFailed = true; want false (unmonitor=false)") - } - if len(fake.calls) != 0 { - t.Errorf("Lidarr call count = %d, want 0 (unmonitor=false)", len(fake.calls)) - } -} diff --git a/internal/wanted/manual.go b/internal/wanted/manual.go index 3282cb64..5bbe91f4 100644 --- a/internal/wanted/manual.go +++ b/internal/wanted/manual.go @@ -16,7 +16,7 @@ import ( ) // Matching a library album by hand (#5579): the Collection's Unmatched -// section, like Lidarr's "Unmapped Files". A person names the MusicBrainz +// section. A person names the MusicBrainz // album an album is, by a link or a search, and it goes on the list as held; // or leaves it off for good, as a local band's album MusicBrainz will never // have. diff --git a/internal/wanted/wanted.go b/internal/wanted/wanted.go index e23ea9d5..019e1775 100644 --- a/internal/wanted/wanted.go +++ b/internal/wanted/wanted.go @@ -155,7 +155,7 @@ func (s *Service) RemoveWant(ctx context.Context, id pgtype.UUID) (bool, error) const ( // FollowRefreshAge is how often a followed artist's discography is read - // for new releases: daily, as Lidarr refreshed its artists. + // for new releases: daily. FollowRefreshAge = 24 * time.Hour // NewReleaseGrace: a group first seen after the baseline is wanted only // if it came out no earlier than this before the follow began. MusicBrainz diff --git a/web/csp.go b/web/csp.go index b255998c..7ac77736 100644 --- a/web/csp.go +++ b/web/csp.go @@ -26,8 +26,8 @@ var srcAttrRe = regexp.MustCompile(`(?i)\bsrc\s*=`) // The rest: // - style-src allows inline styles: Svelte transitions and style: // directives write them, and inline style is not a script vector. -// - img-src admits https:/http: because Lidarr suggestion art is a remote -// poster URL. Images cannot run code. +// - img-src admits https:/http: because catalogue search art is a remote +// URL. Images cannot run code. // - media-src/connect-src stay on our own origin: streams, the API and the // SSE stream are all same-origin. blob: covers Web Audio and object URLs. func contentSecurityPolicy(indexHTML []byte) string { diff --git a/web/src/lib/admin/reports.test.ts b/web/src/lib/admin/reports.test.ts index 8c3a75d8..57cc6dcf 100644 --- a/web/src/lib/admin/reports.test.ts +++ b/web/src/lib/admin/reports.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { errorCounts, mergeHandled, mergeReports } from './reports'; -import type { AdminPlaybackError, AdminQuarantineRow, LidarrQuarantineActionRow } from '#lib/api/types.js'; +import type { AdminPlaybackError, AdminQuarantineRow, QuarantineActionRow } from '#lib/api/types.js'; const flagged = (track: string, at: string): AdminQuarantineRow => ({ track_id: track, @@ -8,7 +8,6 @@ const flagged = (track: string, at: string): AdminQuarantineRow => ({ artist_name: 'A', album_title: 'Al', album_id: 'al', - lidarr_album_mbid: 'rg', report_count: 1, latest_at: at, reason_counts: { bad_rip: 1 }, @@ -40,7 +39,6 @@ describe('mergeReports', () => { expect(rows[0].reports).toHaveLength(1); expect(rows[0].errors.map((e) => e.id)).toEqual(['e1']); expect(rows[0].latest).toBe('2026-10-09T00:00:00Z'); - expect(rows[0].lidarr_album_mbid).toBe('rg'); expect(rows[2].reports).toHaveLength(0); expect(rows[2].file_path).toBe('/music/3.flac'); }); @@ -53,7 +51,7 @@ describe('mergeReports', () => { describe('mergeHandled', () => { test('cleared reports, deletions and resolved errors read as one history, newest first', () => { - const action: LidarrQuarantineActionRow = { + const action: QuarantineActionRow = { id: 'x', track_id: '1', track_title: 'T1', artist_name: 'A', action: 'deleted_file', affected_users: 1, created_at: '2026-10-02T00:00:00Z' }; diff --git a/web/src/lib/admin/reports.ts b/web/src/lib/admin/reports.ts index 7b29aa95..3720c80e 100644 --- a/web/src/lib/admin/reports.ts +++ b/web/src/lib/admin/reports.ts @@ -2,8 +2,8 @@ import type { AdminPlaybackError, AdminQuarantineReport, AdminQuarantineRow, - LidarrQuarantineActionRow, - LidarrQuarantineReason, + QuarantineActionRow, + QuarantineReason, PlaybackErrorKind } from '#lib/api/types.js'; @@ -19,14 +19,13 @@ export type ReportRow = { album: string; album_id: string; file_path: string | null; - lidarr_album_mbid: string | null; reports: AdminQuarantineReport[]; - reason_counts: Partial>; + reason_counts: Partial>; errors: AdminPlaybackError[]; latest: string; }; -export const REASON_LABELS: Record = { +export const REASON_LABELS: Record = { bad_rip: 'Bad rip', wrong_file: 'Wrong file', wrong_tags: 'Wrong tags', @@ -52,7 +51,6 @@ export function mergeReports(flagged: AdminQuarantineRow[], errors: AdminPlaybac album: q.album_title, album_id: q.album_id, file_path: null, - lidarr_album_mbid: q.lidarr_album_mbid ?? null, reports: q.reports, reason_counts: q.reason_counts, errors: [], @@ -69,7 +67,6 @@ export function mergeReports(flagged: AdminQuarantineRow[], errors: AdminPlaybac album: e.album_title, album_id: e.album_id, file_path: null, - lidarr_album_mbid: null, reports: [], reason_counts: {}, errors: [], @@ -94,10 +91,10 @@ export function errorCounts(row: ReportRow): [PlaybackErrorKind, number][] { // The Handled view: what was done about past reports, newest first. export type HandledRow = { key: string; title: string; artist: string; what: string; at: string }; -const ACTION_LABELS: Record = { +const ACTION_LABELS: Record = { resolved: 'Reports cleared', deleted_file: 'File deleted', - deleted_via_lidarr: 'Deleted through Lidarr' + deleted_album: 'Album deleted' }; const RESOLUTION_LABELS: Record = { @@ -108,7 +105,7 @@ const RESOLUTION_LABELS: Record = { requested: 'Re-requested after a playback error' }; -export function mergeHandled(actions: LidarrQuarantineActionRow[], resolved: AdminPlaybackError[]): HandledRow[] { +export function mergeHandled(actions: QuarantineActionRow[], resolved: AdminPlaybackError[]): HandledRow[] { const rows: HandledRow[] = [ ...actions.map((a) => ({ key: `a:${a.id}`, diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 2a15a3eb..fffad4fe 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -10,18 +10,12 @@ vi.mock('./client', () => ({ })); import { - getLidarrConfig, - putLidarrConfig, - testLidarrConnection, - listQualityProfiles, - listRootFolders, listAdminRequests, approveRequest, rejectRequest, listAdminQuarantine, resolveQuarantine, deleteQuarantineFile, - deleteQuarantineViaLidarr, listQuarantineActions } from './admin'; import { api } from './client'; @@ -29,22 +23,10 @@ import { qk } from './queries'; import type { ActionResult, AdminQuarantineRow, - LidarrConfig, - LidarrQualityProfile, - LidarrQuarantineActionRow, - MusicRequest, - LidarrRootFolder + QuarantineActionRow, + MusicRequest } from './types'; -const baseConfig: LidarrConfig = { - 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' -}; - const baseRow: MusicRequest = { id: 'r1', user_id: 'u1', @@ -73,83 +55,6 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe('getLidarrConfig', () => { - test('GETs /api/admin/lidarr/config', async () => { - (api.get as ReturnType).mockResolvedValueOnce(baseConfig); - const out = await getLidarrConfig(); - expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/config'); - expect(out).toBe(baseConfig); - }); -}); - -describe('putLidarrConfig', () => { - test('PUTs /api/admin/lidarr/config with the full config body', async () => { - const next: LidarrConfig = { ...baseConfig, api_key: 'plain-key' }; - (api.put as ReturnType).mockResolvedValueOnce(next); - const out = await putLidarrConfig(next); - expect(api.put).toHaveBeenCalledWith('/api/admin/lidarr/config', next); - expect(out).toBe(next); - }); -}); - -describe('testLidarrConnection', () => { - test('POSTs /api/admin/lidarr/test with empty body when no overrides', async () => { - (api.post as ReturnType).mockResolvedValueOnce({ - ok: true, - version: '2.0.0' - }); - const out = await testLidarrConnection(); - expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {}); - expect(out).toEqual({ ok: true, version: '2.0.0' }); - }); - - test('forwards override base_url + api_key body', async () => { - (api.post as ReturnType).mockResolvedValueOnce({ - ok: true, - version: '2.0.0' - }); - await testLidarrConnection({ - base_url: 'http://other:8686', - api_key: 'try-this' - }); - expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', { - base_url: 'http://other:8686', - api_key: 'try-this' - }); - }); - - test('returns ok:false branch without throwing', async () => { - (api.post as ReturnType).mockResolvedValueOnce({ - ok: false, - error: 'auth failed' - }); - const out = await testLidarrConnection(); - expect(out).toEqual({ ok: false, error: 'auth failed' }); - }); -}); - -describe('listQualityProfiles', () => { - test('GETs /api/admin/lidarr/quality-profiles', async () => { - const profiles: LidarrQualityProfile[] = [{ id: 1, name: 'Lossless' }]; - (api.get as ReturnType).mockResolvedValueOnce(profiles); - const out = await listQualityProfiles(); - expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/quality-profiles'); - expect(out).toBe(profiles); - }); -}); - -describe('listRootFolders', () => { - test('GETs /api/admin/lidarr/root-folders', async () => { - const folders: LidarrRootFolder[] = [ - { path: '/music', accessible: true, free_space: 100 } - ]; - (api.get as ReturnType).mockResolvedValueOnce(folders); - const out = await listRootFolders(); - expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/root-folders'); - expect(out).toBe(folders); - }); -}); - describe('listAdminRequests', () => { test('no args -> /api/admin/requests with no query string', async () => { (api.get as ReturnType).mockResolvedValueOnce([baseRow]); @@ -209,15 +114,6 @@ describe('rejectRequest', () => { }); describe('qk admin keys', () => { - test('lidarrConfig key', () => { - expect(qk.lidarrConfig()).toEqual(['lidarrConfig']); - }); - test('lidarrQualityProfiles key', () => { - expect(qk.lidarrQualityProfiles()).toEqual(['lidarrQualityProfiles']); - }); - test('lidarrRootFolders key', () => { - expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']); - }); test('adminRequests key uses "all" when status omitted', () => { // listAdminRequests sends no status param when called without one (returns // every status), so the cache key must distinguish that from any specific @@ -238,7 +134,6 @@ const baseQuarantineRow: AdminQuarantineRow = { artist_name: 'Aphex Twin', album_title: 'Drukqs', album_id: 'alb1', - lidarr_album_mbid: null, report_count: 1, latest_at: '2026-01-01T00:00:00Z', reason_counts: { bad_rip: 1 }, @@ -250,7 +145,7 @@ const baseAction: ActionResult = { affected_users: 1 }; -const baseActionRow: LidarrQuarantineActionRow = { +const baseActionRow: QuarantineActionRow = { id: 'a1', track_id: 't1', track_title: 'Avril 14th', @@ -258,7 +153,6 @@ const baseActionRow: LidarrQuarantineActionRow = { album_title: 'Drukqs', action: 'resolved', admin_id: 'admin1', - lidarr_album_mbid: null, affected_users: 1, created_at: '2026-01-01T00:00:00Z' }; @@ -293,18 +187,6 @@ describe('deleteQuarantineFile', () => { }); }); -describe('deleteQuarantineViaLidarr', () => { - test('POSTs /api/admin/quarantine/:trackID/delete-via-lidarr with empty body', async () => { - (api.post as ReturnType).mockResolvedValueOnce(baseAction); - const out = await deleteQuarantineViaLidarr('t1'); - expect(api.post).toHaveBeenCalledWith( - '/api/admin/quarantine/t1/delete-via-lidarr', - {} - ); - expect(out).toBe(baseAction); - }); -}); - describe('listQuarantineActions', () => { test('defaults to limit=50 when no argument', async () => { (api.get as ReturnType).mockResolvedValueOnce([baseActionRow]); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index c1b04ca2..2acddc22 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -13,14 +13,9 @@ import type { AdminPlaybackError, AdminQuarantineRow, AdminSummary, - LidarrConfig, - LidarrMetadataProfile, - LidarrQualityProfile, - LidarrQuarantineActionRow, + QuarantineActionRow, MusicRequest, RequestStatus, - LidarrRootFolder, - LidarrTestResult, PlaybackErrorResolution } from './types'; @@ -41,37 +36,6 @@ export function createAdminSummaryQuery() { }); } -// Admin Lidarr config ----------------------------------------------------- - -export async function getLidarrConfig(): Promise { - return api.get('/api/admin/lidarr/config'); -} - -export async function putLidarrConfig(cfg: LidarrConfig): Promise { - return api.put('/api/admin/lidarr/config', cfg); -} - -// testLidarrConnection always returns 200 with a discriminated-union body — -// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants -// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed"). -export async function testLidarrConnection( - body: { base_url?: string; api_key?: string } = {} -): Promise { - return api.post('/api/admin/lidarr/test', body); -} - -export async function listQualityProfiles(): Promise { - return api.get('/api/admin/lidarr/quality-profiles'); -} - -export async function listMetadataProfiles(): Promise { - return api.get('/api/admin/lidarr/metadata-profiles'); -} - -export async function listRootFolders(): Promise { - return api.get('/api/admin/lidarr/root-folders'); -} - // Admin request queue ----------------------------------------------------- export async function listAdminRequests( @@ -106,42 +70,6 @@ export async function rejectRequest( // Query factories --------------------------------------------------------- -export function createLidarrConfigQuery() { - return createQuery({ - queryKey: qk.lidarrConfig(), - queryFn: getLidarrConfig, - staleTime: 60_000 - }); -} - -// `enabled` is passed in by the caller — typically derived from -// LidarrConfig.enabled — so the query only fires once Lidarr is configured. -// Keeping it as a prop (vs. reading config inside this factory) preserves -// purity and lets the caller choose its own gating logic. -export function createQualityProfilesQuery(enabled: boolean = true) { - return createQuery({ - queryKey: qk.lidarrQualityProfiles(), - queryFn: listQualityProfiles, - enabled - }); -} - -export function createMetadataProfilesQuery(enabled: boolean = true) { - return createQuery({ - queryKey: qk.lidarrMetadataProfiles(), - queryFn: listMetadataProfiles, - enabled - }); -} - -export function createRootFoldersQuery(enabled: boolean = true) { - return createQuery({ - queryKey: qk.lidarrRootFolders(), - queryFn: listRootFolders, - enabled - }); -} - export function createAdminRequestsQuery(status?: RequestStatus) { return createQuery({ queryKey: qk.adminRequests(status), @@ -175,14 +103,10 @@ export async function deleteQuarantineFile(trackID: string): Promise(`/api/admin/quarantine/${trackID}/delete-file`, {}); } -export async function deleteQuarantineViaLidarr(trackID: string): Promise { - return api.post(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); -} - export async function listQuarantineActions( limit: number = 50 -): Promise { - return api.get( +): Promise { + return api.get( `/api/admin/quarantine/actions?limit=${limit}` ); } @@ -376,8 +300,8 @@ export type FingerprintSettings = { acoustic_max_bit_error_rate: number; backfill_concurrency: number; sweep_interval_hours: number; - // Lets the duplicate resolver act on its own (M498): merge copies Lidarr - // doesn't need and change a Lidarr release that lists songs twice. + // Lets the duplicate resolver act on its own (M498): merge copies no + // release needs. auto_resolve: boolean; }; @@ -916,7 +840,7 @@ export async function runDuplicateSweep(): Promise<{ started: boolean }> { // the group. export async function mergeDuplicateGroup( id: string, - body: { survivor_track_id: string; unmonitor: boolean } + body: { survivor_track_id: string } ): Promise { return api.post( `/api/admin/library/duplicates/${encodeURIComponent(id)}/merge`, diff --git a/web/src/lib/api/quarantine.test.ts b/web/src/lib/api/quarantine.test.ts index a8843f44..3a348904 100644 --- a/web/src/lib/api/quarantine.test.ts +++ b/web/src/lib/api/quarantine.test.ts @@ -14,11 +14,11 @@ import { flagTrack, unflagTrack, listMyQuarantine } from './quarantine'; import { api, apiFetch } from './client'; import { qk } from './queries'; import type { - LidarrQuarantineRow, - LidarrQuarantineMineRow + QuarantineRow, + QuarantineMineRow } from './types'; -const mockRow: LidarrQuarantineRow = { +const mockRow: QuarantineRow = { user_id: 'u1', track_id: 't1', reason: 'bad_rip', @@ -26,7 +26,7 @@ const mockRow: LidarrQuarantineRow = { created_at: '2026-01-01T00:00:00Z' }; -const mockMineRow: LidarrQuarantineMineRow = { +const mockMineRow: QuarantineMineRow = { track_id: 't1', reason: 'bad_rip', notes: null, diff --git a/web/src/lib/api/quarantine.ts b/web/src/lib/api/quarantine.ts index db14dbec..753af886 100644 --- a/web/src/lib/api/quarantine.ts +++ b/web/src/lib/api/quarantine.ts @@ -2,21 +2,21 @@ import { createQuery } from '@tanstack/svelte-query'; import { api, apiFetch } from './client'; import { qk } from './queries'; import type { - LidarrQuarantineRow, - LidarrQuarantineMineRow, - LidarrQuarantineReason + QuarantineRow, + QuarantineMineRow, + QuarantineReason } from './types'; export type FlagParams = { track_id: string; - reason: LidarrQuarantineReason; + reason: QuarantineReason; notes?: string; }; -export async function flagTrack(params: FlagParams): Promise { +export async function flagTrack(params: FlagParams): Promise { const body: FlagParams = { track_id: params.track_id, reason: params.reason }; if (params.notes && params.notes.length > 0) body.notes = params.notes; - return api.post('/api/quarantine', body); + return api.post('/api/quarantine', body); } // Server returns 204 (no body) for DELETE; apiFetch handles it. @@ -24,8 +24,8 @@ export async function unflagTrack(trackID: string): Promise { await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); } -export async function listMyQuarantine(): Promise { - return api.get('/api/quarantine/mine'); +export async function listMyQuarantine(): Promise { + return api.get('/api/quarantine/mine'); } export function createMyQuarantineQuery() { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 9a0c1278..16a2d521 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -29,14 +29,10 @@ export const qk = { likedAlbums: () => ['likedAlbums'] as const, likedArtists: () => ['likedArtists'] as const, history: () => ['history'] as const, - // Lidarr / requests / admin. + // Requests / admin. catalogueSearch: (q: string, kind: string) => ['catalogueSearch', { q, kind }] as const, myRequests: () => ['myRequests'] as const, - lidarrConfig: () => ['lidarrConfig'] as const, - lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, - lidarrMetadataProfiles: () => ['lidarrMetadataProfiles'] as const, - lidarrRootFolders: () => ['lidarrRootFolders'] as const, adminRequests: (status?: string) => ['adminRequests', { status: status ?? 'all' }] as const, // Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts index f4f64de8..baeb5225 100644 --- a/web/src/lib/api/requests.ts +++ b/web/src/lib/api/requests.ts @@ -9,7 +9,7 @@ import type { MusicRequest, RequestKind } from './types'; const POLL_INTERVAL_MS = 12_000; /** True if any row is mid-ingest (status === 'approved'). The status flips - to 'completed' / 'rejected' when Lidarr finishes, so this predicate + to 'completed' / 'rejected' when the music arrives, so this predicate lets the page poll exactly while there's something interesting to watch. */ export function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean { return rows?.some((r) => r.status === 'approved') ?? false; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index d3487934..a88649fd 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -188,73 +188,32 @@ export type MusicRequest = { updated_at: string; // Live ingest counters. Server fills these from the matched entity's // children. Useful for in-flight requests so the operator can see - // "5 albums · 47 tracks" while Lidarr is still working on the rest. + // "5 albums · 47 tracks" while the rest is still being fetched. imported_album_count: number; imported_track_count: number; }; -export type LidarrConfig = { - enabled: boolean; - // api_key is "" when unset, "***" when masked on GET, plain text on PUT. - base_url: string; - api_key: string; - default_quality_profile_id: number; - default_metadata_profile_id: number; - default_root_folder_path: string; -}; +// Quarantine ------------------------------------------------------------ -export type LidarrQualityProfile = { - id: number; - name: string; -}; - -export type LidarrMetadataProfile = { - id: number; - name: string; -}; - -export type LidarrRootFolder = { - path: string; - accessible: boolean; - free_space: number; -}; - -// testLidarrConnection always returns 200; callers branch on `.ok`. -// On success the response carries the live profile + folder lists so the -// integrations page can pre-fill its dropdowns on first-time setup -// without a separate round-trip. -export type LidarrTestResult = - | { - ok: true; - version: string; - quality_profiles?: { id: number; name: string }[]; - metadata_profiles?: { id: number; name: string }[]; - root_folders?: { path: string; accessible: boolean; free_space: number }[]; - list_errors?: Record; - } - | { ok: false; error: string }; - -// Lidarr quarantine ------------------------------------------------------ - -export type LidarrQuarantineReason = +export type QuarantineReason = | 'bad_rip' | 'wrong_file' | 'wrong_tags' | 'duplicate' | 'other'; -export type LidarrQuarantineRow = { +export type QuarantineRow = { user_id: string; track_id: string; - reason: LidarrQuarantineReason; + reason: QuarantineReason; notes?: string | null; created_at: string; }; // What GET /api/quarantine/mine returns per row -export type LidarrQuarantineMineRow = { +export type QuarantineMineRow = { track_id: string; - reason: LidarrQuarantineReason; + reason: QuarantineReason; notes?: string | null; created_at: string; track_title: string; @@ -273,17 +232,16 @@ export type AdminQuarantineRow = { artist_name: string; album_title: string; album_id: string; - lidarr_album_mbid?: string | null; report_count: number; latest_at: string; - reason_counts: Partial>; + reason_counts: Partial>; reports: AdminQuarantineReport[]; }; export type AdminQuarantineReport = { user_id: string; username: string; - reason: LidarrQuarantineReason; + reason: QuarantineReason; notes?: string | null; created_at: string; }; @@ -315,17 +273,19 @@ export type AdminPlaybackError = { album_id: string; }; -export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; +// deleted_album is history: an album removed whole, before Minstrel filed its +// own music (M502). +export type QuarantineAction = 'resolved' | 'deleted_file' | 'deleted_album'; -export type LidarrQuarantineActionRow = { +export type QuarantineActionRow = { id: string; track_id: string; track_title: string; artist_name: string; album_title?: string | null; - action: LidarrQuarantineAction; + action: QuarantineAction; admin_id?: string | null; - lidarr_album_mbid?: string | null; + album_mbid?: string | null; affected_users: number; created_at: string; }; @@ -333,7 +293,6 @@ export type LidarrQuarantineActionRow = { export type ActionResult = { action_id: string; affected_users: number; - deleted_track_count?: number; }; export type SeedContribution = { @@ -349,7 +308,7 @@ export type ArtistSuggestion = { name: string; score: number; attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC - image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder + image_url?: string; // absent → card placeholder // Tags this candidate shares with your taste profile, strongest first (max 3, // #2377). Absent for most candidates — tag coverage for artists you don't own // is partial by nature (#2376) — so the card falls back to seed attribution. @@ -514,12 +473,10 @@ export type AdminDuplicateSweep = { }; // What a merge did (#3911). removed_paths are the files that were deleted from -// disk; lidarr_unmonitor_failed appears only when unmonitoring was asked for and -// failed. +// disk. export type MergeDuplicateResult = { survivor_track_id: string; removed_paths: string[]; - lidarr_unmonitor_failed?: boolean; }; export type AdminDuplicatesResponse = { @@ -543,24 +500,17 @@ export type AdminDuplicatesResponse = { // The report's group tabs. 'resolved' is the third tab, served separately. export type DuplicatesView = 'review' | 'cross_release'; -// One thing the resolver did on its own (M498), from the audit log. -export type ResolvedDuplicateActivity = - | { - id: string; - action: 'duplicate_merge'; - created_at: string; - details: { - survivor_path?: string; - survivor_reason?: string; - removed?: { track_id: string; file_path: string }[]; - }; - } - | { - id: string; - action: 'lidarr_release_change'; - created_at: string; - details: { album_title?: string; artist_name?: string; from_release?: string; to_release?: string }; - }; +// One merge the resolver made on its own (M498), from the audit log. +export type ResolvedDuplicateActivity = { + id: string; + action: 'duplicate_merge'; + created_at: string; + details: { + survivor_path?: string; + survivor_reason?: string; + removed?: { track_id: string; file_path: string }[]; + }; +}; export type ResolvedDuplicatesResponse = { total: number; @@ -856,8 +806,8 @@ export type LibraryListing = { listed_at: string | null; unlisted: number }; // artist heads the list when it is narrowed to one artist's page. export type WantsPage = Page & { counts: WantCounts; library: LibraryListing; artist?: WantArtist }; -// One artist in the Collection's catalogue (#5572), like Lidarr's artist -// index: how many of their albums on the list are held, of how many. +// One artist in the Collection's catalogue (#5572): how many of their albums +// on the list are held, of how many. export type WantArtist = { artist_mbid: string; artist_name: string; diff --git a/web/src/lib/components/AcquisitionCard.svelte b/web/src/lib/components/AcquisitionCard.svelte index 3484b060..631ad512 100644 --- a/web/src/lib/components/AcquisitionCard.svelte +++ b/web/src/lib/components/AcquisitionCard.svelte @@ -13,7 +13,7 @@ // Where Minstrel looks for music and what downloads it (M502 #5518), // Fabled Librarian's model: two lists of connections the operator adds and - // configures. Nothing is seeded or copied from Lidarr. A FabledExchange is + // configures. Nothing is seeded or copied. A FabledExchange is // one entry under download clients, with a switch for each of its jobs. let services = $state(null); diff --git a/web/src/lib/components/DiscoverResultCard.test.ts b/web/src/lib/components/DiscoverResultCard.test.ts index 7057a7ef..c6b611a5 100644 --- a/web/src/lib/components/DiscoverResultCard.test.ts +++ b/web/src/lib/components/DiscoverResultCard.test.ts @@ -129,7 +129,7 @@ describe('DiscoverResultCard', () => { // --- snooze affordance (#2375) --- test('snooze button appears only when onSnooze is supplied', () => { - // The Lidarr search results reuse this card and have nothing to park, so + // The catalogue search results reuse this card and have nothing to park, so // the affordance must not appear unconditionally. render(DiscoverResultCard, { props: { kind: 'artist', title: 'Outsider', state: 'requestable' } diff --git a/web/src/lib/components/FlagPopover.svelte b/web/src/lib/components/FlagPopover.svelte index a0aadcdd..12de10f4 100644 --- a/web/src/lib/components/FlagPopover.svelte +++ b/web/src/lib/components/FlagPopover.svelte @@ -5,7 +5,7 @@ import { flagTrack } from '#lib/api/quarantine.js'; import { errCode } from '#lib/api/errors.js'; import { qk } from '#lib/api/queries.js'; - import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js'; + import type { TrackRef, QuarantineReason } from '#lib/api/types.js'; let { track, @@ -15,11 +15,11 @@ }: { track: TrackRef; onClose: () => void; - initialReason?: LidarrQuarantineReason; + initialReason?: QuarantineReason; initialNotes?: string; } = $props(); - let reason: LidarrQuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip')); + let reason: QuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip')); let notes = $state(untrack(() => initialNotes ?? '')); let submitting = $state(false); let error = $state(null); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index d318f4d8..0f18e7d2 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -53,7 +53,7 @@ // still matches all sub-routes by prefix so the active state covers // every tab. // Collection (M502) is an admin's: the catalogue of what the library holds - // and wants, and what Minstrel is fetching for it, shaped like Lidarr's. + // and wants, and what Minstrel is fetching for it. const navItems = $derived([ { href: '/', label: 'Home', icon: House, matchPrefix: '/' }, { href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' }, diff --git a/web/src/lib/components/admin/LidarrSettings.svelte b/web/src/lib/components/admin/LidarrSettings.svelte deleted file mode 100644 index 1cbe5b82..00000000 --- a/web/src/lib/components/admin/LidarrSettings.svelte +++ /dev/null @@ -1,327 +0,0 @@ - - -{#snippet listError(key: string)} - {#if testListErrors[key]} -

Couldn't read Lidarr's {LIST_NAMES[key]}.

- {/if} -{/snippet} - - - - - {#if testResult?.ok} - Connected · Lidarr {testResult.version} - {:else if config.data?.enabled} - Connected - {:else} - Not connected - {/if} - - - - - - - - - - - {@render listError('quality_profiles')} - - - - {@render listError('metadata_profiles')} - - - - {@render listError('root_folders')} - -
- - - {#if config.data?.enabled} - - {/if} - {#if testResult && !testResult.ok && !saveError} -

{said(testResult.error)}

- {/if} - {#if saveError} -

Not saved: {saveError}

- {/if} -
-
- - -

- This clears the saved connection. Type - DISCONNECT to go ahead. -

- - {#if disconnectError} -

Not disconnected: {disconnectError}

- {/if} -
- - -
-
diff --git a/web/src/lib/components/admin/LidarrSettings.test.ts b/web/src/lib/components/admin/LidarrSettings.test.ts deleted file mode 100644 index 3b21a247..00000000 --- a/web/src/lib/components/admin/LidarrSettings.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte'; -import { mockQuery } from '../../../test-utils/query'; -import type { LidarrConfig } from '#lib/api/types.js'; - -const invalidateQueries = vi.fn(); -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries }) }; -}); - -vi.mock('#lib/api/admin.js', () => ({ - createLidarrConfigQuery: vi.fn(), - createQualityProfilesQuery: vi.fn(), - createMetadataProfilesQuery: vi.fn(), - createRootFoldersQuery: vi.fn(), - putLidarrConfig: vi.fn(), - testLidarrConnection: vi.fn() -})); - -import LidarrSettings from './LidarrSettings.svelte'; -import { - createLidarrConfigQuery, - createQualityProfilesQuery, - createMetadataProfilesQuery, - createRootFoldersQuery, - putLidarrConfig, - testLidarrConnection -} from '#lib/api/admin.js'; - -const connected: LidarrConfig = { - enabled: true, - base_url: 'http://lidarr.local', - api_key: '***', - default_quality_profile_id: 1, - default_metadata_profile_id: 1, - default_root_folder_path: '/music' -}; - -const unset: LidarrConfig = { - enabled: false, - base_url: '', - api_key: '', - default_quality_profile_id: 0, - default_metadata_profile_id: 0, - default_root_folder_path: '' -}; - -const testOk = { - ok: true as const, - version: '2.0.5', - quality_profiles: [ - { id: 1, name: 'FLAC' }, - { id: 2, name: 'MP3-320' } - ], - metadata_profiles: [{ id: 1, name: 'Standard' }], - root_folders: [{ path: '/music', accessible: true, free_space: 12345 }] -}; - -afterEach(() => { - vi.clearAllMocks(); - invalidateQueries.mockReset(); -}); - -function setup(config: LidarrConfig = connected, lists = true) { - vi.mocked(createLidarrConfigQuery).mockReturnValue(mockQuery({ data: config }) as never); - vi.mocked(createQualityProfilesQuery).mockReturnValue( - mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' }] : [] }) as never - ); - vi.mocked(createMetadataProfilesQuery).mockReturnValue( - mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' }] : [] }) as never - ); - vi.mocked(createRootFoldersQuery).mockReturnValue( - mockQuery({ data: lists ? [{ path: '/music', accessible: true, free_space: 0 }] : [] }) as never - ); - return render(LidarrSettings); -} - -function modalConfirm(): HTMLElement { - return within(screen.getByRole('dialog')).getByRole('button', { name: /^disconnect$/i }); -} - -describe('LidarrSettings', () => { - test('connected, it says so; not connected, it says that and offers no Disconnect', () => { - const { unmount } = setup(); - expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Connected'); - expect(screen.getByRole('button', { name: /^disconnect$/i })).toBeInTheDocument(); - unmount(); - setup(unset); - expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Not connected'); - expect(screen.queryByRole('button', { name: /^disconnect$/i })).toBeNull(); - }); - - test('Save tests first, then saves with an empty key, which keeps the saved one', async () => { - setup(); - vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk); - vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected); - await fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => - expect(putLidarrConfig).toHaveBeenCalledWith( - expect.objectContaining({ enabled: true, base_url: 'http://lidarr.local', api_key: '', default_root_folder_path: '/music' }) - ) - ); - }); - - test('a typed key is sent', async () => { - setup(); - await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'newkey' } }); - vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk); - vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected); - await fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'newkey' }))); - }); - - test('Test shows the Lidarr version when it connects', async () => { - setup(); - vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: true, version: '2.0.0' }); - await fireEvent.click(screen.getByRole('button', { name: /^test$/i })); - await waitFor(() => expect(screen.getByTestId('lidarr-state').textContent).toMatch(/lidarr 2\.0\.0/i)); - }); - - test('a failed test says what went wrong in words, not its code', async () => { - setup(); - vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' }); - await fireEvent.click(screen.getByRole('button', { name: /^test$/i })); - const said = await screen.findByTestId('lidarr-test-error'); - expect(said.textContent).toMatch(/couldn't reach lidarr/i); - expect(said.textContent).not.toMatch(/lidarr_unreachable/); - }); - - test('a save whose test fails is not saved, and says why', async () => { - setup(unset, false); - vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_auth_failed' }); - await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://wrong.local' } }); - await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'k' } }); - await fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => expect(screen.getByTestId('lidarr-save-error').textContent).toMatch(/turned the api key down/i)); - expect(putLidarrConfig).not.toHaveBeenCalled(); - }); - - test('first-time setup fills the defaults from the test, keeping a choice made after it', async () => { - setup(unset, false); - vi.mocked(testLidarrConnection).mockResolvedValue(testOk); - vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected); - await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://lidarr.local' } }); - await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'secret-key' } }); - await fireEvent.click(screen.getByRole('button', { name: /^test$/i })); - await waitFor(() => expect(testLidarrConnection).toHaveBeenCalledTimes(1)); - await fireEvent.change(screen.getByLabelText(/quality profile/i), { target: { value: '2' } }); - await fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => - expect(putLidarrConfig).toHaveBeenCalledWith({ - enabled: true, - base_url: 'http://lidarr.local', - api_key: 'secret-key', - default_quality_profile_id: 2, - default_metadata_profile_id: 1, - default_root_folder_path: '/music' - }) - ); - }); - - test('Disconnect needs DISCONNECT typed; cancelling changes nothing', async () => { - setup(); - await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i })); - expect((modalConfirm() as HTMLButtonElement).disabled).toBe(true); - await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - expect(putLidarrConfig).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - test('Disconnect, confirmed, clears the connection', async () => { - setup(); - await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i })); - await fireEvent.input(screen.getByPlaceholderText('DISCONNECT'), { target: { value: 'DISCONNECT' } }); - vi.mocked(putLidarrConfig).mockResolvedValueOnce(unset); - await fireEvent.click(modalConfirm()); - expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, base_url: '', api_key: '' })); - }); - - test('the profile and folder choices come from Lidarr', () => { - setup(); - expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2); - expect(screen.getByText('Lossless')).toBeInTheDocument(); - expect(screen.getByText('Singles only')).toBeInTheDocument(); - expect(screen.getByText('/music')).toBeInTheDocument(); - }); -}); diff --git a/web/src/lib/components/wanted/CatalogueArtistCard.svelte b/web/src/lib/components/wanted/CatalogueArtistCard.svelte index 88a69371..311b6020 100644 --- a/web/src/lib/components/wanted/CatalogueArtistCard.svelte +++ b/web/src/lib/components/wanted/CatalogueArtistCard.svelte @@ -2,9 +2,9 @@ import Cover from '#lib/components/Cover.svelte'; import type { WantArtist } from '#lib/api/types.js'; - // One artist in the Collection's catalogue (#5572), like a Lidarr artist - // poster: the picture, a bar of how many of their albums on the list are - // held, and whether Minstrel follows them for new releases. + // One artist in the Collection's catalogue (#5572), as a poster: the + // picture, a bar of how many of their albums on the list are held, and + // whether Minstrel follows them for new releases. let { artist: a }: { artist: WantArtist } = $props(); diff --git a/web/src/lib/components/wanted/CatalogueGrid.svelte b/web/src/lib/components/wanted/CatalogueGrid.svelte index 5409a4fd..05ea9180 100644 --- a/web/src/lib/components/wanted/CatalogueGrid.svelte +++ b/web/src/lib/components/wanted/CatalogueGrid.svelte @@ -7,8 +7,7 @@ import CatalogueArtistCard from './CatalogueArtistCard.svelte'; import type { WantArtist } from '#lib/api/types.js'; - // The Collection's catalogue (#5572), its main page, shaped like Lidarr's - // artist index so a person coming from Lidarr finds their way: every + // The Collection's catalogue (#5572), its main page, an artist index: every // artist on the list A–Z, each card showing how many of their albums are // held. An artist opens on their albums. The library's albums were put on // the list as held once (#5567), each matched to MusicBrainz (#5569); the diff --git a/web/src/lib/components/wanted/CollectionRail.svelte b/web/src/lib/components/wanted/CollectionRail.svelte index 5ff5ac26..1152ff77 100644 --- a/web/src/lib/components/wanted/CollectionRail.svelte +++ b/web/src/lib/components/wanted/CollectionRail.svelte @@ -2,11 +2,10 @@ import SectionRail from '#lib/components/SectionRail.svelte'; import type { WantCounts } from '#lib/api/types.js'; - // The Collection's sections, down the left like Lidarr's (#5572): the - // catalogue of artists first, then the sections that show state. Activity - // carries a second, accented count when downloads are waiting on a person. - // Unmatched, like Lidarr's Unmapped Files, holds the library albums no - // MusicBrainz album matched (#5579). Upgrades, Lidarr's Cutoff Unmet, + // The Collection's sections, down the left (#5572): the catalogue of + // artists first, then the sections that show state. Activity carries a + // second, accented count when downloads are waiting on a person. Unmatched + // holds the library albums no MusicBrainz album matched (#5579). Upgrades // holds the albums held but wanted for a better copy (#5524). let { counts }: { counts?: WantCounts } = $props(); diff --git a/web/src/lib/components/wanted/UnmatchedList.svelte b/web/src/lib/components/wanted/UnmatchedList.svelte index 85d6ce19..6ea48a74 100644 --- a/web/src/lib/components/wanted/UnmatchedList.svelte +++ b/web/src/lib/components/wanted/UnmatchedList.svelte @@ -8,12 +8,12 @@ import UnmatchedMatch from './UnmatchedMatch.svelte'; import type { UnmatchedAlbum } from '#lib/api/types.js'; - // The Collection's Unmatched section (#5579), like Lidarr's Unmapped - // Files: the library albums no MusicBrainz album matched by artist, title - // and tracks. Each can be matched by hand, to a search result or a pasted - // link, and goes on the list as held; or left off, as a local band's album - // MusicBrainz will never have. Until then an album isn't fetched back if - // its files go missing. + // The Collection's Unmatched section (#5579): the library albums no + // MusicBrainz album matched by artist, title and tracks. Each can be + // matched by hand, to a search result or a pasted link, and goes on the + // list as held; or left off, as a local band's album MusicBrainz will + // never have. Until then an album isn't fetched back if its files go + // missing. const client = useQueryClient(); const unmatchedStore = createUnmatchedQuery(); diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json index 37250d65..e6706e12 100644 --- a/web/src/lib/styles/error-copy.json +++ b/web/src/lib/styles/error-copy.json @@ -34,14 +34,6 @@ "system_playlist_readonly": "System playlists can't be edited directly.", "connection_refused": "Couldn't reach the server. Check the URL and try again.", "musicbrainz_unreachable": "MusicBrainz can't be reached right now, so search is unavailable. Try again in a minute.", - "lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.", - "lidarr_disabled": "Lidarr integration is not enabled.", - "lidarr_auth_failed": "Lidarr authentication failed.", - "lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin › Acquisition.", - "lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.", - "lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.", - "lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", - "album_mbid_missing": "This track has no Lidarr album to remove.", "request_not_pending": "This request is no longer pending.", "request_not_found": "That request no longer exists.", "track_not_found": "That track no longer exists.", diff --git a/web/src/routes/admin/acquisition/+page.svelte b/web/src/routes/admin/acquisition/+page.svelte index c71cc1b5..9b07a607 100644 --- a/web/src/routes/admin/acquisition/+page.svelte +++ b/web/src/routes/admin/acquisition/+page.svelte @@ -3,12 +3,11 @@ import AcquisitionCard from '#lib/components/AcquisitionCard.svelte'; import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte'; import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte'; - import LidarrSettings from '#lib/components/admin/LidarrSettings.svelte'; import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte'; // Admin › Acquisition (#5607): how Minstrel gets music. Where it searches - // and what downloads, how it searches and what it takes, what happens to - // a file that goes missing, and the optional Lidarr connection. + // and what downloads, how it searches and what it takes, and what happens + // to a file that goes missing. {pageTitle('Admin · Acquisition')} @@ -22,6 +21,5 @@ {/snippet} {#snippet side()}
- {/snippet} diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte index 7ee4e2f6..c83c4679 100644 --- a/web/src/routes/admin/duplicates/+page.svelte +++ b/web/src/routes/admin/duplicates/+page.svelte @@ -43,10 +43,9 @@ let sweeping = $state(false); let dismissing = $state(null); - // Per group: which copy to keep (defaults to the proposed survivor), whether to - // unmonitor the removed copies in Lidarr, and the two-click confirm. + // Per group: which copy to keep (defaults to the proposed survivor) and the + // two-click confirm. let keepChoice = $state>({}); - let unmonitorChoice = $state>({}); let confirmingMerge = $state(null); let merging = $state(null); @@ -116,14 +115,8 @@ confirmingMerge = null; merging = group.id; try { - const result = await mergeDuplicateGroup(group.id, { - survivor_track_id: keeperOf(group), - unmonitor: unmonitorChoice[group.id] ?? false - }); + const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) }); pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`); - if (result.lidarr_unmonitor_failed) { - pushToast("Merged, but Lidarr couldn't be told to stop monitoring the removed copies.", 'error'); - } query.refetch(); } catch (e: unknown) { pushToast(errMessage(e), 'error'); @@ -261,30 +254,18 @@ {:else}
    {#each activity as item (item.id)} + {@const removed = item.details.removed ?? []}
  • - {#if item.action === 'duplicate_merge'} - {@const removed = item.details.removed ?? []} -

    - Kept {basename(item.details.survivor_path ?? '')} - {#if item.details.survivor_reason} - ({item.details.survivor_reason}) - {/if} - and removed {fileCountLabel(removed.length)}. -

    - {#each removed as r (r.track_id)} -

    {r.file_path}

    - {/each} - {:else} -

    - Lidarr now monitors {item.details.to_release ?? 'another release'} of - {item.details.album_title ?? 'an album'}{item.details.artist_name - ? ` by ${item.details.artist_name}` - : ''}, which lists each song once. -

    - {#if item.details.from_release} -

    Was {item.details.from_release}.

    +

    + Kept {basename(item.details.survivor_path ?? '')} + {#if item.details.survivor_reason} + ({item.details.survivor_reason}) {/if} - {/if} + and removed {fileCountLabel(removed.length)}. +

    + {#each removed as r (r.track_id)} +

    {r.file_path}

    + {/each}

    {relativeTime(item.created_at)}

  • {/each} @@ -412,14 +393,6 @@ The copy marked Keep stays. The other {fileCountLabel(group.members.length - 1)} will be deleted from disk, and their likes, plays and playlist entries move to the copy kept.

    -