Compare commits
+2
-2
@@ -28,8 +28,8 @@ ARG MINSTREL_VERSION=dev
|
|||||||
ARG MINSTREL_CHANNEL=local
|
ARG MINSTREL_CHANNEL=local
|
||||||
RUN go build -trimpath \
|
RUN go build -trimpath \
|
||||||
-ldflags="-s -w \
|
-ldflags="-s -w \
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \
|
||||||
-o /out/minstrel ./cmd/minstrel
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Minstrel
|
# Minstrel
|
||||||
|
|
||||||
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
|
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
|
||||||
|
|
||||||
> State and intelligence belong on the server, not the client.
|
> State and intelligence belong on the server, not the client.
|
||||||
|
|
||||||
@@ -11,21 +11,21 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
|
||||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||||
|
|
||||||
## Scope and responsible use
|
## Scope and responsible use
|
||||||
|
|
||||||
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients.
|
||||||
|
|
||||||
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind.
|
||||||
|
|
||||||
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
|
||||||
|
|
||||||
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||||
|
|
||||||
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -40,11 +40,13 @@ services:
|
|||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
# Your music library. Point ./music at wherever your audio files
|
# Your music library. Point ./music at wherever your audio files
|
||||||
# live. Writable, because Minstrel deletes a file when an admin asks
|
# live. Writable, because Minstrel files the albums it downloads into
|
||||||
# it to (for example, quarantine's "Delete file"). It never moves,
|
# it (as Artist/Album (Year)/..., tagged with MusicBrainz ids) and
|
||||||
# renames or retags anything. The container runs as uid 1000, so that
|
# deletes a file when an admin asks it to (for example, quarantine's
|
||||||
# user needs write access to the folders. Mount it :ro to forbid even
|
# "Delete file"). Files already there are never moved, renamed or
|
||||||
# deletes: those actions then refuse, say why, and delete nothing.
|
# retagged. The container runs as uid 1000, so that user needs write
|
||||||
|
# access to the folders. Mount it :ro to forbid writes: those actions
|
||||||
|
# then refuse, say why, and change nothing.
|
||||||
- ./music:/music
|
- ./music:/music
|
||||||
# Generated data: playlist cover collages, artist art, caches.
|
# Generated data: playlist cover collages, artist art, caches.
|
||||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||||
@@ -89,9 +91,9 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
|
|||||||
|
|
||||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||||
|
|
||||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
**3. (Optional) Set up acquisition and connections.** In admin **Acquisition** (`/admin/acquisition`), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under **Settings → Connections** (`/settings/connections`). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||||
|
|
||||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="Search services, download clients and searching in admin Acquisition"></a>
|
||||||
|
|
||||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ Most operators only need the env vars in the quickstart above. A few extras wort
|
|||||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
|
|
||||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
ListenBrainz (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
|
|
||||||
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
|
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
|
||||||
|
|
||||||
|
|||||||
-3
@@ -27,8 +27,6 @@ class AdminQuarantineRepository @Inject constructor(
|
|||||||
suspend fun resolve(trackId: String) = api.resolve(trackId)
|
suspend fun resolve(trackId: String) = api.resolve(trackId)
|
||||||
|
|
||||||
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
|
||||||
|
|
||||||
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers ──
|
// ── Mappers ──
|
||||||
@@ -49,7 +47,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
|
|||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
albumTitle = albumTitle,
|
albumTitle = albumTitle,
|
||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
lidarrAlbumMbid = lidarrAlbumMbid,
|
|
||||||
reportCount = reportCount,
|
reportCount = reportCount,
|
||||||
latestAt = latestAt,
|
latestAt = latestAt,
|
||||||
reasonCounts = reasonCounts,
|
reasonCounts = reasonCounts,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
SectionCard(
|
SectionCard(
|
||||||
icon = Lucide.Inbox,
|
icon = Lucide.Inbox,
|
||||||
title = "Requests",
|
title = "Requests",
|
||||||
subtitle = "Approve or reject Lidarr requests",
|
subtitle = "Approve or reject requests for new music",
|
||||||
count = counts.requests,
|
count = counts.requests,
|
||||||
onClick = { navController.navigate(AdminRequests) },
|
onClick = { navController.navigate(AdminRequests) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -85,7 +84,6 @@ fun AdminQuarantineScreen(
|
|||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
onResolve = viewModel::resolve,
|
onResolve = viewModel::resolve,
|
||||||
onDeleteFile = viewModel::deleteFile,
|
onDeleteFile = viewModel::deleteFile,
|
||||||
onDeleteViaLidarr = viewModel::deleteViaLidarr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,6 @@ private fun QueueList(
|
|||||||
rows: List<AdminQuarantineItemRef>,
|
rows: List<AdminQuarantineItemRef>,
|
||||||
onResolve: (String) -> Unit,
|
onResolve: (String) -> Unit,
|
||||||
onDeleteFile: (String) -> Unit,
|
onDeleteFile: (String) -> Unit,
|
||||||
onDeleteViaLidarr: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(items = rows, key = { it.trackId }) { row ->
|
items(items = rows, key = { it.trackId }) { row ->
|
||||||
@@ -105,7 +102,6 @@ private fun QueueList(
|
|||||||
row = row,
|
row = row,
|
||||||
onResolve = { onResolve(row.trackId) },
|
onResolve = { onResolve(row.trackId) },
|
||||||
onDeleteFile = { onDeleteFile(row.trackId) },
|
onDeleteFile = { onDeleteFile(row.trackId) },
|
||||||
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
|
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
@@ -117,7 +113,6 @@ private fun QuarantineRow(
|
|||||||
row: AdminQuarantineItemRef,
|
row: AdminQuarantineItemRef,
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
onDeleteViaLidarr: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -166,7 +161,6 @@ private fun QuarantineRow(
|
|||||||
ActionButtons(
|
ActionButtons(
|
||||||
onResolve = onResolve,
|
onResolve = onResolve,
|
||||||
onDeleteFile = onDeleteFile,
|
onDeleteFile = onDeleteFile,
|
||||||
onDeleteViaLidarr = onDeleteViaLidarr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +169,6 @@ private fun QuarantineRow(
|
|||||||
private fun ActionButtons(
|
private fun ActionButtons(
|
||||||
onResolve: () -> Unit,
|
onResolve: () -> Unit,
|
||||||
onDeleteFile: () -> Unit,
|
onDeleteFile: () -> Unit,
|
||||||
onDeleteViaLidarr: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -183,6 +176,5 @@ private fun ActionButtons(
|
|||||||
) {
|
) {
|
||||||
TextButton(onClick = onResolve) { Text("Resolve") }
|
TextButton(onClick = onResolve) { Text("Resolve") }
|
||||||
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
|
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
|
||||||
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -82,7 +82,6 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
|
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
|
||||||
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
|
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
|
||||||
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
|
|
||||||
|
|
||||||
private fun act(trackId: String, action: suspend (String) -> Unit) {
|
private fun act(trackId: String, action: suspend (String) -> Unit) {
|
||||||
val before = internal.value
|
val before = internal.value
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun AdminRequestsScreen(
|
|||||||
AdminRequestsUiState.Loading -> LoadingCentered()
|
AdminRequestsUiState.Loading -> LoadingCentered()
|
||||||
AdminRequestsUiState.Empty -> EmptyState(
|
AdminRequestsUiState.Empty -> EmptyState(
|
||||||
title = "No requests waiting",
|
title = "No requests waiting",
|
||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> ErrorRetry(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import javax.inject.Singleton
|
|||||||
* leaks here: (1) the session cookie was being attached to every
|
* leaks here: (1) the session cookie was being attached to every
|
||||||
* external request the shared OkHttpClient services — including
|
* external request the shared OkHttpClient services — including
|
||||||
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
||||||
* / Lidarr's /MediaCover endpoints — exposing the session
|
* — exposing the session
|
||||||
* identifier to third-party logging; (2) a 401 from any of those
|
* identifier to third-party logging; (2) a 401 from any of those
|
||||||
* external hosts silently wiped the user's Minstrel session.
|
* external hosts silently wiped the user's Minstrel session.
|
||||||
* Restricting both attach + clear to placeholder-host requests
|
* Restricting both attach + clear to placeholder-host requests
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import javax.inject.Singleton
|
|||||||
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||||
*
|
*
|
||||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||||
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
|
* rewritten. Absolute external URLs — artwork from
|
||||||
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||||
* reach their authored host unchanged; rewriting them to the
|
* reach their authored host unchanged; rewriting them to the
|
||||||
* Minstrel host produced 404s the user saw as missing Discover
|
* Minstrel host produced 404s the user saw as missing Discover
|
||||||
|
|||||||
@@ -105,21 +105,10 @@ object ErrorCopy {
|
|||||||
"cleartext_public" to
|
"cleartext_public" to
|
||||||
"This server is on the internet, so its URL must start with https://. " +
|
"This server is on the internet, so its URL must start with https://. " +
|
||||||
"Plain http:// only works on your home network.",
|
"Plain http:// only works on your home network.",
|
||||||
"lidarr_unreachable" to
|
|
||||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
|
||||||
"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 → Integrations.",
|
|
||||||
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
|
|
||||||
"lidarr_rejected" to
|
|
||||||
"Lidarr rejected the request. Check the server logs for the field-level reason.",
|
|
||||||
"lidarr_album_lookup_failed" to
|
|
||||||
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
|
|
||||||
"album_mbid_missing" to "This track has no Lidarr album to remove.",
|
|
||||||
"request_not_pending" to "This request is no longer pending.",
|
"request_not_pending" to "This request is no longer pending.",
|
||||||
"request_not_found" to "That request no longer exists.",
|
"request_not_found" to "That request no longer exists.",
|
||||||
|
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
|
||||||
|
"profile_not_found" to "That release-type profile no longer exists.",
|
||||||
"track_not_found" to "That track no longer exists.",
|
"track_not_found" to "That track no longer exists.",
|
||||||
"library_not_writable" to "The music library isn't writable by the server.",
|
"library_not_writable" to "The music library isn't writable by the server.",
|
||||||
"file_delete_failed" to "The file couldn't be deleted.",
|
"file_delete_failed" to "The file couldn't be deleted.",
|
||||||
|
|||||||
+3
-7
@@ -8,10 +8,9 @@ import retrofit2.http.Path
|
|||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/quarantine`.
|
* Retrofit interface for `/api/admin/quarantine`.
|
||||||
*
|
*
|
||||||
* Three resolution endpoints:
|
* Two resolution endpoints:
|
||||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||||
* - `delete-file` → drop the local file; Lidarr keeps the track.
|
* - `delete-file` → delete the track's file and row.
|
||||||
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
|
|
||||||
*/
|
*/
|
||||||
interface AdminQuarantineApi {
|
interface AdminQuarantineApi {
|
||||||
@GET("api/admin/quarantine")
|
@GET("api/admin/quarantine")
|
||||||
@@ -22,7 +21,4 @@ interface AdminQuarantineApi {
|
|||||||
|
|
||||||
@POST("api/admin/quarantine/{trackId}/delete-file")
|
@POST("api/admin/quarantine/{trackId}/delete-file")
|
||||||
suspend fun deleteFile(@Path("trackId") trackId: String)
|
suspend fun deleteFile(@Path("trackId") trackId: String)
|
||||||
|
|
||||||
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
|
|
||||||
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -13,10 +13,11 @@ import retrofit2.http.Path
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for Discover / Lidarr search / request creation.
|
* Retrofit interface for Discover / catalogue search / request creation.
|
||||||
*
|
*
|
||||||
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
* `/api/catalogue/search` asks MusicBrainz, which allows one request a
|
||||||
* of the same query are cheap.
|
* second, so the Discover screen searches when the query is submitted,
|
||||||
|
* not on every keystroke.
|
||||||
*
|
*
|
||||||
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
|
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
|
||||||
* Commit B), not here.
|
* Commit B), not here.
|
||||||
@@ -25,11 +26,11 @@ interface DiscoverApi {
|
|||||||
@GET("api/discover/suggestions")
|
@GET("api/discover/suggestions")
|
||||||
suspend fun listSuggestions(): List<ArtistSuggestionWire>
|
suspend fun listSuggestions(): List<ArtistSuggestionWire>
|
||||||
|
|
||||||
@GET("api/lidarr/search")
|
@GET("api/catalogue/search")
|
||||||
suspend fun search(
|
suspend fun search(
|
||||||
@Query("q") query: String,
|
@Query("q") query: String,
|
||||||
@Query("kind") kind: String,
|
@Query("kind") kind: String,
|
||||||
): List<LidarrSearchResultWire>
|
): List<CatalogueSearchResultWire>
|
||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|||||||
+2
-2
@@ -75,8 +75,8 @@ data class LikeTogglePayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
|
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
|
||||||
* Lidarr request that landed during a connectivity hiccup). Optional
|
* request that landed during a connectivity hiccup). Optional
|
||||||
* fields stay null when not applicable; the replayer rebuilds the
|
* fields stay null when not applicable; the replayer rebuilds the
|
||||||
* `CreateRequestBody` from these.
|
* `CreateRequestBody` from these.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+33
-10
@@ -109,19 +109,36 @@ class SyncController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls pages until the server has nothing newer (#5654). The server
|
||||||
|
* returns at most 1000 changes a call; taking one page per trigger left
|
||||||
|
* the cache behind a change log that a full re-read grows by ~26,000
|
||||||
|
* rows, so merge deletes never arrived and the freshness sweep kept
|
||||||
|
* asking for tracks that were gone. The cursor is stored after each
|
||||||
|
* page, so an interrupted pass resumes where it stopped.
|
||||||
|
*/
|
||||||
private suspend fun sync() {
|
private suspend fun sync() {
|
||||||
|
repeat(MAX_PAGES_PER_PASS) {
|
||||||
|
if (!syncPage()) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of the change log. True when the server may hold more. */
|
||||||
|
private suspend fun syncPage(): Boolean {
|
||||||
val meta = syncMetadataDao.get()
|
val meta = syncMetadataDao.get()
|
||||||
val cursor = meta?.cursor ?: 0L
|
val cursor = meta?.cursor ?: 0L
|
||||||
val response = api.sync(cursor)
|
val response = api.sync(cursor)
|
||||||
when (response.code()) {
|
return when (response.code()) {
|
||||||
HTTP_NO_CONTENT -> {
|
HTTP_NO_CONTENT -> {
|
||||||
touchLastSyncAt(cursor)
|
touchLastSyncAt(cursor)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
HTTP_GONE -> {
|
HTTP_GONE -> {
|
||||||
wipeAndRetry()
|
resetCursor()
|
||||||
|
true
|
||||||
}
|
}
|
||||||
HTTP_OK -> {
|
HTTP_OK -> {
|
||||||
val body = response.body() ?: return
|
val body = response.body() ?: return false
|
||||||
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
|
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
|
||||||
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
|
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
|
||||||
syncMetadataDao.upsert(
|
syncMetadataDao.upsert(
|
||||||
@@ -131,10 +148,14 @@ class SyncController @Inject constructor(
|
|||||||
lastSyncAt = Clock.System.now(),
|
lastSyncAt = Clock.System.now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A cursor that did not advance would ask for the same page
|
||||||
|
// again; stop rather than spin.
|
||||||
|
body.cursor > cursor
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Unhandled status — treat as a no-op for v1; the next
|
// Unhandled status — treat as a no-op for v1; the next
|
||||||
// app start retries from the same cursor.
|
// app start retries from the same cursor.
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,25 +186,27 @@ class SyncController @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun wipeAndRetry() {
|
private suspend fun resetCursor() {
|
||||||
// The Library DAOs only expose deleteByIds; for the rare 410
|
// The Library DAOs only expose deleteByIds; for the rare 410
|
||||||
// path we re-issue the same request with cursor=0 after
|
// path the next page is requested with cursor=0 after
|
||||||
// resetting our cursor. The next response carries the full
|
// resetting our cursor. The following pages carry the full
|
||||||
// entity set, which upsertAll overwrites the existing rows
|
// entity set, which upsertAll overwrites the existing rows
|
||||||
// with — old rows for entities the server no longer knows
|
// with — old rows for entities the server no longer knows
|
||||||
// about will linger until a later 410 or until the user clears
|
// about linger until MetadataProvider's refresh meets their
|
||||||
// app data. Acceptable for v1; a future "wipe all" DAO method
|
// 404 and drops them.
|
||||||
// tightens this up.
|
|
||||||
syncMetadataDao.upsert(
|
syncMetadataDao.upsert(
|
||||||
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
|
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
|
||||||
)
|
)
|
||||||
sync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val HTTP_OK = 200
|
const val HTTP_OK = 200
|
||||||
const val HTTP_NO_CONTENT = 204
|
const val HTTP_NO_CONTENT = 204
|
||||||
const val HTTP_GONE = 410
|
const val HTTP_GONE = 410
|
||||||
|
|
||||||
|
// A guard, not a budget: 1000 pages is a million changes, far past
|
||||||
|
// anything a library writes between two app starts.
|
||||||
|
const val MAX_PAGES_PER_PASS = 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -4,13 +4,13 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
|||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
@@ -28,7 +28,7 @@ import javax.inject.Singleton
|
|||||||
enum class RequestOutcome { ACCEPTED, QUEUED }
|
enum class RequestOutcome { ACCEPTED, QUEUED }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through Discover surface: Lidarr search, suggestion feed, and
|
* Read-through Discover surface: catalogue search, suggestion feed, and
|
||||||
* the request-creation write path. Mirrors the Flutter
|
* the request-creation write path. Mirrors the Flutter
|
||||||
* `DiscoverApi` + `mutationQueueProvider` integration —
|
* `DiscoverApi` + `mutationQueueProvider` integration —
|
||||||
* `feedback_offline_first_for_server_writes` says writes never go
|
* `feedback_offline_first_for_server_writes` says writes never go
|
||||||
@@ -113,7 +113,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||||
|
|
||||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
suspend fun search(query: String, kind: RequestKind): List<CatalogueResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +123,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
|
||||||
*/
|
*/
|
||||||
suspend fun createRequest(
|
suspend fun createRequest(
|
||||||
kind: LidarrRequestKind,
|
kind: RequestKind,
|
||||||
artistMbid: String,
|
artistMbid: String,
|
||||||
artistName: String,
|
artistName: String,
|
||||||
albumMbid: String? = null,
|
albumMbid: String? = null,
|
||||||
@@ -163,7 +163,7 @@ class DiscoverRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
|
|
||||||
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
|
private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef(
|
||||||
mbid = mbid,
|
mbid = mbid,
|
||||||
name = name,
|
name = name,
|
||||||
secondaryText = secondaryText,
|
secondaryText = secondaryText,
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Search as LucideSearch
|
import com.composables.icons.lucide.Search as LucideSearch
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
@@ -161,7 +161,7 @@ private fun SearchBar(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
placeholder = { Text("Search Lidarr for new music") },
|
placeholder = { Text("Search MusicBrainz for new music") },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
@@ -173,7 +173,7 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
|
private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -181,13 +181,13 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == LidarrRequestKind.ARTIST,
|
selected = kind == RequestKind.ARTIST,
|
||||||
onClick = { onChange(LidarrRequestKind.ARTIST) },
|
onClick = { onChange(RequestKind.ARTIST) },
|
||||||
label = { Text("Artists") },
|
label = { Text("Artists") },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = kind == LidarrRequestKind.ALBUM,
|
selected = kind == RequestKind.ALBUM,
|
||||||
onClick = { onChange(LidarrRequestKind.ALBUM) },
|
onClick = { onChange(RequestKind.ALBUM) },
|
||||||
label = { Text("Albums") },
|
label = { Text("Albums") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -316,9 +316,9 @@ private fun SuggestionsHeader() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResultsList(
|
private fun ResultsList(
|
||||||
rows: List<LidarrSearchResultRef>,
|
rows: List<CatalogueResultRef>,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (LidarrSearchResultRef) -> Unit,
|
onRequest: (CatalogueResultRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
CenteredMessage("Nothing to add for that search yet.")
|
CenteredMessage("Nothing to add for that search yet.")
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.composables.icons.lucide.Clock
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(
|
internal fun SuggestionTile(
|
||||||
@@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ResultTile(
|
internal fun ResultTile(
|
||||||
row: LidarrSearchResultRef,
|
row: CatalogueResultRef,
|
||||||
locallyRequested: Boolean,
|
locallyRequested: Boolean,
|
||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -173,18 +173,19 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
|
|||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (imageUrl.isEmpty()) {
|
// The server hands back its own art endpoint (relative, M502 #5519),
|
||||||
|
// which answers 404 when no source has an image: ServerImage resolves
|
||||||
|
// the path and shows the icon while loading and on a miss.
|
||||||
|
ServerImage(
|
||||||
|
url = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = fallback,
|
imageVector = fallback,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -8,8 +8,8 @@ import com.fabledsword.minstrel.connectivity.recoveries
|
|||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.RequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.CatalogueResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -24,7 +24,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class DiscoverState(
|
data class DiscoverState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
|
val kind: RequestKind = RequestKind.ARTIST,
|
||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
@@ -51,7 +51,7 @@ sealed interface ResultsState {
|
|||||||
/** No active search — the screen shows the suggestion feed. */
|
/** No active search — the screen shows the suggestion feed. */
|
||||||
data object Idle : ResultsState
|
data object Idle : ResultsState
|
||||||
data object Loading : ResultsState
|
data object Loading : ResultsState
|
||||||
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
|
data class Loaded(val items: List<CatalogueResultRef>) : ResultsState
|
||||||
data class Error(val message: String) : ResultsState
|
data class Error(val message: String) : ResultsState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setKind(kind: LidarrRequestKind) {
|
fun setKind(kind: RequestKind) {
|
||||||
internal.update { it.copy(kind = kind) }
|
internal.update { it.copy(kind = kind) }
|
||||||
if (internal.value.query.isNotBlank()) runSearch()
|
if (internal.value.query.isNotBlank()) runSearch()
|
||||||
}
|
}
|
||||||
@@ -177,14 +177,14 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
* QUEUED) so the screen can pick the snackbar wording and update
|
* QUEUED) so the screen can pick the snackbar wording and update
|
||||||
* the local "requested" overlay.
|
* the local "requested" overlay.
|
||||||
*/
|
*/
|
||||||
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
|
suspend fun requestRow(row: CatalogueResultRef): RequestOutcome {
|
||||||
val kind = internal.value.kind
|
val kind = internal.value.kind
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
|
artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid,
|
||||||
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
|
artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText,
|
||||||
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
|
albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null,
|
||||||
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
|
albumTitle = if (kind == RequestKind.ALBUM) row.name else null,
|
||||||
)
|
)
|
||||||
markRequested(row.mbid)
|
markRequested(row.mbid)
|
||||||
return outcome
|
return outcome
|
||||||
@@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
|
||||||
val outcome = repository.createRequest(
|
val outcome = repository.createRequest(
|
||||||
kind = LidarrRequestKind.ARTIST,
|
kind = RequestKind.ARTIST,
|
||||||
artistMbid = s.mbid,
|
artistMbid = s.mbid,
|
||||||
artistName = s.name,
|
artistName = s.name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import retrofit2.HttpException
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val MAX_CONCURRENT_FETCHES = 4
|
private const val MAX_CONCURRENT_FETCHES = 4
|
||||||
|
private const val HTTP_NOT_FOUND = 404
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-first metadata reads with eager on-miss live-fetch.
|
* Cache-first metadata reads with eager on-miss live-fetch.
|
||||||
@@ -93,26 +95,47 @@ class MetadataProvider @Inject constructor(
|
|||||||
fun refreshArtist(id: String): Job = fetchArtist(id)
|
fun refreshArtist(id: String): Job = fetchArtist(id)
|
||||||
fun refreshTrack(id: String): Job = fetchTrack(id)
|
fun refreshTrack(id: String): Job = fetchTrack(id)
|
||||||
|
|
||||||
private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) {
|
private fun fetchAlbum(id: String): Job = launchDeduped(
|
||||||
|
inFlightAlbums,
|
||||||
|
id,
|
||||||
|
onGone = { albumDao.deleteByIds(listOf(id)) },
|
||||||
|
) {
|
||||||
libraryRepository.refreshAlbumDetail(id)
|
libraryRepository.refreshAlbumDetail(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) {
|
private fun fetchArtist(id: String): Job = launchDeduped(
|
||||||
|
inFlightArtists,
|
||||||
|
id,
|
||||||
|
onGone = { artistDao.deleteByIds(listOf(id)) },
|
||||||
|
) {
|
||||||
libraryRepository.refreshArtistDetail(id)
|
libraryRepository.refreshArtistDetail(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) {
|
private fun fetchTrack(id: String): Job = launchDeduped(
|
||||||
|
inFlightTracks,
|
||||||
|
id,
|
||||||
|
onGone = { trackDao.deleteByIds(listOf(id)) },
|
||||||
|
) {
|
||||||
libraryRepository.refreshTrack(id)
|
libraryRepository.refreshTrack(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [onGone] runs when the server answers 404: the row no longer exists
|
||||||
|
* there (a duplicate merge, a deleted file), so the cached copy is
|
||||||
|
* dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for
|
||||||
|
* it every pass (#5654). Any other failure leaves the row for a retry.
|
||||||
|
*/
|
||||||
private fun launchDeduped(
|
private fun launchDeduped(
|
||||||
inFlight: ConcurrentHashMap<String, Job>,
|
inFlight: ConcurrentHashMap<String, Job>,
|
||||||
id: String,
|
id: String,
|
||||||
|
onGone: suspend () -> Unit,
|
||||||
block: suspend () -> Unit,
|
block: suspend () -> Unit,
|
||||||
): Job = inFlight.computeIfAbsent(id) {
|
): Job = inFlight.computeIfAbsent(id) {
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
try {
|
try {
|
||||||
fetchGate.withPermit { block() }
|
fetchGate.withPermit { block() }
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
if (e.code() == HTTP_NOT_FOUND) onGone()
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ data class AdminQuarantineItemRef(
|
|||||||
val artistName: String,
|
val artistName: String,
|
||||||
val albumTitle: String,
|
val albumTitle: String,
|
||||||
val albumId: String,
|
val albumId: String,
|
||||||
val lidarrAlbumMbid: String? = null,
|
|
||||||
val reportCount: Int = 0,
|
val reportCount: Int = 0,
|
||||||
val latestAt: String = "",
|
val latestAt: String = "",
|
||||||
val reasonCounts: Map<String, Int> = emptyMap(),
|
val reasonCounts: Map<String, Int> = emptyMap(),
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ import kotlinx.datetime.Instant
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
*/
|
*/
|
||||||
enum class LidarrRequestKind {
|
enum class RequestKind {
|
||||||
ARTIST, ALBUM, TRACK;
|
ARTIST, ALBUM, TRACK;
|
||||||
|
|
||||||
val wire: String get() = name.lowercase()
|
val wire: String get() = name.lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lidarr search hit. Mirrors the Flutter client's
|
* Catalogue search hit — `mbid` is the result's own MBID; `artistMbid`
|
||||||
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
|
||||||
* and `albumMbid` are filled when the row is an album/track and the
|
* and `albumMbid` are filled when the row is an album/track and the
|
||||||
* UI needs the parent IDs to build the request.
|
* UI needs the parent IDs to build the request.
|
||||||
*
|
*
|
||||||
* `inLibrary` and `requested` together cover the "already-handled"
|
* `inLibrary` and `requested` together cover the "already-handled"
|
||||||
* cases — UI greys the row + swaps the Request button for a pill.
|
* cases — UI greys the row + swaps the Request button for a pill.
|
||||||
*/
|
*/
|
||||||
data class LidarrSearchResultRef(
|
data class CatalogueResultRef(
|
||||||
val mbid: String,
|
val mbid: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val secondaryText: String = "",
|
val secondaryText: String = "",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One quarantined track owned by the caller. Mirrors the Flutter
|
* One quarantined track owned by the caller. Mirrors the Flutter
|
||||||
* `QuarantineMineRow` (web `LidarrQuarantineMineRow`).
|
* `QuarantineMineRow` (web `QuarantineMineRow`).
|
||||||
*
|
*
|
||||||
* `reasonLabel` provides the display string mapped from the raw
|
* `reasonLabel` provides the display string mapped from the raw
|
||||||
* server-side reason key — UI renders it as a pill on the row.
|
* server-side reason key — UI renders it as a pill on the row.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status of a Lidarr request. Stored on the wire as a plain string;
|
* Status of a request. Stored on the wire as a plain string;
|
||||||
* the typed enum keeps switch / when blocks exhaustive and lets the
|
* the typed enum keeps switch / when blocks exhaustive and lets the
|
||||||
* UI map directly to a status pill color without raw-string matches.
|
* UI map directly to a status pill color without raw-string matches.
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +23,7 @@ enum class RequestStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One Lidarr request the user has submitted. Mirrors
|
* One request the user has submitted. Mirrors
|
||||||
* the Flutter client's `AdminRequest` —
|
* the Flutter client's `AdminRequest` —
|
||||||
* shared between the user-side `/api/requests` view and the admin
|
* shared between the user-side `/api/requests` view and the admin
|
||||||
* cross-user view since the wire shape is identical.
|
* cross-user view since the wire shape is identical.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ data class AdminQuarantineItemWire(
|
|||||||
@SerialName("artist_name") val artistName: String = "",
|
@SerialName("artist_name") val artistName: String = "",
|
||||||
@SerialName("album_title") val albumTitle: String = "",
|
@SerialName("album_title") val albumTitle: String = "",
|
||||||
@SerialName("album_id") val albumId: String = "",
|
@SerialName("album_id") val albumId: String = "",
|
||||||
@SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null,
|
|
||||||
@SerialName("report_count") val reportCount: Int = 0,
|
@SerialName("report_count") val reportCount: Int = 0,
|
||||||
@SerialName("latest_at") val latestAt: String = "",
|
@SerialName("latest_at") val latestAt: String = "",
|
||||||
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
|
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of `GET /api/lidarr/search`. Mirrors
|
* One row of `GET /api/catalogue/search` (the MusicBrainz search, M502
|
||||||
* `web/src/lib/api/types.ts LidarrSearchResult` /
|
* #5519). Mirrors `web/src/lib/api/types.ts
|
||||||
* the Flutter client's `LidarrSearchResult`.
|
* CatalogueSearchResult`. `imageUrl` is the server's own art endpoint,
|
||||||
|
* relative, so it goes through ServerImage.
|
||||||
*
|
*
|
||||||
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
||||||
* act on (already imported / already awaiting review). All defaults
|
* act on (already imported / already awaiting review). All defaults
|
||||||
* empty so missing keys don't throw on decode.
|
* empty so missing keys don't throw on decode.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LidarrSearchResultWire(
|
data class CatalogueSearchResultWire(
|
||||||
val mbid: String = "",
|
val mbid: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("secondary_text") val secondaryText: String = "",
|
@SerialName("secondary_text") val secondaryText: String = "",
|
||||||
@@ -26,8 +27,7 @@ data class LidarrSearchResultWire(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /api/discover/suggestions` row — an out-of-library artist
|
* `GET /api/discover/suggestions` row — an out-of-library artist
|
||||||
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
|
* surfaced from ListenBrainz-derived seeds. `imageUrl` may be empty.
|
||||||
* on-demand from Lidarr server-side and may be empty.
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ArtistSuggestionWire(
|
data class ArtistSuggestionWire(
|
||||||
@@ -93,10 +93,10 @@ data class SnoozeSuggestionBody(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class CreateRequestBody(
|
data class CreateRequestBody(
|
||||||
val kind: String,
|
val kind: String,
|
||||||
@SerialName("lidarr_artist_mbid") val artistMbid: String,
|
@SerialName("artist_mbid") val artistMbid: String,
|
||||||
@SerialName("artist_name") val artistName: String,
|
@SerialName("artist_name") val artistName: String,
|
||||||
@SerialName("lidarr_album_mbid") val albumMbid: String? = null,
|
@SerialName("album_mbid") val albumMbid: String? = null,
|
||||||
@SerialName("album_title") val albumTitle: String? = null,
|
@SerialName("album_title") val albumTitle: String? = null,
|
||||||
@SerialName("lidarr_track_mbid") val trackMbid: String? = null,
|
@SerialName("track_mbid") val trackMbid: String? = null,
|
||||||
@SerialName("track_title") val trackTitle: String? = null,
|
@SerialName("track_title") val trackTitle: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
|||||||
/**
|
/**
|
||||||
* One row of `GET /api/quarantine/mine`. Mirrors
|
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||||
* the Flutter client's `QuarantineMineRow`
|
* the Flutter client's `QuarantineMineRow`
|
||||||
* (web `LidarrQuarantineMineRow`).
|
* (web `QuarantineMineRow`).
|
||||||
*
|
*
|
||||||
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
||||||
* / `other`. Stored as a plain string on the wire; the UI does the
|
* / `other`. Stored as a plain string on the wire; the UI does the
|
||||||
|
|||||||
+30
-9
@@ -30,8 +30,9 @@ internal data class CatchUp(
|
|||||||
* - The mark moves to the newest notice in the page, announced or not, so a
|
* - The mark moves to the newest notice in the page, announced or not, so a
|
||||||
* kind with the phone off is never announced later.
|
* kind with the phone off is never announced later.
|
||||||
*
|
*
|
||||||
* A coalesced notice (tracks missing, now 5) moves its time forward when its
|
* A coalesced notice (tracks missing, now 5) keeps the time it arrived when
|
||||||
* count grows, so it is announced again with the new count.
|
* its count grows (M542), so it is announced once: the inbox shows it grown,
|
||||||
|
* the phone does not buzz for it again.
|
||||||
*/
|
*/
|
||||||
internal fun planCatchUp(
|
internal fun planCatchUp(
|
||||||
page: List<CachedNotificationEntity>,
|
page: List<CachedNotificationEntity>,
|
||||||
@@ -51,18 +52,34 @@ internal fun planCatchUp(
|
|||||||
internal sealed interface Announcement {
|
internal sealed interface Announcement {
|
||||||
data class Each(val items: List<CachedNotificationEntity>) : Announcement
|
data class Each(val items: List<CachedNotificationEntity>) : Announcement
|
||||||
|
|
||||||
/** Coming back from a day offline is one line, not forty buzzes. */
|
/**
|
||||||
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
|
* Coming back from a day offline is one line, not forty buzzes. [lines]
|
||||||
|
* are the newest notices' titles, so the pile says what it holds (M542).
|
||||||
|
*/
|
||||||
|
data class Pile(
|
||||||
|
val count: Int,
|
||||||
|
val channel: ShadeChannel,
|
||||||
|
val lines: List<String> = emptyList(),
|
||||||
|
) : Announcement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many titles an expanded pile lists, newest first. */
|
||||||
|
internal const val PILE_LINES = 5
|
||||||
|
|
||||||
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
|
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
|
||||||
fresh.isEmpty() -> null
|
fresh.isEmpty() -> null
|
||||||
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
|
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
|
||||||
// A pile goes where its notices would: library health only when every
|
else -> Announcement.Pile(
|
||||||
// one of them is, so a listener's own news is never filed under it.
|
count = fresh.size,
|
||||||
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
|
// A pile goes where its notices would: library health only when every
|
||||||
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
|
// one of them is, so a listener's own news is never filed under it.
|
||||||
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
|
channel = if (fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH }) {
|
||||||
|
ShadeChannel.LIBRARY_HEALTH
|
||||||
|
} else {
|
||||||
|
ShadeChannel.YOUR_REQUESTS
|
||||||
|
},
|
||||||
|
lines = fresh.sortedByDescending { it.createdAt }.take(PILE_LINES).map { it.title },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
|
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
|
||||||
@@ -92,3 +109,7 @@ internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = sign
|
|||||||
|
|
||||||
/** One line for a pile. */
|
/** One line for a pile. */
|
||||||
internal fun pileText(count: Int): String = "$count new notifications"
|
internal fun pileText(count: Int): String = "$count new notifications"
|
||||||
|
|
||||||
|
/** Under an expanded pile's titles: how many it holds beyond them, or null. */
|
||||||
|
internal fun pileMore(p: Announcement.Pile): String? =
|
||||||
|
(p.count - p.lines.size).takeIf { it > 0 }?.let { "and $it more" }
|
||||||
|
|||||||
+11
-4
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.notifications.delivery
|
package com.fabledsword.minstrel.notifications.delivery
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
@@ -50,14 +51,20 @@ class NotificationPoster @Inject constructor(
|
|||||||
.setContentIntent(openIntent(item.link, item.id.hashCode()))
|
.setContentIntent(openIntent(item.link, item.id.hashCode()))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun pile(p: Announcement.Pile) =
|
/** Collapsed, the count; expanded, the newest titles and how many more. */
|
||||||
NotificationCompat.Builder(context, p.channel.id)
|
private fun pile(p: Announcement.Pile): Notification {
|
||||||
|
val style = NotificationCompat.InboxStyle().setBigContentTitle(pileText(p.count))
|
||||||
|
p.lines.forEach { style.addLine(it) }
|
||||||
|
pileMore(p)?.let { style.setSummaryText(it) }
|
||||||
|
return NotificationCompat.Builder(context, p.channel.id)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle("Minstrel")
|
.setContentTitle(pileText(p.count))
|
||||||
.setContentText(pileText(p.count))
|
.setContentText(p.lines.joinToString(" · "))
|
||||||
|
.setStyle(style)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
|
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
/** Each notice gets its own request code, so their links stay apart. */
|
/** Each notice gets its own request code, so their links stay apart. */
|
||||||
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
|
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
|
||||||
|
|||||||
+10
-5
@@ -9,18 +9,23 @@ import com.fabledsword.minstrel.nav.Requests
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The app screen for a notice's link. The server writes web paths, the same
|
* The app screen for a notice's link. The server writes web paths, the same
|
||||||
* for every client; this maps them onto the app's routes. Admin pages the app
|
* for every client; this maps them onto the app's routes. The library's
|
||||||
* has no screen for (missing files, duplicates, playback errors) open the
|
* health pages sit under Collection on the web (M504): Reports opens the
|
||||||
* Admin landing; anything unknown opens nothing.
|
* app's report screen, and the ones the app has no screen for (duplicates,
|
||||||
|
* suspect sources, missing files) open the Admin landing, as do other Admin
|
||||||
|
* pages; anything unknown opens nothing.
|
||||||
*/
|
*/
|
||||||
|
private val ADMIN_COLLECTION_PAGES = setOf("duplicates", "suspect-sources", "missing")
|
||||||
|
|
||||||
internal fun routeForLink(link: String): Any? {
|
internal fun routeForLink(link: String): Any? {
|
||||||
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
|
val parts = link.substringBefore('?').trim('/').split('/').filter { it.isNotEmpty() }
|
||||||
return when {
|
return when {
|
||||||
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
|
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
|
||||||
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
|
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
|
||||||
parts == listOf("requests") -> Requests
|
parts == listOf("requests") -> Requests
|
||||||
parts == listOf("admin", "requests") -> AdminRequests
|
parts == listOf("admin", "requests") -> AdminRequests
|
||||||
parts == listOf("admin", "quarantine") -> AdminQuarantine
|
parts == listOf("collection", "reports") -> AdminQuarantine
|
||||||
|
parts.firstOrNull() == "collection" && parts.getOrNull(1) in ADMIN_COLLECTION_PAGES -> Admin
|
||||||
parts.firstOrNull() == "admin" -> Admin
|
parts.firstOrNull() == "admin" -> Admin
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import javax.inject.Singleton
|
|||||||
enum class CancelOutcome { Synced, Queued }
|
enum class CancelOutcome { Synced, Queued }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through accessor for the caller's own Lidarr requests +
|
* Read-through accessor for the caller's own requests +
|
||||||
* cancel-while-pending write. No Room caching for v1 — same rationale
|
* cancel-while-pending write. No Room caching for v1 — same rationale
|
||||||
* as HistoryRepository; the offline-snapshot mirror lands with a
|
* as HistoryRepository; the offline-snapshot mirror lands with a
|
||||||
* later slice. Cancel is offline-first: direct REST on the happy
|
* later slice. Cancel is offline-first: direct REST on the happy
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fun RequestsScreen(
|
|||||||
UiState.Loading -> LoadingCentered()
|
UiState.Loading -> LoadingCentered()
|
||||||
UiState.Empty -> EmptyState(
|
UiState.Empty -> EmptyState(
|
||||||
title = "Nothing requested yet",
|
title = "Nothing requested yet",
|
||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
@@ -218,7 +218,7 @@ private fun CancelConfirmDialog(
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Cancel request?") },
|
title = { Text("Cancel request?") },
|
||||||
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
|
text = { Text("Cancel “$displayName”? Your request is withdrawn.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(onClick = onConfirm) { Text("Cancel request") }
|
Button(onClick = onConfirm) { Text("Cancel request") }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,9 +135,8 @@ class AuthCookieInterceptorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drift #569 regression guard: a 401 from an external host must
|
// Drift #569 regression guard: a 401 from an external host must
|
||||||
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
|
// NOT wipe the user's Minstrel session. A misbehaving CDN that's
|
||||||
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
|
// 401-ing on artwork URLs used to silently sign the user out.
|
||||||
// the user out.
|
|
||||||
@Test
|
@Test
|
||||||
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
||||||
authStore.setSessionCookie("session=keep")
|
authStore.setSessionCookie("session=keep")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class BaseUrlInterceptorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `passes external absolute URL through unchanged`() {
|
fun `passes external absolute URL through unchanged`() {
|
||||||
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
|
// External artwork hosts (artwork.musicbrainz.org,
|
||||||
// coverartarchive.org) are absolute external URLs. The
|
// coverartarchive.org) are absolute external URLs. The
|
||||||
// interceptor must NOT rewrite them to the Minstrel base —
|
// interceptor must NOT rewrite them to the Minstrel base —
|
||||||
// doing so produces 404s the user saw as missing Discover
|
// doing so produces 404s the user saw as missing Discover
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.models.wire.SyncDeletesWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SyncResponseWire
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server returns at most 1000 changes a call (#5654). One page per
|
||||||
|
* trigger left the cache behind the change log, so deletes from duplicate
|
||||||
|
* merges never arrived; a pass pulls pages until the server answers 204.
|
||||||
|
*/
|
||||||
|
class SyncControllerTest {
|
||||||
|
private val api: SyncApi = mockk()
|
||||||
|
private val trackDao: CachedTrackDao = mockk(relaxed = true)
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(SyncApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val authStore: AuthStore = mockk {
|
||||||
|
every { sessionCookie } returns MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
private val networkStatus: NetworkStatusController = mockk {
|
||||||
|
// Offline, so construction starts no pass; each test runs one itself.
|
||||||
|
every { state } returns MutableStateFlow(ServerHealth.Offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor round-trips through the DAO, so it is held for real.
|
||||||
|
private var stored: SyncMetadataEntity? = null
|
||||||
|
private val metadataDao: SyncMetadataDao = mockk {
|
||||||
|
coEvery { get() } answers { stored }
|
||||||
|
coEvery { upsert(any()) } answers { stored = firstArg() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun page(cursor: Long, deletedTracks: List<String> = emptyList()) =
|
||||||
|
Response.success(SyncResponseWire(cursor = cursor, deletes = SyncDeletesWire(track = deletedTracks)))
|
||||||
|
|
||||||
|
private fun noContent(): Response<SyncResponseWire> = Response.success(204, null as SyncResponseWire?)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pages are pulled until the server has nothing newer`() = runTest {
|
||||||
|
coEvery { api.sync(0) } returns page(1000)
|
||||||
|
coEvery { api.sync(1000) } returns page(2000, deletedTracks = listOf("merged-away"))
|
||||||
|
coEvery { api.sync(2000) } returns noContent()
|
||||||
|
|
||||||
|
val controller = SyncController(
|
||||||
|
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
|
||||||
|
trackDao, authStore, backgroundScope, networkStatus, retrofit,
|
||||||
|
)
|
||||||
|
controller.syncSafe()
|
||||||
|
|
||||||
|
assertNull(controller.lastSyncError.value)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { api.sync(2000) }
|
||||||
|
coVerify { trackDao.deleteByIds(listOf("merged-away")) }
|
||||||
|
assertEquals(2000L, stored?.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cursor that does not advance stops the pass`() = runTest {
|
||||||
|
stored = SyncMetadataEntity(cursor = 500)
|
||||||
|
coEvery { api.sync(500) } returns page(500)
|
||||||
|
|
||||||
|
val controller = SyncController(
|
||||||
|
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
|
||||||
|
trackDao, authStore, backgroundScope, networkStatus, retrofit,
|
||||||
|
)
|
||||||
|
controller.syncSafe()
|
||||||
|
|
||||||
|
assertNull(controller.lastSyncError.value)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { api.sync(500) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stale cursor resets to zero and keeps pulling`() = runTest {
|
||||||
|
stored = SyncMetadataEntity(cursor = 7)
|
||||||
|
coEvery { api.sync(7) } returns Response.error(410, "".toResponseBody())
|
||||||
|
coEvery { api.sync(0) } returns page(1000)
|
||||||
|
coEvery { api.sync(1000) } returns noContent()
|
||||||
|
|
||||||
|
val controller = SyncController(
|
||||||
|
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
|
||||||
|
trackDao, authStore, backgroundScope, networkStatus, retrofit,
|
||||||
|
)
|
||||||
|
controller.syncSafe()
|
||||||
|
|
||||||
|
assertNull(controller.lastSyncError.value)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { api.sync(1000) }
|
||||||
|
assertEquals(1000L, stored?.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@@ -36,6 +39,30 @@ class MetadataProviderTest {
|
|||||||
coVerify(exactly = 1) { library.refreshAlbumDetail("a1") }
|
coVerify(exactly = 1) { library.refreshAlbumDetail("a1") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A row the server no longer has (a duplicate merge) is dropped, or the
|
||||||
|
// freshness sweep asks for it every 30 minutes forever (#5654).
|
||||||
|
@Test
|
||||||
|
fun `a 404 drops the cached row`() = runTest {
|
||||||
|
coEvery { library.refreshTrack("t1") } throws httpError(404)
|
||||||
|
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, this)
|
||||||
|
|
||||||
|
provider.refreshTrack("t1").join()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { trackDao.deleteByIds(listOf("t1")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `any other failure keeps the cached row`() = runTest {
|
||||||
|
coEvery { library.refreshAlbumDetail("a1") } throws httpError(500)
|
||||||
|
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, this)
|
||||||
|
|
||||||
|
provider.refreshAlbum("a1").join()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { albumDao.deleteByIds(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `at most four fetches run concurrently`() = runTest {
|
fun `at most four fetches run concurrently`() = runTest {
|
||||||
val scope = this
|
val scope = this
|
||||||
|
|||||||
+10
@@ -99,6 +99,16 @@ class DeliveryPlanTest {
|
|||||||
assertEquals(5, pile.count)
|
assertEquals(5, pile.count)
|
||||||
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
|
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
|
||||||
assertEquals("5 new notifications", pileText(pile.count))
|
assertEquals("5 new notifications", pileText(pile.count))
|
||||||
|
assertEquals(listOf("t-n5", "t-n4", "t-n3", "t-n2", "t-n1"), pile.lines)
|
||||||
|
assertNull(pileMore(pile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pile names its newest notices and counts the rest`() {
|
||||||
|
val many = (1..17).map { notice("n$it", it.toLong()) }
|
||||||
|
val pile = assertIs<Announcement.Pile>(announcementFor(many))
|
||||||
|
assertEquals(listOf("t-n17", "t-n16", "t-n15", "t-n14", "t-n13"), pile.lines)
|
||||||
|
assertEquals("and 12 more", pileMore(pile))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+5
-3
@@ -23,9 +23,11 @@ class NotificationUiTest {
|
|||||||
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
|
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
|
||||||
assertEquals(Requests, routeForLink("/requests"))
|
assertEquals(Requests, routeForLink("/requests"))
|
||||||
assertEquals(AdminRequests, routeForLink("/admin/requests"))
|
assertEquals(AdminRequests, routeForLink("/admin/requests"))
|
||||||
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
|
assertEquals(AdminQuarantine, routeForLink("/collection/reports"))
|
||||||
// Admin pages the app has no screen for open the Admin landing.
|
// Library health pages the app has no screen for open the Admin landing.
|
||||||
assertEquals(Admin, routeForLink("/admin/duplicates"))
|
assertEquals(Admin, routeForLink("/collection/duplicates"))
|
||||||
|
assertEquals(Admin, routeForLink("/collection/duplicates?view=resolved"))
|
||||||
|
assertEquals(Admin, routeForLink("/collection/missing"))
|
||||||
assertEquals(Admin, routeForLink("/admin"))
|
assertEquals(Admin, routeForLink("/admin"))
|
||||||
assertNull(routeForLink("/somewhere-new"))
|
assertNull(routeForLink("/somewhere-new"))
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-50
@@ -9,28 +9,30 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
@@ -38,6 +40,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -133,7 +136,18 @@ func run() error {
|
|||||||
if fpErr != nil {
|
if fpErr != nil {
|
||||||
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
||||||
}
|
}
|
||||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
// The library's folders (#6129): the ones saved in Settings, or
|
||||||
|
// MINSTREL_LIBRARY_SCAN_PATHS until any are. A load failure falls back to
|
||||||
|
// env, which is what the server read before the setting existed.
|
||||||
|
roots, rootsSaved, rootsErr := library.LoadRoots(ctx, pool, cfg.Library.ScanPaths)
|
||||||
|
switch {
|
||||||
|
case rootsErr != nil:
|
||||||
|
logger.Warn("library folders: using MINSTREL_LIBRARY_SCAN_PATHS", "err", rootsErr)
|
||||||
|
case rootsSaved && len(cfg.Library.ScanPaths) > 0 && !slices.Equal(roots, cfg.Library.ScanPaths):
|
||||||
|
logger.Warn("library folders: the ones saved in Settings are used, not MINSTREL_LIBRARY_SCAN_PATHS",
|
||||||
|
"settings", roots, "env", cfg.Library.ScanPaths)
|
||||||
|
}
|
||||||
|
scanner := library.New(pool, logger, roots, fpSettings)
|
||||||
|
|
||||||
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
||||||
// and the admin API, and falls back to the defaults like the above.
|
// and the admin API, and falls back to the defaults like the above.
|
||||||
@@ -148,8 +162,11 @@ func run() error {
|
|||||||
if acoustIDErr != nil {
|
if acoustIDErr != nil {
|
||||||
logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
|
logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
|
||||||
}
|
}
|
||||||
|
// One AcoustID client, so the lookup worker and the import's check share
|
||||||
|
// its pace (AcoustID allows three requests a second per key).
|
||||||
|
acoustIDClient := acoustid.New(acoustid.DefaultBaseURL)
|
||||||
acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
|
acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
|
||||||
acoustIDSettings, acoustid.New(acoustid.DefaultBaseURL))
|
acoustIDSettings, acoustIDClient)
|
||||||
|
|
||||||
contact := cfg.Library.ContactEmail
|
contact := cfg.Library.ContactEmail
|
||||||
if contact == "" {
|
if contact == "" {
|
||||||
@@ -190,7 +207,7 @@ func run() error {
|
|||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
if cfg.Library.ScanOnStartup && len(cfg.Library.ScanPaths) > 0 {
|
if cfg.Library.ScanOnStartup && len(roots) > 0 {
|
||||||
go func() {
|
go func() {
|
||||||
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
|
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
|
||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
@@ -291,10 +308,8 @@ func run() error {
|
|||||||
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
|
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
|
||||||
go gcWorker.Run(ctx)
|
go gcWorker.Run(ctx)
|
||||||
|
|
||||||
// Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr
|
// The request reconciler completes approved requests once the library
|
||||||
// import requests and reconciles them against the library. Short-circuits
|
// holds what they asked for (M502 #5520).
|
||||||
// to no-op when lidarr_config.enabled = false.
|
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
|
||||||
// Live-event bus shared between SSE subscribers (api.Mount) and
|
// Live-event bus shared between SSE subscribers (api.Mount) and
|
||||||
// background workers that publish (reconciler today; scanner later).
|
// background workers that publish (reconciler today; scanner later).
|
||||||
// Constructed before any service that publishes so they all share the
|
// Constructed before any service that publishes so they all share the
|
||||||
@@ -304,45 +319,62 @@ func run() error {
|
|||||||
// threading the bus through RunScan + TryStartScan + the Scheduler
|
// threading the bus through RunScan + TryStartScan + the Scheduler
|
||||||
// + every test caller. Per-process singleton, set once at startup.
|
// + every test caller. Per-process singleton, set once at startup.
|
||||||
library.SetEventBus(bus)
|
library.SetEventBus(bus)
|
||||||
// Per-tick Lidarr client factory: re-reads config so an admin save
|
|
||||||
// takes effect without a restart, returning nil while Lidarr is
|
|
||||||
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
|
|
||||||
// reconciler uses it to (re)send unconfirmed adds.
|
|
||||||
lidarrClientFn := func() *lidarr.Client {
|
|
||||||
c, cerr := lidarrCfg.Get(ctx)
|
|
||||||
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
|
||||||
}
|
|
||||||
// The notifications inbox's one writer (M489), shared by every
|
// The notifications inbox's one writer (M489), shared by every
|
||||||
// background producer started here. The API builds its own over the
|
// background producer started here. The API builds its own over the
|
||||||
// same pool and bus.
|
// same pool and bus.
|
||||||
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
|
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
|
||||||
|
|
||||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
|
requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus)
|
||||||
lidarrReconciler.SetNotifier(notifier)
|
requestReconciler.SetNotifier(notifier)
|
||||||
library.SetNotifier(notifier)
|
library.SetNotifier(notifier)
|
||||||
go lidarrReconciler.Run(ctx)
|
go requestReconciler.Run(ctx)
|
||||||
|
|
||||||
// Duplicate resolver (M498): classifies the sweep's groups and, with the
|
// Release catalogue (M502 #5519): re-reads discographies older than a
|
||||||
// operator's auto-resolve on, merges copies Lidarr does not map and moves a
|
// week from MusicBrainz, a batch an hour, through the process-wide
|
||||||
// Lidarr release that lists songs twice. Started after SetNotifier so its
|
// MusicBrainz pacer it shares with tag enrichment.
|
||||||
// summaries reach admins. See internal/library/duplicate_resolve.go.
|
cat := catalogue.New(pool, nil, logger.With("component", "catalogue"))
|
||||||
resolverLidarr := func() library.LidarrLibrary {
|
go cat.Run(ctx)
|
||||||
if c := lidarrClientFn(); c != nil {
|
|
||||||
return c
|
// The wanted list (M502 #5520): reads followed artists' discographies
|
||||||
}
|
// daily for new releases and marks wants held once the library has them.
|
||||||
return nil // a nil *lidarr.Client must not become a non-nil interface
|
go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx)
|
||||||
|
|
||||||
|
// Search and grab (M502 #5521): every ten minutes, searches the
|
||||||
|
// configured indexers for wants due, paced by each source's
|
||||||
|
// searches-per-hour, and hands the best release to the download client
|
||||||
|
// when auto-grab is on.
|
||||||
|
go wanted.NewSearcher(pool, cat, logger.With("component", "search")).Run(ctx)
|
||||||
|
|
||||||
|
// Import with verification (M502 #5522): every minute, finished
|
||||||
|
// downloads are checked against their album (AcoustID, lengths, tags)
|
||||||
|
// and filed into the library with MusicBrainz tags, or refused.
|
||||||
|
var printer importer.Printer
|
||||||
|
if acoustIDSettings != nil {
|
||||||
|
printer = &importer.AcoustIDPrinter{Settings: acoustIDSettings, Client: acoustIDClient}
|
||||||
}
|
}
|
||||||
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
imp := importer.New(pool, scanner, cat, printer, scanner.Roots, logger.With("component", "import"))
|
||||||
cfg.Storage.DataDir, fpSettings, resolverLidarr).Run(ctx)
|
imp.DataDir = cfg.Storage.DataDir
|
||||||
|
go imp.Run(ctx)
|
||||||
|
|
||||||
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
// Download lifecycle (M502 #5523): every ten minutes, a download that
|
||||||
// have been gone longer than the grace window into Lidarr requests, on an
|
// failed or that nobody shares is retried, then given up and its album
|
||||||
// exponential per-album backoff. Hourly tick — the shortest meaningful
|
// searched again; an imported one seeds, then is removed.
|
||||||
// backoff is measured in hours, so waking more often would only re-read
|
go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx)
|
||||||
// settings and find nothing due.
|
|
||||||
|
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
|
||||||
|
// the operator's auto-resolve on, merges the copies that are safe to merge.
|
||||||
|
// Started after SetNotifier so its summaries reach admins. It asks the
|
||||||
|
// catalogue for the track list of an album whose wrong slot it must
|
||||||
|
// decide, a few a pass. See internal/library/duplicate_resolve.go.
|
||||||
|
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
||||||
|
cfg.Storage.DataDir, fpSettings, library.ResolveSources{
|
||||||
|
TrackLists: cat.RefreshReleaseGroup, Identify: acoustIDLookup.IdentifyRecordings,
|
||||||
|
}).Run(ctx)
|
||||||
|
|
||||||
|
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
|
||||||
|
// albums whose files have been gone longer than the grace window; the
|
||||||
|
// search schedule looks for them. Hourly tick — the grace window is
|
||||||
|
// measured in hours.
|
||||||
//
|
//
|
||||||
// A settings-load failure is logged, not fatal: NewSettingsService always
|
// A settings-load failure is logged, not fatal: NewSettingsService always
|
||||||
// returns a usable service holding the defaults, and running on defaults
|
// returns a usable service holding the defaults, and running on defaults
|
||||||
@@ -353,14 +385,7 @@ func run() error {
|
|||||||
if reacqErr != nil {
|
if reacqErr != nil {
|
||||||
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
||||||
}
|
}
|
||||||
reacqSweeper := reacquisition.NewSweeper(
|
go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx)
|
||||||
pool,
|
|
||||||
reacqSettings,
|
|
||||||
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
|
|
||||||
logger.With("component", "reacquisition"),
|
|
||||||
)
|
|
||||||
reacqSweeper.SetNotifier(notifier)
|
|
||||||
go reacqSweeper.Run(ctx)
|
|
||||||
|
|
||||||
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||||
// rows older than the configured retention so the change-log table
|
// rows older than the configured retention so the change-log table
|
||||||
@@ -421,8 +446,7 @@ func run() error {
|
|||||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||||
// fsnotify, scanning just the affected paths + enriching their albums
|
// fsnotify, scanning just the affected paths + enriching their albums
|
||||||
// inline. Replaces the removed configurable scan scheduler.
|
// inline. Replaces the removed configurable scan scheduler.
|
||||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
watcher := library.NewWatcher(scanner, coverEnricher, logger.With("component", "watcher"))
|
||||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
|
||||||
go func() {
|
go func() {
|
||||||
if werr := watcher.Run(ctx); werr != nil {
|
if werr := watcher.Run(ctx); werr != nil {
|
||||||
logger.Warn("library watcher exited", "err", werr)
|
logger.Warn("library watcher exited", "err", werr)
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,10 @@ log:
|
|||||||
|
|
||||||
library:
|
library:
|
||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
|
# These are the starting folders only: once they are changed in
|
||||||
|
# Admin › Library, the saved ones are used and this value is ignored.
|
||||||
|
# Changing a folder there moves its tracks with it, so moving the library
|
||||||
|
# to a new mount keeps every play, like and playlist.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
scan_paths: []
|
scan_paths: []
|
||||||
# Kick off a scan on server startup. On by default so a fresh install
|
# Kick off a scan on server startup. On by default so a fresh install
|
||||||
@@ -41,7 +45,7 @@ library:
|
|||||||
# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION
|
# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION
|
||||||
branding:
|
branding:
|
||||||
app_name: "Minstrel"
|
app_name: "Minstrel"
|
||||||
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration."
|
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition."
|
||||||
|
|
||||||
subsonic:
|
subsonic:
|
||||||
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
|
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ Publish the port to the network and use `http://<host>:4533`:
|
|||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
```
|
```
|
||||||
|
|
||||||
If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
|
If nothing sits in front of Minstrel, set **Admin › Email & network → Client IP
|
||||||
detection** to 0. It defaults to 1, which assumes one proxy (see below for
|
detection** to 0. It defaults to 1, which assumes one proxy (see below for
|
||||||
why that matters).
|
why that matters).
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Three things, all required:
|
|||||||
the app is. Raise the proxy's read timeout well past a minute (nginx:
|
the app is. Raise the proxy's read timeout well past a minute (nginx:
|
||||||
`proxy_read_timeout 1h;`).
|
`proxy_read_timeout 1h;`).
|
||||||
|
|
||||||
3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
|
3. **Tell Minstrel where it lives**, in **Admin › Email & network**:
|
||||||
- **Client IP detection:** the number of proxies in front of Minstrel. One
|
- **Client IP detection:** the number of proxies in front of Minstrel. One
|
||||||
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
|
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
|
||||||
Minstrel uses this to find the real client address, for login rate
|
Minstrel uses this to find the real client address, for login rate
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ across all shots for a consistent look.
|
|||||||
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||||
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||||
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
| `integrations.png` | `/admin/acquisition` | Indexers, download clients and searching |
|
||||||
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Project Spec: Smart Music Client (companion app)
|
# Project Spec: Smart Music Client (companion app)
|
||||||
|
|
||||||
|
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
|
||||||
|
|
||||||
## 1. Overview
|
## 1. Overview
|
||||||
|
|
||||||
A mobile music player client built as the intentional companion to the Smart Music Server (see `smart-music-server-spec.md`). This is not a general-purpose Subsonic client — it is the client that knows how to consume the server's extended API and surface its novel features (contextual likes, session-aware radio, Lidarr integration, server-canonical state) as first-class UX.
|
A mobile music player client built as the intentional companion to the Smart Music Server (see `smart-music-server-spec.md`). This is not a general-purpose Subsonic client — it is the client that knows how to consume the server's extended API and surface its novel features (contextual likes, session-aware radio, Lidarr integration, server-canonical state) as first-class UX.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Project Spec: Smart Music Server (working name)
|
# Project Spec: Smart Music Server (working name)
|
||||||
|
|
||||||
|
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
|
||||||
|
|
||||||
## 1. Overview
|
## 1. Overview
|
||||||
|
|
||||||
A self-hosted music server and streaming backend built to close a real gap in the self-hosted music ecosystem: **state and intelligence belong on the server, not the client.** Existing Subsonic-compatible servers (Navidrome, Gonic, Airsonic-Advanced) defer all of the "smart" behavior — shuffle logic, radio, play count semantics, like management — to clients. The result is fragmented experience across devices: a song liked on one client is unknown to another; shuffle quality depends on which app you opened; play counts diverge. This project centralizes those decisions on the server so every client sees the same canonical state.
|
A self-hosted music server and streaming backend built to close a real gap in the self-hosted music ecosystem: **state and intelligence belong on the server, not the client.** Existing Subsonic-compatible servers (Navidrome, Gonic, Airsonic-Advanced) defer all of the "smart" behavior — shuffle logic, radio, play count semantics, like management — to clients. The result is fragmented experience across devices: a song liked on one client is unknown to another; shuffle quality depends on which app you opened; play counts diverge. This project centralizes those decisions on the server so every client sees the same canonical state.
|
||||||
|
|||||||
@@ -198,10 +198,14 @@ func TestLookup_KeepsToTheRateLimit(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The client spaces its sends by minInterval; the server sees arrivals,
|
||||||
|
// which transit can bunch by a few milliseconds (389ms under -race, CI run
|
||||||
|
// 9543). What AcoustID enforces is 3 a second at its end, so that is the
|
||||||
|
// bound: minInterval's headroom is what absorbs the bunching.
|
||||||
|
const serviceLimit = time.Second / 3
|
||||||
for i := 1; i < len(at); i++ {
|
for i := 1; i < len(at); i++ {
|
||||||
// A little slack for the server's own clock reading.
|
if gap := at[i].Sub(at[i-1]); gap < serviceLimit {
|
||||||
if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond {
|
t.Errorf("requests %d and %d arrived %s apart, want at least %s", i-1, i, gap, serviceLimit)
|
||||||
t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validUpdate() Update {
|
||||||
|
return Update{Label: "Prowlarr", BaseURL: "http://prowlarr:9696", TimeoutMs: 30000, LocalPath: "/downloads", SearchesPerHour: 120}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidate(t *testing.T) {
|
||||||
|
if err := validUpdate().validate(); err != nil {
|
||||||
|
t.Fatalf("a valid form: %v", err)
|
||||||
|
}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
edit func(*Update)
|
||||||
|
field string
|
||||||
|
}{
|
||||||
|
"no name": {func(u *Update) { u.Label = "" }, "label"},
|
||||||
|
"switched on with no address": {func(u *Update) { u.Enabled, u.BaseURL = true, "" }, "base_url"},
|
||||||
|
"an address with no scheme": {func(u *Update) { u.BaseURL = "prowlarr:9696" }, "base_url"},
|
||||||
|
"a timeout too short": {func(u *Update) { u.TimeoutMs = 10 }, "timeout_ms"},
|
||||||
|
"a relative downloads folder": {func(u *Update) { u.LocalPath = "downloads" }, "local_path"},
|
||||||
|
"a relative client folder": {func(u *Update) { u.RemotePath = "data" }, "remote_path"},
|
||||||
|
"too many searches": {func(u *Update) { u.SearchesPerHour = 4000 }, "searches_per_hour"},
|
||||||
|
} {
|
||||||
|
u := validUpdate()
|
||||||
|
tc.edit(&u)
|
||||||
|
var invalid *InvalidError
|
||||||
|
if err := u.validate(); !errors.As(err, &invalid) || invalid.Field != tc.field {
|
||||||
|
t.Errorf("%s: err = %v, want an InvalidError on %s", name, err, tc.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Switched off, an empty address is a row still being set up.
|
||||||
|
u := validUpdate()
|
||||||
|
u.BaseURL = ""
|
||||||
|
if err := u.validate(); err != nil {
|
||||||
|
t.Errorf("switched off with no address: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceFor_BuildsTheClientTheProtocolNames(t *testing.T) {
|
||||||
|
for protocol, check := range map[string]func(Source) bool{
|
||||||
|
ProtocolProwlarr: func(s Source) bool { _, ok := s.Search.(*prowlarr.Client); return ok && s.Fetch != nil },
|
||||||
|
ProtocolTorznab: func(s Source) bool { _, ok := s.Search.(*indexer.Torznab); return ok && s.Fetch != nil },
|
||||||
|
// Exchange's results are all magnets: nothing to fetch through it.
|
||||||
|
ProtocolExchange: func(s Source) bool { _, ok := s.Search.(*exchange.Client); return ok && s.Fetch == nil },
|
||||||
|
} {
|
||||||
|
src := SourceFor(dbq.AcquisitionService{Code: protocol, Protocol: protocol, SearchesPerHour: 7})
|
||||||
|
if !check(src) || src.Code != protocol || src.PerHour != 7 {
|
||||||
|
t.Errorf("%s: source %+v", protocol, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobs_AnExchangeDoesWhatItsSwitchesAllow(t *testing.T) {
|
||||||
|
ex := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolExchange, ServesSearch: true}
|
||||||
|
if !Searches(ex) || Fetches(ex) {
|
||||||
|
t.Error("an exchange with only search on should search and not fetch")
|
||||||
|
}
|
||||||
|
// A qBittorrent's switches mean nothing: it fetches, whatever they say.
|
||||||
|
qb := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolQBittorrent, ServesSearch: true}
|
||||||
|
if Searches(qb) || !Fetches(qb) {
|
||||||
|
t.Error("a qBittorrent fetches and never searches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTest_NoAddressIsNothingToReach(t *testing.T) {
|
||||||
|
r := Test(context.Background(), dbq.AcquisitionService{Protocol: ProtocolProwlarr, Kind: KindSearch})
|
||||||
|
if r.OK || !strings.Contains(r.Detail, "No address") {
|
||||||
|
t.Fatalf("result = %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTest_SearchReportsResultsAndARefusedKey(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("X-Api-Key") != "good" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{"title": "Radiohead - OK Computer"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
row := dbq.AcquisitionService{Kind: KindSearch, Protocol: ProtocolProwlarr, BaseUrl: srv.URL, ApiKey: "good", TimeoutMs: 5000}
|
||||||
|
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, "1 results") {
|
||||||
|
t.Errorf("a working key: %+v", r)
|
||||||
|
}
|
||||||
|
row.ApiKey = "bad"
|
||||||
|
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "refused") {
|
||||||
|
t.Errorf("a refused key: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeExchange answers the three native APIs.
|
||||||
|
func fakeExchange(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/downloads":
|
||||||
|
_, _ = w.Write([]byte(`{"enabled": true, "running": true, "inbound_conns": 3, "downloads": []}`))
|
||||||
|
case r.URL.Path == "/api/search":
|
||||||
|
_, _ = w.Write([]byte(`{"total": 0, "results": []}`))
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/torrents/"):
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"error": "unknown torrent"}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTest_AnExchangeReportsEachJobSwitchedOn(t *testing.T) {
|
||||||
|
srv := fakeExchange(t)
|
||||||
|
row := dbq.AcquisitionService{
|
||||||
|
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
||||||
|
ServesDownloads: true, ServesSearch: true, ServesEnrichment: true, LocalPath: t.TempDir(),
|
||||||
|
}
|
||||||
|
r := Test(context.Background(), row)
|
||||||
|
if !r.OK {
|
||||||
|
t.Fatalf("all three jobs: %+v", r)
|
||||||
|
}
|
||||||
|
for _, job := range []string{"Downloads:", "Search:", "Release details:"} {
|
||||||
|
if !strings.Contains(r.Detail, job) {
|
||||||
|
t.Errorf("detail %q does not report %s", r.Detail, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.ServesDownloads, row.ServesEnrichment = false, false
|
||||||
|
if r := Test(context.Background(), row); strings.Contains(r.Detail, "Downloads:") || !strings.Contains(r.Detail, "Search:") {
|
||||||
|
t.Errorf("only search on: %+v", r)
|
||||||
|
}
|
||||||
|
row.ServesSearch = false
|
||||||
|
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "switched off") {
|
||||||
|
t.Errorf("every job off: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTest_ADownloadClientNeedsAFolderMinstrelCanSee(t *testing.T) {
|
||||||
|
srv := fakeExchange(t)
|
||||||
|
row := dbq.AcquisitionService{
|
||||||
|
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
||||||
|
ServesDownloads: true, Category: DefaultCategory,
|
||||||
|
LocalPath: filepath.Join(t.TempDir(), "not-mounted"),
|
||||||
|
}
|
||||||
|
r := Test(context.Background(), row)
|
||||||
|
if r.OK || !strings.Contains(r.Detail, "cannot see its downloads folder") {
|
||||||
|
t.Fatalf("a missing folder: %+v", r)
|
||||||
|
}
|
||||||
|
row.LocalPath = t.TempDir()
|
||||||
|
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, `"minstrel" category`) {
|
||||||
|
t.Errorf("a visible, writable folder: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadClient_WrapsThePathMapping(t *testing.T) {
|
||||||
|
d, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolQBittorrent, LocalPath: "/downloads", RemotePath: "/data"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, plain := d.(*downloader.Client); plain {
|
||||||
|
t.Error("a row naming two folders should come back wrapped in its mapping")
|
||||||
|
}
|
||||||
|
if _, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolProwlarr}); err == nil {
|
||||||
|
t.Error("a search protocol is not a download client")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build makes a Service from the settings as they stand: every enabled
|
||||||
|
// search source behind one paced Fanout, the first enabled download client,
|
||||||
|
// and the quality profile. Built afresh for each pass and each request, so a
|
||||||
|
// changed setting takes effect on the next one.
|
||||||
|
func Build(ctx context.Context, pool *pgxpool.Pool) (*Service, dbq.AcquisitionSetting, error) {
|
||||||
|
q := dbq.New(pool)
|
||||||
|
settings, err := q.GetAcquisitionSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, settings, fmt.Errorf("acquire: read settings: %w", err)
|
||||||
|
}
|
||||||
|
sources, err := Sources(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
return nil, settings, fmt.Errorf("acquire: read search sources: %w", err)
|
||||||
|
}
|
||||||
|
clients, err := q.ListUsableAcquisitionServices(ctx, JobDownload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, settings, fmt.Errorf("acquire: read download clients: %w", err)
|
||||||
|
}
|
||||||
|
s := &Service{Profile: release.NewProfile(settings.AllowedQualities), Memory: NewMemory(pool)}
|
||||||
|
fan := &Fanout{Sources: sources, Pace: SearchPace}
|
||||||
|
if len(sources) > 0 {
|
||||||
|
s.Search = fan
|
||||||
|
}
|
||||||
|
if len(clients) > 0 {
|
||||||
|
row := clients[0]
|
||||||
|
client, err := DownloadClient(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, settings, fmt.Errorf("acquire: download client %s: %w", row.Code, err)
|
||||||
|
}
|
||||||
|
s.Grabber = &Downloader{Client: client, Code: row.Code, Landing: row.LocalPath, Fetch: fan}
|
||||||
|
}
|
||||||
|
return s, settings, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Searching every source at once (M502 #5521), ported from Fabled Librarian
|
||||||
|
// (#4182). A source that is down narrows a search rather than failing it: the
|
||||||
|
// results come back with the list of who did not answer.
|
||||||
|
|
||||||
|
// SourceFailure is one source that could not be reached.
|
||||||
|
type SourceFailure struct {
|
||||||
|
Code string
|
||||||
|
Label string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartialError is a search that reached some sources and not others. It is
|
||||||
|
// returned alongside the results, not instead of them.
|
||||||
|
type PartialError struct {
|
||||||
|
Failures []SourceFailure
|
||||||
|
// Answered is how many sources did answer. Zero means the search failed.
|
||||||
|
Answered int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PartialError) Error() string {
|
||||||
|
if e == nil || len(e.Failures) == 0 {
|
||||||
|
return "some sources did not answer"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(e.Failures))
|
||||||
|
for _, f := range e.Failures {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s: %s", f.Label, indexer.FirstLine(f.Err.Error())))
|
||||||
|
}
|
||||||
|
return "some sources did not answer — " + strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap lets errors.Is find indexer.ErrUnreachable through a partial failure.
|
||||||
|
func (e *PartialError) Unwrap() []error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]error, 0, len(e.Failures))
|
||||||
|
for _, f := range e.Failures {
|
||||||
|
out = append(out, f.Err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fanout searches every source and merges what comes back. It is both the
|
||||||
|
// Searcher and the Fetcher, routing a .torrent fetch to the source that
|
||||||
|
// offered the result.
|
||||||
|
type Fanout struct {
|
||||||
|
// Sources in code order: the fold keeps a duplicate's first arrival, so
|
||||||
|
// the order decides which source a grab is fetched through.
|
||||||
|
Sources []Source
|
||||||
|
// Pace spaces each source's searches. Nil paces nothing.
|
||||||
|
Pace *Pacer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search asks every source concurrently. Each client bounds its own call by
|
||||||
|
// its configured timeout, so there is no shared deadline here.
|
||||||
|
func (f *Fanout) Search(ctx context.Context, query indexer.Query) ([]indexer.Result, error) {
|
||||||
|
if f == nil || len(f.Sources) == 0 {
|
||||||
|
return nil, indexer.ErrUnconfigured
|
||||||
|
}
|
||||||
|
found := make([][]indexer.Result, len(f.Sources))
|
||||||
|
errs := make([]error, len(f.Sources))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, src := range f.Sources {
|
||||||
|
if src.Search == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.Pace.spend(src)
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, src Source) {
|
||||||
|
defer wg.Done()
|
||||||
|
found[i], errs[i] = src.Search.Search(ctx, query)
|
||||||
|
}(i, src)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
out := make([]indexer.Result, 0, 32)
|
||||||
|
var failures []SourceFailure
|
||||||
|
answered, unconfigured := 0, 0
|
||||||
|
for i, src := range f.Sources {
|
||||||
|
if src.Search == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(errs[i], indexer.ErrUnconfigured) {
|
||||||
|
unconfigured++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rl, ok := indexer.AsRateLimited(errs[i]); ok {
|
||||||
|
f.Pace.hold(src.Code, rl)
|
||||||
|
}
|
||||||
|
if errs[i] != nil {
|
||||||
|
failures = append(failures, SourceFailure{Code: src.Code, Label: src.Label, Err: errs[i]})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
answered++
|
||||||
|
for j := range found[i] {
|
||||||
|
if found[i][j].Source == "" {
|
||||||
|
found[i][j].Source = src.Code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, found[i]...)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case answered == 0 && len(failures) > 0:
|
||||||
|
return nil, &PartialError{Failures: failures}
|
||||||
|
case len(failures) > 0:
|
||||||
|
return out, &PartialError{Failures: failures, Answered: answered}
|
||||||
|
case answered == 0 && unconfigured > 0:
|
||||||
|
return nil, indexer.ErrUnconfigured
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches a result's .torrent from the source that offered it: a
|
||||||
|
// Prowlarr link carries Prowlarr's key and only Prowlarr can redeem it.
|
||||||
|
func (f *Fanout) Download(ctx context.Context, r indexer.Result) ([]byte, error) {
|
||||||
|
if f == nil {
|
||||||
|
return nil, indexer.ErrUnconfigured
|
||||||
|
}
|
||||||
|
for _, src := range f.Sources {
|
||||||
|
if src.Code != r.Source {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if src.Fetch == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s cannot fetch torrent files", indexer.ErrUnconfigured, src.Label)
|
||||||
|
}
|
||||||
|
return src.Fetch.Download(ctx, r)
|
||||||
|
}
|
||||||
|
if r.Source == "" {
|
||||||
|
return nil, fmt.Errorf("%w: this result does not say which source it came from", indexer.ErrUnconfigured)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %q is no longer a configured source", indexer.ErrUnconfigured, r.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrowing is one source that did not answer, as a page shows it.
|
||||||
|
type Narrowing struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// narrowings reads a PartialError out of err, and whether any source
|
||||||
|
// answered. Nil when err is not a partial failure.
|
||||||
|
func narrowings(err error) ([]Narrowing, bool) {
|
||||||
|
var partial *PartialError
|
||||||
|
if !errors.As(err, &partial) || len(partial.Failures) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]Narrowing, 0, len(partial.Failures))
|
||||||
|
for _, f := range partial.Failures {
|
||||||
|
out = append(out, Narrowing{Source: f.Code, Label: f.Label, Reason: indexer.FirstLine(f.Err.Error())})
|
||||||
|
}
|
||||||
|
return out, partial.Answered > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Taking a release (M502 #5521), ported from Fabled Librarian's grab.go and
|
||||||
|
// rivals.go. One live grab per want: a second is tie-broken against the
|
||||||
|
// download already running (FL #4470).
|
||||||
|
|
||||||
|
// Tag goes on every torrent Minstrel adds, so the operator can tell them
|
||||||
|
// from their own in the client.
|
||||||
|
const Tag = "minstrel"
|
||||||
|
|
||||||
|
// MissingClient is Taken.Missing when no download client is set up.
|
||||||
|
const MissingClient = "download_client"
|
||||||
|
|
||||||
|
// nearlyDone is how far along a running download stays whatever would
|
||||||
|
// replace it.
|
||||||
|
const nearlyDone = 0.9
|
||||||
|
|
||||||
|
// Downloader is the grab half of a Service.
|
||||||
|
type Downloader struct {
|
||||||
|
Client downloader.Driver
|
||||||
|
// Code is the client's acquisition_services row.
|
||||||
|
Code string
|
||||||
|
// Landing is the client's downloads folder as Minstrel sees it; each
|
||||||
|
// grab lands in a folder of its own under it.
|
||||||
|
Landing string
|
||||||
|
// Fetch gets a result's .torrent from the source that offered it.
|
||||||
|
Fetch Fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin says who took a release.
|
||||||
|
type Origin struct {
|
||||||
|
By string // OriginSchedule or OriginOperator
|
||||||
|
User pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest is how a grab fared against the download already running.
|
||||||
|
type Contest struct {
|
||||||
|
// Kept: the running one stays and this grab was not made. Replaced:
|
||||||
|
// this grab took its place.
|
||||||
|
Kept, Replaced bool
|
||||||
|
Reason string
|
||||||
|
Rival dbq.Grab
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken is what came of a grab.
|
||||||
|
type Taken struct {
|
||||||
|
Chosen *Candidate
|
||||||
|
// Added is the only field that means a download started.
|
||||||
|
Added bool
|
||||||
|
SavePath string
|
||||||
|
Grab dbq.Grab
|
||||||
|
// Gone: the search no longer offers the release.
|
||||||
|
Gone bool
|
||||||
|
// Refused: not taken; the reason is on Chosen.
|
||||||
|
Refused bool
|
||||||
|
// Unconfigured: Missing names what is not set up ("" for search).
|
||||||
|
Unconfigured bool
|
||||||
|
Missing string
|
||||||
|
Contest *Contest
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take searches again and grabs the candidate named by key: a person's
|
||||||
|
// grab. Searching again keeps the indexer's download link (which can carry
|
||||||
|
// its key) on the server, and finds a release gone since it was shown.
|
||||||
|
// force takes what the verdict would not.
|
||||||
|
func (s *Service) Take(ctx context.Context, want Want, key string, force bool, origin Origin) Taken {
|
||||||
|
if s.Grabber == nil {
|
||||||
|
return Taken{Unconfigured: true, Missing: MissingClient}
|
||||||
|
}
|
||||||
|
found := s.For(ctx, want)
|
||||||
|
switch {
|
||||||
|
case found.Unconfigured:
|
||||||
|
return Taken{Unconfigured: true}
|
||||||
|
case found.Error != "":
|
||||||
|
return Taken{Error: found.Error}
|
||||||
|
}
|
||||||
|
chosen := pick(found.Candidates, key)
|
||||||
|
if chosen == nil {
|
||||||
|
return Taken{Gone: true}
|
||||||
|
}
|
||||||
|
return s.Grab(ctx, want, chosen, found.Candidates, force, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab hands a judged candidate to the download client. The schedule calls
|
||||||
|
// it with the candidate it has just judged; Take calls it after searching
|
||||||
|
// again.
|
||||||
|
func (s *Service) Grab(ctx context.Context, want Want, chosen *Candidate, all []Candidate, force bool, origin Origin) Taken {
|
||||||
|
out := Taken{Chosen: chosen}
|
||||||
|
if s.Grabber == nil || s.Grabber.Client == nil {
|
||||||
|
out.Unconfigured, out.Missing = true, MissingClient
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if chosen.Verdict == Blocked || (chosen.Verdict != Grab && !force) {
|
||||||
|
out.Refused = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock := lockWant(want.ID)
|
||||||
|
defer unlock()
|
||||||
|
contest := s.contest(ctx, want, chosen, all, origin)
|
||||||
|
if contest != nil && contest.Kept {
|
||||||
|
out.Contest = contest
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
add := downloader.Add{Magnet: chosen.Source.Magnet, Tags: []string{Tag}}
|
||||||
|
hash := chosen.InfoHash
|
||||||
|
if add.Magnet == "" {
|
||||||
|
if s.Grabber.Fetch == nil {
|
||||||
|
out.Error = "This release is a .torrent link, and nothing is set up to fetch it."
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
body, err := s.Grabber.Fetch.Download(ctx, chosen.Source)
|
||||||
|
if magnet, ok := indexer.AsMagnet(err); ok {
|
||||||
|
add.Magnet, err = magnet, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
out.Error = indexer.FirstLine(err.Error())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if add.Magnet == "" {
|
||||||
|
add.Torrent = body
|
||||||
|
if meta, err := release.ParseTorrent(body); err == nil {
|
||||||
|
if hash == "" {
|
||||||
|
hash = meta.InfoHash
|
||||||
|
}
|
||||||
|
if refused := s.judgeFiles(ctx, want, chosen, hash, meta.Files, force); refused {
|
||||||
|
out.Refused = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hash == "" && add.Magnet != "" {
|
||||||
|
hash = indexer.MagnetHash(add.Magnet)
|
||||||
|
}
|
||||||
|
add.SavePath = landingFor(s.Grabber.Landing, hash, chosen.Name)
|
||||||
|
|
||||||
|
err := s.Grabber.Client.AddTorrent(ctx, add)
|
||||||
|
if errors.Is(err, downloader.ErrDuplicate) && s.heldElsewhere(ctx, hash) {
|
||||||
|
why := "Already in the download client outside Minstrel's category, where Minstrel cannot follow it (" +
|
||||||
|
strings.TrimPrefix(indexer.FirstLine(err.Error()), downloader.ErrDuplicate.Error()+": ") + ")"
|
||||||
|
if force {
|
||||||
|
out.Error = why + "."
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
set(chosen, Blocked, Hard, CodeBlocked, why+".")
|
||||||
|
if s.Memory != nil && hash != "" {
|
||||||
|
_ = s.Memory.Block(ctx, dbq.BlockReleaseParams{
|
||||||
|
InfoHash: hash, Name: chosen.Name, Reason: BlockGone, Detail: why + ".", WantID: want.ID,
|
||||||
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(GoneBlock), Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.Refused = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, downloader.ErrDuplicate) {
|
||||||
|
if errors.Is(err, downloader.ErrUnconfigured) {
|
||||||
|
out.Unconfigured, out.Missing = true, MissingClient
|
||||||
|
} else {
|
||||||
|
out.Error = indexer.FirstLine(err.Error())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Added, out.SavePath = true, add.SavePath
|
||||||
|
if s.Memory == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var replaced pgtype.UUID
|
||||||
|
why := ""
|
||||||
|
if contest != nil && contest.Replaced {
|
||||||
|
out.Contest = contest
|
||||||
|
replaced, why = contest.Rival.ID, "Replaced by "+chosen.Name+": "+contest.Reason+"."
|
||||||
|
}
|
||||||
|
g, err := s.Memory.Grabbed(ctx, dbq.InsertGrabParams{
|
||||||
|
WantID: want.ID, InfoHash: hash, Name: chosen.Name, Key: Key(*chosen), Indexer: chosen.Indexer,
|
||||||
|
Source: chosen.Source.Source, Client: s.Grabber.Code, SavePath: add.SavePath,
|
||||||
|
Quality: string(chosen.Tier), Origin: origin.By, Forced: force, GrabbedBy: origin.User,
|
||||||
|
}, replaced, why)
|
||||||
|
if err != nil {
|
||||||
|
out.Error = "The download started, but could not be recorded: " + indexer.FirstLine(err.Error())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Grab = g
|
||||||
|
// Removed only after the new download was added and recorded, so a
|
||||||
|
// failure never leaves the want with nothing running.
|
||||||
|
if replaced.Valid && contest.Rival.InfoHash != "" {
|
||||||
|
if err := s.Grabber.Client.Delete(ctx, true, contest.Rival.InfoHash); err != nil {
|
||||||
|
contest.Reason += ". The download it replaced could not be removed from the client: " + indexer.FirstLine(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldElsewhere reports whether a release the client says it already holds is
|
||||||
|
// one it does not list for Minstrel: held under another category, from before
|
||||||
|
// Minstrel took the downloads over (#6176). Recorded as a grab, it would read
|
||||||
|
// as gone half an hour later and be taken again, every pass. A client that
|
||||||
|
// cannot answer now is given the benefit of the doubt, as the add was.
|
||||||
|
func (s *Service) heldElsewhere(ctx context.Context, hash string) bool {
|
||||||
|
if hash == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
listed, err := s.Grabber.Client.Torrents(ctx, indexer.NormalizeHash(hash))
|
||||||
|
return err == nil && len(listed) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeFiles judges a chosen release again on its .torrent's file list, and
|
||||||
|
// sets it aside when the files say it is not the album, so no later search
|
||||||
|
// offers it again. force still takes it.
|
||||||
|
func (s *Service) judgeFiles(ctx context.Context, want Want, chosen *Candidate, hash string, files []release.File, force bool) bool {
|
||||||
|
chosen.Files = files
|
||||||
|
if chosen.FilesCount == 0 {
|
||||||
|
chosen.FilesCount = len(files)
|
||||||
|
}
|
||||||
|
veto, reason, _ := release.JudgeFiles(want.Want, files)
|
||||||
|
if veto == "" || force {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set(chosen, Rejected, Hard, veto, reason)
|
||||||
|
if s.Memory != nil && hash != "" {
|
||||||
|
_ = s.Memory.Block(ctx, dbq.BlockReleaseParams{
|
||||||
|
InfoHash: hash, Name: chosen.Name, Reason: BlockFiles, Detail: reason, WantID: want.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var wantLocks sync.Map // want id → *sync.Mutex
|
||||||
|
|
||||||
|
// lockWant settles two grabs for one want one after the other. The
|
||||||
|
// one-live-grab index is the backstop for anything outside this process.
|
||||||
|
func lockWant(id pgtype.UUID) func() {
|
||||||
|
if !id.Valid {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
m, _ := wantLocks.LoadOrStore(id.Bytes, &sync.Mutex{})
|
||||||
|
mu := m.(*sync.Mutex)
|
||||||
|
mu.Lock()
|
||||||
|
return mu.Unlock
|
||||||
|
}
|
||||||
|
|
||||||
|
// contest settles a grab against the download running for its want: a
|
||||||
|
// person's grab beats the schedule's; between two of a kind the better
|
||||||
|
// candidate wins; a tie, a download nearly done, or one that cannot be
|
||||||
|
// removed stays. Nil when nothing is running.
|
||||||
|
func (s *Service) contest(ctx context.Context, want Want, chosen *Candidate, all []Candidate, origin Origin) *Contest {
|
||||||
|
if s.Memory == nil || !want.ID.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
running, ok, err := s.Memory.LiveGrab(ctx, want.ID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := &Contest{Rival: running}
|
||||||
|
if running.InfoHash != "" && running.InfoHash == chosen.InfoHash {
|
||||||
|
c.Kept, c.Reason = true, "That release is already downloading for this album."
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case origin.By == OriginOperator && running.Origin == OriginSchedule:
|
||||||
|
c.Reason = "you chose it over the one the schedule started"
|
||||||
|
case origin.By == OriginSchedule && running.Origin == OriginOperator:
|
||||||
|
c.Kept, c.Reason = true, "A download you started for this album is already running."
|
||||||
|
return c
|
||||||
|
default:
|
||||||
|
var theirs *Candidate
|
||||||
|
for i := range all {
|
||||||
|
if (running.InfoHash != "" && all[i].InfoHash == running.InfoHash) || Key(all[i]) == running.Key {
|
||||||
|
theirs = &all[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if theirs == nil || !better(*chosen, *theirs) {
|
||||||
|
c.Kept, c.Reason = true, "A download for this album is already running, and this release is no better."
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c.Reason = "it ranks above the one that was running"
|
||||||
|
}
|
||||||
|
if running.InfoHash == "" {
|
||||||
|
c.Kept, c.Reason = true, "A download for this album is already running, and it cannot be identified to replace it."
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if ts, err := s.Grabber.Client.Torrents(ctx, running.InfoHash); err == nil && len(ts) > 0 && ts[0].Progress >= nearlyDone {
|
||||||
|
c.Kept, c.Reason = true, "A download for this album is already running and nearly done."
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c.Replaced = true
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// landingFor is the folder a download lands in: one per torrent, named by
|
||||||
|
// its infohash, so two grabs never unpack into the same place.
|
||||||
|
func landingFor(root, hash, name string) string {
|
||||||
|
dir := hash
|
||||||
|
if dir == "" {
|
||||||
|
dir = slug(name)
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
dir = "unnamed"
|
||||||
|
}
|
||||||
|
return path.Join(root, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug keeps a folder name to characters every filesystem in the chain
|
||||||
|
// takes.
|
||||||
|
func slug(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
if b.Len() >= 80 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-', r == '_', r == ' ', r == '.':
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-")
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"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/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block reasons, matching release_blocks_reason_check.
|
||||||
|
const (
|
||||||
|
BlockOperator = "operator"
|
||||||
|
BlockFiles = "files"
|
||||||
|
// BlockImport: the import found its files were not the album (#5522).
|
||||||
|
BlockImport = "import"
|
||||||
|
// BlockStalled: given up on because it would not finish (#5523); held
|
||||||
|
// back until the block's expires_at.
|
||||||
|
BlockStalled = "stalled"
|
||||||
|
// BlockGone: its download client would not let Minstrel follow it, held
|
||||||
|
// outside Minstrel's category or leaving the client twice (#6176); held
|
||||||
|
// back for GoneBlock.
|
||||||
|
BlockGone = "gone"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoneBlock is how long a release its client lost track of is held back:
|
||||||
|
// long enough that a release which keeps vanishing is tried once a day rather
|
||||||
|
// than on every pass, short enough that it is taken once the client lets go.
|
||||||
|
const GoneBlock = 24 * time.Hour
|
||||||
|
|
||||||
|
// Grab origins, matching grabs_origin_check.
|
||||||
|
const (
|
||||||
|
OriginSchedule = "schedule"
|
||||||
|
OriginOperator = "operator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Memory is what searches and grabs leave behind: every candidate of a
|
||||||
|
// want's last search, the grabs, and the releases set aside.
|
||||||
|
type Memory struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemory returns a Memory over pool.
|
||||||
|
func NewMemory(pool *pgxpool.Pool) *Memory { return &Memory{pool: pool} }
|
||||||
|
|
||||||
|
// Keep replaces a want's candidates with this search's, in order.
|
||||||
|
func (m *Memory) Keep(ctx context.Context, wantID pgtype.UUID, cands []Candidate) error {
|
||||||
|
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if err := q.DeleteWantCandidates(ctx, wantID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, c := range cands {
|
||||||
|
reasons, err := json.Marshal(c.Reasons)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Reasons == nil {
|
||||||
|
reasons = []byte("[]")
|
||||||
|
}
|
||||||
|
folded := c.FoldedFrom
|
||||||
|
if folded == nil {
|
||||||
|
folded = []string{}
|
||||||
|
}
|
||||||
|
if err := q.InsertWantCandidate(ctx, dbq.InsertWantCandidateParams{
|
||||||
|
WantID: wantID, Key: Key(c), Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer,
|
||||||
|
Source: c.Source.Source, SizeBytes: c.SizeBytes, Seeders: int32(c.Seeders),
|
||||||
|
FilesCount: int32(c.FilesCount), Format: release.Describe(c.Release), Quality: string(c.Tier),
|
||||||
|
Year: int32(c.Year), Verdict: string(c.Verdict), Reason: c.Reason, Reasons: reasons,
|
||||||
|
FoldedFrom: folded, Rank: int32(i),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// blocked is every release set aside, by infohash, with the sentence a
|
||||||
|
// candidate shows. A failed read blocks nothing: offering a release set
|
||||||
|
// aside costs one grab a person can undo, while blocking everything on a
|
||||||
|
// database blip would hide every result.
|
||||||
|
func (m *Memory) blocked(ctx context.Context) map[string]string {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(m.pool).ListReleaseBlocks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[indexer.NormalizeHash(r.InfoHash)] = BlockSentence(r.Reason, r.Detail)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockSentence is what a blocked candidate says.
|
||||||
|
func BlockSentence(reason, detail string) string {
|
||||||
|
switch reason {
|
||||||
|
case BlockFiles:
|
||||||
|
if detail != "" {
|
||||||
|
return "Set aside for what its files hold: " + detail
|
||||||
|
}
|
||||||
|
return "Set aside for what its files hold."
|
||||||
|
case BlockImport:
|
||||||
|
if detail != "" {
|
||||||
|
return "Refused on import: " + detail
|
||||||
|
}
|
||||||
|
return "Refused on import: its files were not the album."
|
||||||
|
case BlockGone:
|
||||||
|
if detail != "" {
|
||||||
|
return "Held back for now: " + detail
|
||||||
|
}
|
||||||
|
return "Held back for now: its download client lost track of it."
|
||||||
|
case BlockStalled:
|
||||||
|
if detail != "" {
|
||||||
|
return "Given up on for now: " + detail
|
||||||
|
}
|
||||||
|
return "Given up on for now: it would not finish."
|
||||||
|
}
|
||||||
|
return "You set this release aside."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block sets a release aside and marks every kept candidate of it blocked.
|
||||||
|
func (m *Memory) Block(ctx context.Context, p dbq.BlockReleaseParams) error {
|
||||||
|
p.InfoHash = indexer.NormalizeHash(p.InfoHash)
|
||||||
|
if p.InfoHash == "" {
|
||||||
|
return errors.New("a release without an infohash cannot be set aside")
|
||||||
|
}
|
||||||
|
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if err := q.BlockRelease(ctx, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return q.MarkCandidatesBlocked(ctx, dbq.MarkCandidatesBlockedParams{
|
||||||
|
InfoHash: p.InfoHash, Reason: BlockSentence(p.Reason, p.Detail),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock lets a release be taken again. Its kept candidates wait for a
|
||||||
|
// person until the next search judges them afresh.
|
||||||
|
func (m *Memory) Unblock(ctx context.Context, hash string) (bool, error) {
|
||||||
|
hash = indexer.NormalizeHash(hash)
|
||||||
|
var n int64
|
||||||
|
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
var err error
|
||||||
|
if n, err = q.UnblockRelease(ctx, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return q.MarkCandidatesUnblocked(ctx, dbq.MarkCandidatesUnblockedParams{
|
||||||
|
InfoHash: hash, Reason: "Allowed again. The next search judges it afresh.",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveGrab is the download running for a want, if there is one.
|
||||||
|
func (m *Memory) LiveGrab(ctx context.Context, wantID pgtype.UUID) (dbq.Grab, bool, error) {
|
||||||
|
g, err := dbq.New(m.pool).GetLiveGrab(ctx, wantID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return g, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return g, false, err
|
||||||
|
}
|
||||||
|
return g, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabbed records a download started, ending the grab it replaced first so
|
||||||
|
// the one-live-grab index holds.
|
||||||
|
func (m *Memory) Grabbed(ctx context.Context, p dbq.InsertGrabParams, replaced pgtype.UUID, why string) (dbq.Grab, error) {
|
||||||
|
var g dbq.Grab
|
||||||
|
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if replaced.Valid {
|
||||||
|
if err := q.EndGrab(ctx, dbq.EndGrabParams{ID: replaced, EndReason: "superseded", EndDetail: why}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
g, err = q.InsertGrab(ctx, p)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return g, fmt.Errorf("record grab: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchPace is the one Pacer every search shares, ported from Fabled
|
||||||
|
// Librarian (#4448). Process-wide because a Fanout is built fresh for each
|
||||||
|
// search; a Pacer per Fanout would start empty and pace nothing.
|
||||||
|
var SearchPace = &Pacer{}
|
||||||
|
|
||||||
|
// holdWithoutRetryAfter is how long a source that answered 429 without saying
|
||||||
|
// for how long is left alone: one schedule tick.
|
||||||
|
const holdWithoutRetryAfter = 10 * time.Minute
|
||||||
|
|
||||||
|
// Pacer spaces the searches sent to each source by its searches_per_hour: a
|
||||||
|
// spacing, not a burst bucket, because Prowlarr answers 429 to a few requests
|
||||||
|
// in quick succession (FL #4304). Every search spends, the schedule's and a
|
||||||
|
// person's alike; only the schedule waits.
|
||||||
|
type Pacer struct {
|
||||||
|
// Now and Sleep are the clock, replaceable in tests.
|
||||||
|
Now func() time.Time
|
||||||
|
Sleep func(context.Context, time.Duration) error
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
next map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) now() time.Time {
|
||||||
|
if p.Now != nil {
|
||||||
|
return p.Now()
|
||||||
|
}
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) sleep(ctx context.Context, d time.Duration) error {
|
||||||
|
if p.Sleep != nil {
|
||||||
|
return p.Sleep(ctx, d)
|
||||||
|
}
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gap is the spacing a source's number asks for; zero is unpaced.
|
||||||
|
func gap(perHour int) time.Duration {
|
||||||
|
if perHour <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Hour / time.Duration(perHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyAt is when every source may next be asked.
|
||||||
|
func (p *Pacer) readyAt(sources []Source) time.Time {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
var at time.Time
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.Search == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := p.next[s.Code]; n.After(at) {
|
||||||
|
at = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait holds until every source may be asked, and answers false, without
|
||||||
|
// waiting, when that is after the deadline or ctx ends first. All of them,
|
||||||
|
// not any: a search while one is held back would be narrowed, and the want
|
||||||
|
// recorded as looked for.
|
||||||
|
func (p *Pacer) Wait(ctx context.Context, sources []Source, deadline time.Time) bool {
|
||||||
|
if p == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
at := p.readyAt(sources)
|
||||||
|
if !deadline.IsZero() && at.After(deadline) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d := at.Sub(p.now()); d > 0 {
|
||||||
|
return p.sleep(ctx, d) == nil
|
||||||
|
}
|
||||||
|
return ctx.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spend takes one search's slot from a source.
|
||||||
|
func (p *Pacer) spend(s Source) {
|
||||||
|
g := gap(s.PerHour)
|
||||||
|
if p == nil || g == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.next == nil {
|
||||||
|
p.next = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
at := p.next[s.Code]
|
||||||
|
if t := p.now(); at.Before(t) {
|
||||||
|
at = t
|
||||||
|
}
|
||||||
|
p.next[s.Code] = at.Add(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hold leaves a source alone after it answered 429, for as long as it asked,
|
||||||
|
// whatever its searches_per_hour says.
|
||||||
|
func (p *Pacer) hold(code string, rl *indexer.RateLimited) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := rl.RetryAfter
|
||||||
|
if d <= 0 {
|
||||||
|
d = holdWithoutRetryAfter
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.next == nil {
|
||||||
|
p.next = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
if until := p.now().Add(d); until.After(p.next[code]) {
|
||||||
|
p.next[code] = until
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready holds until every source this service searches may be asked again,
|
||||||
|
// and answers false when that is past the deadline. The schedule calls it
|
||||||
|
// before each want; a person's search never does.
|
||||||
|
func (s *Service) Ready(ctx context.Context, deadline time.Time) bool {
|
||||||
|
if s == nil {
|
||||||
|
return ctx.Err() == nil
|
||||||
|
}
|
||||||
|
if f, ok := s.Search.(*Fanout); ok && f != nil {
|
||||||
|
return f.Pace.Wait(ctx, f.Sources, deadline)
|
||||||
|
}
|
||||||
|
return ctx.Err() == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One search for one want, and what could be done about each result (M502
|
||||||
|
// #5521), ported from Fabled Librarian's internal/acquire. internal/release
|
||||||
|
// says whether a name is the album and what quality it is; this package
|
||||||
|
// asks in the right order and says why in words a person can act on.
|
||||||
|
|
||||||
|
// Verdict is what can be done about one result.
|
||||||
|
type Verdict string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Grab: the album, in a quality the profile takes.
|
||||||
|
Grab Verdict = "grab"
|
||||||
|
// Review: maybe the album; a person decides.
|
||||||
|
Review Verdict = "review"
|
||||||
|
// Refused: the album, in a quality the profile does not take. The only
|
||||||
|
// verdict that names a setting.
|
||||||
|
Refused Verdict = "refused"
|
||||||
|
// Rejected: not the album.
|
||||||
|
Rejected Verdict = "rejected"
|
||||||
|
// Blocked: a release set aside, by a person or by its files.
|
||||||
|
Blocked Verdict = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// order sorts verdicts: what can be taken first, what was given up on last.
|
||||||
|
var order = map[Verdict]int{Grab: 0, Review: 1, Refused: 2, Rejected: 3, Blocked: 4}
|
||||||
|
|
||||||
|
// Reason kinds: a hard reason is a fact about the bytes, a soft one about the
|
||||||
|
// moment or a setting.
|
||||||
|
const (
|
||||||
|
Hard = "hard"
|
||||||
|
Soft = "soft"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reason codes beyond internal/release's vetoes.
|
||||||
|
const (
|
||||||
|
CodeBlocked = "blocked"
|
||||||
|
CodeProfile = "profile"
|
||||||
|
CodeNoSeeders = "no_seeders"
|
||||||
|
CodeDoubt = "doubt"
|
||||||
|
// CodeNotBetter: the album, but no better than the copy the library
|
||||||
|
// holds, for a want that is an upgrade (#5524).
|
||||||
|
CodeNotBetter = "not_better"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reason is one thing a verdict rests on.
|
||||||
|
type Reason struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Want is the album searched for: its wants row and how the catalogue
|
||||||
|
// describes it.
|
||||||
|
type Want struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
release.Want
|
||||||
|
// Floor is the tier of the copy the library holds, for an album wanted
|
||||||
|
// for a better one (#5524): a release must rank above it. "" for an
|
||||||
|
// album not held, and for a video rip, which any clean copy beats.
|
||||||
|
Floor release.Tier
|
||||||
|
// Held says the floor in words: "MP3, about 192 kbps".
|
||||||
|
Held string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate is one result with everything decided about it.
|
||||||
|
type Candidate struct {
|
||||||
|
release.Release
|
||||||
|
// Source is the indexer's own result. It never leaves the server: its
|
||||||
|
// DownloadURL can carry the indexer's key.
|
||||||
|
Source indexer.Result
|
||||||
|
InfoHash string
|
||||||
|
Indexer string
|
||||||
|
SizeBytes int64
|
||||||
|
Seeders int
|
||||||
|
SwarmKnown bool
|
||||||
|
Category int
|
||||||
|
FilesCount int
|
||||||
|
// Files is the torrent's file list once its .torrent has been read.
|
||||||
|
Files []release.File
|
||||||
|
FoldedFrom []string
|
||||||
|
|
||||||
|
Tier release.Tier
|
||||||
|
YearAgrees bool
|
||||||
|
Verdict Verdict
|
||||||
|
Reason string
|
||||||
|
Reasons []Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is how a candidate is named across a search and a grab: its infohash,
|
||||||
|
// or its name when the source gave none.
|
||||||
|
func Key(c Candidate) string {
|
||||||
|
if c.InfoHash != "" {
|
||||||
|
return c.InfoHash
|
||||||
|
}
|
||||||
|
return c.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is one search.
|
||||||
|
type Result struct {
|
||||||
|
Query string
|
||||||
|
// Unconfigured: no search source is set up.
|
||||||
|
Unconfigured bool
|
||||||
|
// Error is a search that failed outright.
|
||||||
|
Error string
|
||||||
|
// Narrowed names sources that did not answer on a search that otherwise
|
||||||
|
// did.
|
||||||
|
Narrowed []Narrowing
|
||||||
|
Considered int
|
||||||
|
Folded int
|
||||||
|
// Looked is how many .torrent files were read for their file lists.
|
||||||
|
Looked int
|
||||||
|
Candidates []Candidate
|
||||||
|
// KeepError: the candidates could not be stored.
|
||||||
|
KeepError string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabbable is what may be taken, best first.
|
||||||
|
func (r Result) Grabbable() []Candidate {
|
||||||
|
var out []Candidate
|
||||||
|
for _, c := range r.Candidates {
|
||||||
|
if c.Verdict == Grab {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForAPerson says whether anything found is worth a person's look: a
|
||||||
|
// release that may be the album, whatever its quality.
|
||||||
|
func (r Result) ForAPerson() bool {
|
||||||
|
for _, c := range r.Candidates {
|
||||||
|
if c.Verdict == Grab || c.Verdict == Review || c.Verdict == Refused {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service searches and grabs.
|
||||||
|
type Service struct {
|
||||||
|
// Search is nil when no source is configured.
|
||||||
|
Search Searcher
|
||||||
|
// Grabber is nil when no download client is configured.
|
||||||
|
Grabber *Downloader
|
||||||
|
Profile release.Profile
|
||||||
|
// Memory stores candidates, grabs and blocks. Nil keeps nothing.
|
||||||
|
Memory *Memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query is the text sent to the indexers: the artist and the title, without
|
||||||
|
// a trailing bracketed part the release names usually leave off.
|
||||||
|
func Query(w release.Want) string {
|
||||||
|
title := w.Title
|
||||||
|
if i := strings.IndexAny(title, "(["); i > 0 {
|
||||||
|
title = title[:i]
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(strings.TrimSpace(w.Artist)+" "+strings.TrimSpace(title)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// For searches for one want and judges everything that comes back. It never
|
||||||
|
// returns an error: a search that could not be made is a Result saying so.
|
||||||
|
func (s *Service) For(ctx context.Context, want Want) Result {
|
||||||
|
r := Result{Query: Query(want.Want)}
|
||||||
|
if s == nil || s.Search == nil {
|
||||||
|
r.Unconfigured = true
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
found, err := s.Search.Search(ctx, indexer.Query{Text: r.Query, Categories: indexer.MusicCategories})
|
||||||
|
// A partial search arrives as results and an error together.
|
||||||
|
if narrowed, answered := narrowings(err); narrowed != nil && answered {
|
||||||
|
r.Narrowed, err = narrowed, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, indexer.ErrUnconfigured):
|
||||||
|
r.Unconfigured = true
|
||||||
|
return r
|
||||||
|
case err != nil:
|
||||||
|
r.Error = indexer.FirstLine(err.Error())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r.Considered = len(found)
|
||||||
|
cands, folded := fold(found)
|
||||||
|
r.Folded = folded
|
||||||
|
|
||||||
|
blocked := s.Memory.blocked(ctx)
|
||||||
|
for i := range cands {
|
||||||
|
decide(&cands[i], want, s.Profile, blocked)
|
||||||
|
}
|
||||||
|
sortCandidates(cands)
|
||||||
|
for _, i := range s.peek(ctx, cands) {
|
||||||
|
decide(&cands[i], want, s.Profile, blocked)
|
||||||
|
r.Looked++
|
||||||
|
}
|
||||||
|
sortCandidates(cands)
|
||||||
|
r.Candidates = cands
|
||||||
|
if s.Memory != nil && want.ID.Valid {
|
||||||
|
if err := s.Memory.Keep(ctx, want.ID, cands); err != nil {
|
||||||
|
r.KeepError = indexer.FirstLine(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide works out one candidate's verdict, in order: given up on, then
|
||||||
|
// whether it is the album, then whether the profile takes its quality, then
|
||||||
|
// whether anything leaves it to a person. A quality refusal said about the
|
||||||
|
// wrong album would be noise, so identity comes first.
|
||||||
|
func decide(c *Candidate, want Want, profile release.Profile, blocked map[string]string) {
|
||||||
|
w := want.Want
|
||||||
|
c.Verdict, c.Reason, c.Reasons = "", "", nil
|
||||||
|
if why, ok := blocked[c.InfoHash]; ok && c.InfoHash != "" {
|
||||||
|
set(c, Blocked, Hard, CodeBlocked, why)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := release.Judge(w, c.Release)
|
||||||
|
c.YearAgrees = m.YearAgrees
|
||||||
|
if m.Veto != "" {
|
||||||
|
set(c, Rejected, Hard, m.Veto, m.Reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doubt := m.Doubt
|
||||||
|
if len(c.Files) > 0 {
|
||||||
|
if in := release.Inspect(c.Files); in.Format != "" {
|
||||||
|
c.Format = in.Format
|
||||||
|
}
|
||||||
|
veto, reason, fileDoubt := release.JudgeFiles(w, c.Files)
|
||||||
|
if veto != "" {
|
||||||
|
set(c, Rejected, Hard, veto, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doubt == "" {
|
||||||
|
doubt = fileDoubt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Tier = release.TierOf(c.Release, c.Category)
|
||||||
|
if !profile.Allows(c.Tier) {
|
||||||
|
set(c, Refused, Soft, CodeProfile, "Your quality settings don’t take "+strings.ToLower(release.Label(c.Tier))+".")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if want.Floor != "" && release.Rank(c.Tier) <= release.Rank(want.Floor) {
|
||||||
|
held := want.Held
|
||||||
|
if held == "" {
|
||||||
|
held = strings.ToLower(release.Label(want.Floor))
|
||||||
|
}
|
||||||
|
set(c, Refused, Soft, CodeNotBetter, "No better than the copy you have ("+held+").")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doubt != "" {
|
||||||
|
set(c, Review, Soft, CodeDoubt, doubt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Seeders == 0 {
|
||||||
|
set(c, Review, Soft, CodeNoSeeders, "Nobody is sharing it right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Verdict = Grab
|
||||||
|
c.Reason = "The album, " + strings.ToLower(release.Label(c.Tier)) + "."
|
||||||
|
if d := release.Describe(c.Release); d != "" {
|
||||||
|
c.Reason = "The album, " + d + "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(c *Candidate, v Verdict, kind, code, text string) {
|
||||||
|
c.Verdict, c.Reason = v, text
|
||||||
|
c.Reasons = append(c.Reasons, Reason{Kind: kind, Code: code, Text: text})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCandidates puts the best first, stably so the same search proposes the
|
||||||
|
// same grab twice.
|
||||||
|
func sortCandidates(cands []Candidate) {
|
||||||
|
sort.SliceStable(cands, func(a, b int) bool { return better(cands[a], cands[b]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// better says whether x ranks above y: verdict, then quality, then how many
|
||||||
|
// share it, then whether its year agrees.
|
||||||
|
func better(x, y Candidate) bool {
|
||||||
|
if order[x.Verdict] != order[y.Verdict] {
|
||||||
|
return order[x.Verdict] < order[y.Verdict]
|
||||||
|
}
|
||||||
|
if rx, ry := release.Rank(x.Tier), release.Rank(y.Tier); rx != ry {
|
||||||
|
return rx > ry
|
||||||
|
}
|
||||||
|
if x.Seeders != y.Seeders {
|
||||||
|
return x.Seeders > y.Seeders
|
||||||
|
}
|
||||||
|
return x.YearAgrees && !y.YearAgrees
|
||||||
|
}
|
||||||
|
|
||||||
|
// fold merges results that are the same torrent, by infohash. Prowlarr
|
||||||
|
// aggregates, so one release arrives once per tracker that lists it. A
|
||||||
|
// result without a hash is never folded: an absent hash is not a shared one.
|
||||||
|
func fold(found []indexer.Result) ([]Candidate, int) {
|
||||||
|
out := make([]Candidate, 0, len(found))
|
||||||
|
at := make(map[string]int, len(found))
|
||||||
|
folded := 0
|
||||||
|
for _, f := range found {
|
||||||
|
if strings.TrimSpace(f.Title) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash := indexer.NormalizeHash(f.InfoHash)
|
||||||
|
if hash == "" && f.Magnet != "" {
|
||||||
|
hash = indexer.MagnetHash(f.Magnet)
|
||||||
|
}
|
||||||
|
if hash != "" {
|
||||||
|
if i, seen := at[hash]; seen {
|
||||||
|
folded++
|
||||||
|
if f.Indexer != "" {
|
||||||
|
out[i].FoldedFrom = append(out[i].FoldedFrom, f.Indexer)
|
||||||
|
}
|
||||||
|
// A measured swarm beats a reported one; among like, the
|
||||||
|
// highest count.
|
||||||
|
switch {
|
||||||
|
case f.SwarmKnown && !out[i].SwarmKnown:
|
||||||
|
out[i].Seeders, out[i].SwarmKnown = f.Seeders, true
|
||||||
|
case f.SwarmKnown == out[i].SwarmKnown && f.Seeders > out[i].Seeders:
|
||||||
|
out[i].Seeders = f.Seeders
|
||||||
|
}
|
||||||
|
if f.FilesCount > 0 && out[i].FilesCount == 0 {
|
||||||
|
out[i].FilesCount = f.FilesCount
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at[hash] = len(out)
|
||||||
|
}
|
||||||
|
out = append(out, Candidate{
|
||||||
|
Release: release.Parse(f.Title), Source: f, InfoHash: hash, Indexer: f.Indexer,
|
||||||
|
SizeBytes: f.SizeBytes, Seeders: f.Seeders, SwarmKnown: f.SwarmKnown,
|
||||||
|
Category: f.Category, FilesCount: f.FilesCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, folded
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPeeks is how many .torrent files one search reads. Few: each goes
|
||||||
|
// through Prowlarr to a tracker, and Prowlarr answers 429 to a burst.
|
||||||
|
const maxPeeks = 3
|
||||||
|
|
||||||
|
// peek reads the .torrent of candidates that are the album but whose name
|
||||||
|
// states no format, so their files can say what they are; it returns which
|
||||||
|
// now carry a file list. A failed fetch leaves the candidate as it was.
|
||||||
|
func (s *Service) peek(ctx context.Context, cands []Candidate) []int {
|
||||||
|
f, ok := s.Search.(Fetcher)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var looked []int
|
||||||
|
for i := range cands {
|
||||||
|
if len(looked) >= maxPeeks || ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c := &cands[i]
|
||||||
|
if c.Tier != release.TierUnknown || (c.Verdict != Grab && c.Verdict != Refused) ||
|
||||||
|
c.Files != nil || c.Source.DownloadURL == "" || c.Source.Magnet != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := f.Download(ctx, c.Source)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta, err := release.ParseTorrent(body)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Files = meta.Files
|
||||||
|
if c.InfoHash == "" {
|
||||||
|
c.InfoHash = meta.InfoHash
|
||||||
|
}
|
||||||
|
if c.FilesCount == 0 {
|
||||||
|
c.FilesCount = len(meta.Files)
|
||||||
|
}
|
||||||
|
looked = append(looked, i)
|
||||||
|
}
|
||||||
|
return looked
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick finds a candidate by key: the infohash first, the exact name for a
|
||||||
|
// result that has none.
|
||||||
|
func pick(cands []Candidate, key string) *Candidate {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash := indexer.NormalizeHash(key)
|
||||||
|
for i := range cands {
|
||||||
|
if cands[i].InfoHash != "" && cands[i].InfoHash == hash {
|
||||||
|
return &cands[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range cands {
|
||||||
|
if cands[i].InfoHash == "" && cands[i].Name == key {
|
||||||
|
return &cands[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSearch struct {
|
||||||
|
results []indexer.Result
|
||||||
|
err error
|
||||||
|
asked []indexer.Query
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSearch) Search(_ context.Context, q indexer.Query) ([]indexer.Result, error) {
|
||||||
|
f.asked = append(f.asked, q)
|
||||||
|
return f.results, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ram = Want{Want: release.Want{
|
||||||
|
Artist: "Daft Punk", Title: "Random Access Memories", Year: 2013, PrimaryType: "Album",
|
||||||
|
MinTracks: 13, MaxTracks: 15,
|
||||||
|
}}
|
||||||
|
|
||||||
|
var anyQuality = release.NewProfile([]string{
|
||||||
|
"lossless_hires", "lossless", "lossy_high", "lossy_standard", "lossy_low", "lossy_poor", "lossy_trash",
|
||||||
|
})
|
||||||
|
|
||||||
|
func result(title, hash string, seeders int) indexer.Result {
|
||||||
|
return indexer.Result{Title: title, InfoHash: hash, Seeders: seeders, Indexer: "Knaben", Source: "prowlarr",
|
||||||
|
Magnet: "magnet:?xt=urn:btih:" + hash}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFor_JudgesSortsAndFolds(t *testing.T) {
|
||||||
|
search := &fakeSearch{results: []indexer.Result{
|
||||||
|
result("Daft Punk - Random Access Memories (2013) [MP3 320]", "aaaa", 40),
|
||||||
|
result("Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", "bbbb", 90),
|
||||||
|
result("Daft Punk - Random Access Memories (2013) [FLAC]", "cccc", 12),
|
||||||
|
result("Daft Punk - Random Access Memories (2013) [FLAC]", "CCCC", 30), // the same torrent from another tracker
|
||||||
|
result("Daft Punk - Discovery (2001) [FLAC]", "dddd", 50),
|
||||||
|
}}
|
||||||
|
s := &Service{Search: search, Profile: anyQuality}
|
||||||
|
r := s.For(context.Background(), ram)
|
||||||
|
|
||||||
|
if len(search.asked) != 1 || search.asked[0].Text != "Daft Punk Random Access Memories" {
|
||||||
|
t.Fatalf("asked %+v", search.asked)
|
||||||
|
}
|
||||||
|
if r.Considered != 5 || r.Folded != 1 || len(r.Candidates) != 4 {
|
||||||
|
t.Fatalf("considered %d folded %d candidates %d", r.Considered, r.Folded, len(r.Candidates))
|
||||||
|
}
|
||||||
|
// Lossless before high-quality lossy among what can be taken; the
|
||||||
|
// folded copy keeps the higher seeder count.
|
||||||
|
first, second := r.Candidates[0], r.Candidates[1]
|
||||||
|
if first.InfoHash != "cccc" || first.Verdict != Grab || first.Seeders != 30 || first.Tier != release.TierLossless {
|
||||||
|
t.Errorf("first = %s %s %d %s", first.Name, first.Verdict, first.Seeders, first.Tier)
|
||||||
|
}
|
||||||
|
if second.InfoHash != "aaaa" || second.Verdict != Grab {
|
||||||
|
t.Errorf("second = %s %s", second.Name, second.Verdict)
|
||||||
|
}
|
||||||
|
for _, c := range r.Candidates[2:] {
|
||||||
|
if c.Verdict != Rejected || len(c.Reasons) != 1 || c.Reasons[0].Kind != Hard {
|
||||||
|
t.Errorf("%s: %s %+v", c.Name, c.Verdict, c.Reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Grabbable()) != 2 || !r.ForAPerson() {
|
||||||
|
t.Errorf("grabbable %d", len(r.Grabbable()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFor_ProfileSeedersAndDoubt(t *testing.T) {
|
||||||
|
search := &fakeSearch{results: []indexer.Result{
|
||||||
|
result("Daft Punk - Random Access Memories [MP3 128]", "aaaa", 5),
|
||||||
|
result("Daft Punk - Random Access Memories [FLAC]", "bbbb", 0),
|
||||||
|
result("Daft Punk - Random Access Memories", "cccc", 9),
|
||||||
|
}}
|
||||||
|
s := &Service{Search: search, Profile: release.NewProfile([]string{"lossless", "lossy_high"})}
|
||||||
|
r := s.For(context.Background(), ram)
|
||||||
|
verdicts := map[string]Verdict{}
|
||||||
|
for _, c := range r.Candidates {
|
||||||
|
verdicts[c.InfoHash] = c.Verdict
|
||||||
|
}
|
||||||
|
if verdicts["aaaa"] != Refused || verdicts["bbbb"] != Review || verdicts["cccc"] != Refused {
|
||||||
|
t.Errorf("verdicts = %v", verdicts)
|
||||||
|
}
|
||||||
|
if len(r.Grabbable()) != 0 || !r.ForAPerson() {
|
||||||
|
t.Error("nothing grabbable, but something for a person")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFor_BlockedReleasesAreSetAside(t *testing.T) {
|
||||||
|
c := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "aaaa", Seeders: 3}
|
||||||
|
decide(&c, ram, anyQuality, map[string]string{"aaaa": "You set this release aside."})
|
||||||
|
if c.Verdict != Blocked || c.Reason != "You set this release aside." {
|
||||||
|
t.Errorf("verdict %s %q", c.Verdict, c.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFor_UnconfiguredAndFailedSearches(t *testing.T) {
|
||||||
|
if r := (&Service{}).For(context.Background(), ram); !r.Unconfigured {
|
||||||
|
t.Error("no source is unconfigured")
|
||||||
|
}
|
||||||
|
r := (&Service{Search: &fakeSearch{err: indexer.ErrUnconfigured}}).For(context.Background(), ram)
|
||||||
|
if !r.Unconfigured {
|
||||||
|
t.Error("ErrUnconfigured is unconfigured")
|
||||||
|
}
|
||||||
|
r = (&Service{Search: &fakeSearch{err: errors.New("dial tcp: refused")}}).For(context.Background(), ram)
|
||||||
|
if r.Error == "" {
|
||||||
|
t.Error("a failed search says so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFanout_ANarrowedSearchKeepsWhatAnswered(t *testing.T) {
|
||||||
|
up := &fakeSearch{results: []indexer.Result{result("Daft Punk - Random Access Memories [FLAC]", "aaaa", 3)}}
|
||||||
|
down := &fakeSearch{err: indexer.ErrUnreachable}
|
||||||
|
f := &Fanout{Sources: []Source{{Code: "prowlarr", Label: "Prowlarr", Search: up}, {Code: "torznab", Label: "Feed", Search: down}}}
|
||||||
|
r := (&Service{Search: f, Profile: anyQuality}).For(context.Background(), ram)
|
||||||
|
if r.Error != "" || len(r.Candidates) != 1 || len(r.Narrowed) != 1 || r.Narrowed[0].Source != "torznab" {
|
||||||
|
t.Errorf("narrowed search: error %q, %d candidates, narrowed %+v", r.Error, len(r.Candidates), r.Narrowed)
|
||||||
|
}
|
||||||
|
f.Sources = f.Sources[1:]
|
||||||
|
if r := (&Service{Search: f}).For(context.Background(), ram); r.Error == "" {
|
||||||
|
t.Error("every source down is a failed search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacer_SpacesSearchesAndHoldsAfterA429(t *testing.T) {
|
||||||
|
now := time.Unix(0, 0)
|
||||||
|
var slept time.Duration
|
||||||
|
p := &Pacer{Now: func() time.Time { return now }, Sleep: func(_ context.Context, d time.Duration) error { slept += d; return nil }}
|
||||||
|
src := []Source{{Code: "prowlarr", PerHour: 120, Search: &fakeSearch{}}}
|
||||||
|
p.spend(src[0])
|
||||||
|
if !p.Wait(context.Background(), src, now.Add(time.Hour)) || slept != 30*time.Second {
|
||||||
|
t.Errorf("slept %s, want 30s", slept)
|
||||||
|
}
|
||||||
|
if p.Wait(context.Background(), src, now.Add(time.Second)) {
|
||||||
|
t.Error("waited past the deadline")
|
||||||
|
}
|
||||||
|
p.hold("prowlarr", &indexer.RateLimited{RetryAfter: 5 * time.Minute})
|
||||||
|
if at := p.readyAt(src); at.Sub(now) != 5*time.Minute {
|
||||||
|
t.Errorf("held until %s", at.Sub(now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
downloader.Driver
|
||||||
|
added []downloader.Add
|
||||||
|
addErr error
|
||||||
|
deleted []string
|
||||||
|
progress float64
|
||||||
|
// elsewhere: the client holds it but does not list it for Minstrel.
|
||||||
|
elsewhere bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClient) AddTorrent(_ context.Context, a downloader.Add) error {
|
||||||
|
c.added = append(c.added, a)
|
||||||
|
return c.addErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClient) Torrents(_ context.Context, hashes ...string) ([]downloader.Torrent, error) {
|
||||||
|
if c.elsewhere {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []downloader.Torrent{{Hash: hashes[0], Progress: c.progress}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
|
||||||
|
c.deleted = append(c.deleted, hashes...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrab_AddsAMagnetToItsOwnFolder(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
s := &Service{Search: &fakeSearch{results: []indexer.Result{
|
||||||
|
result("Daft Punk - Random Access Memories (2013) [FLAC]", "ABCD", 8),
|
||||||
|
}}, Profile: anyQuality, Grabber: &Downloader{Client: client, Code: "qbittorrent", Landing: "/downloads"}}
|
||||||
|
r := s.For(context.Background(), ram)
|
||||||
|
took := s.Grab(context.Background(), ram, &r.Candidates[0], r.Candidates, false, Origin{By: OriginSchedule})
|
||||||
|
if !took.Added || took.SavePath != "/downloads/abcd" {
|
||||||
|
t.Fatalf("took %+v", took)
|
||||||
|
}
|
||||||
|
if len(client.added) != 1 || !strings.Contains(strings.ToLower(client.added[0].Magnet), "abcd") || client.added[0].Tags[0] != Tag {
|
||||||
|
t.Errorf("added %+v", client.added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrab_RefusesWhatTheVerdictWouldNotTake(t *testing.T) {
|
||||||
|
client := &fakeClient{}
|
||||||
|
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
|
||||||
|
review := Candidate{Verdict: Review, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
|
||||||
|
if took := s.Grab(context.Background(), ram, &review, nil, false, Origin{By: OriginSchedule}); !took.Refused {
|
||||||
|
t.Error("a review was taken unforced")
|
||||||
|
}
|
||||||
|
if took := s.Grab(context.Background(), ram, &review, nil, true, Origin{By: OriginOperator}); !took.Added {
|
||||||
|
t.Errorf("a forced review was not taken: %+v", took)
|
||||||
|
}
|
||||||
|
blocked := Candidate{Verdict: Blocked, InfoHash: "bbbb"}
|
||||||
|
if took := s.Grab(context.Background(), ram, &blocked, nil, true, Origin{By: OriginOperator}); !took.Refused {
|
||||||
|
t.Error("forcing took a release set aside")
|
||||||
|
}
|
||||||
|
if took := (&Service{}).Grab(context.Background(), ram, &review, nil, true, Origin{}); !took.Unconfigured || took.Missing != MissingClient {
|
||||||
|
t.Errorf("no client: %+v", took)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrab_AClientThatAlreadyHasItCounts(t *testing.T) {
|
||||||
|
client := &fakeClient{addErr: downloader.ErrDuplicate}
|
||||||
|
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
|
||||||
|
c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
|
||||||
|
if took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule}); !took.Added {
|
||||||
|
t.Errorf("a duplicate: %+v", took)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A duplicate the client does not list for Minstrel sits under another
|
||||||
|
// category, where it would read as gone half an hour later (#6176). It is set
|
||||||
|
// aside rather than taken; forced, it is refused with the client's reason.
|
||||||
|
func TestGrab_ADuplicateHeldOutsideMinstrelIsNotTaken(t *testing.T) {
|
||||||
|
client := &fakeClient{addErr: fmt.Errorf("%w: already downloading Ohio Players (lidarr)", downloader.ErrDuplicate), elsewhere: true}
|
||||||
|
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
|
||||||
|
c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
|
||||||
|
took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule})
|
||||||
|
if took.Added || !took.Refused {
|
||||||
|
t.Fatalf("took %+v", took)
|
||||||
|
}
|
||||||
|
if c.Verdict != Blocked || !strings.Contains(c.Reason, "(already downloading Ohio Players (lidarr))") {
|
||||||
|
t.Errorf("candidate %s: %q", c.Verdict, c.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
forced := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
|
||||||
|
took = s.Grab(context.Background(), ram, &forced, nil, true, Origin{By: OriginOperator})
|
||||||
|
if took.Added || !strings.Contains(took.Error, "outside Minstrel's category") {
|
||||||
|
t.Errorf("forced: %+v", took)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetter_QualityThenSeedersThenYear(t *testing.T) {
|
||||||
|
flac := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2}
|
||||||
|
mp3 := Candidate{Verdict: Grab, Tier: release.TierLossyHigh, Seeders: 90}
|
||||||
|
if !better(flac, mp3) || better(mp3, flac) {
|
||||||
|
t.Error("quality first among grabs")
|
||||||
|
}
|
||||||
|
busy := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 20}
|
||||||
|
if !better(busy, flac) {
|
||||||
|
t.Error("then seeders")
|
||||||
|
}
|
||||||
|
dated := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2, YearAgrees: true}
|
||||||
|
if !better(dated, flac) {
|
||||||
|
t.Error("then the year")
|
||||||
|
}
|
||||||
|
if better(Candidate{Verdict: Review}, flac) || !better(flac, Candidate{Verdict: Review}) {
|
||||||
|
t.Error("a grab ranks above a review")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLandingFor_OneFolderPerTorrent(t *testing.T) {
|
||||||
|
if got := landingFor("/downloads", "abcd", "x"); got != "/downloads/abcd" {
|
||||||
|
t.Error(got)
|
||||||
|
}
|
||||||
|
if got := landingFor("/downloads", "", "Daft Punk: R.A.M. [FLAC]"); got != "/downloads/daft-punk-r-a-m--flac" {
|
||||||
|
t.Error(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecide_AnUpgradeTakesOnlyABetterCopy(t *testing.T) {
|
||||||
|
up := ram
|
||||||
|
up.Floor, up.Held = release.TierLossyLow, "MP3, about 192 kbps"
|
||||||
|
same := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [MP3 192]"), InfoHash: "aaaa", Seeders: 3}
|
||||||
|
decide(&same, up, anyQuality, nil)
|
||||||
|
if same.Verdict != Refused || same.Reasons[0].Code != CodeNotBetter ||
|
||||||
|
same.Reason != "No better than the copy you have (MP3, about 192 kbps)." {
|
||||||
|
t.Errorf("same quality: %s %q", same.Verdict, same.Reason)
|
||||||
|
}
|
||||||
|
flac := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "bbbb", Seeders: 3}
|
||||||
|
decide(&flac, up, anyQuality, nil)
|
||||||
|
if flac.Verdict != Grab {
|
||||||
|
t.Errorf("a FLAC over a 192 MP3: %s %q", flac.Verdict, flac.Reason)
|
||||||
|
}
|
||||||
|
// A video rip has no floor: any clean copy is better.
|
||||||
|
rip := ram
|
||||||
|
decide(&same, rip, anyQuality, nil)
|
||||||
|
if same.Verdict != Grab {
|
||||||
|
t.Errorf("no floor: %s %q", same.Verdict, same.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Package acquire turns the operator's configured indexers and download
|
||||||
|
// clients into working clients (M502 #5518). Ported from Fabled Librarian's
|
||||||
|
// internal/acquire.
|
||||||
|
//
|
||||||
|
// ONE BUILDER PER KIND. SourceFor makes a search source from a row and
|
||||||
|
// DownloadClient makes a download client, path mapping included. The grab,
|
||||||
|
// the importer, the stall watchdog and the settings page's Test all build
|
||||||
|
// through them, so a Test that passes vouches for the path real traffic takes.
|
||||||
|
// Clients are rebuilt from the row on every use, so a changed address or a new
|
||||||
|
// key takes effect on the next call.
|
||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kinds: which list a row appears in.
|
||||||
|
const (
|
||||||
|
KindSearch = "search"
|
||||||
|
KindDownload = "download"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jobs a row can do. An exchange row does each its switch allows; every
|
||||||
|
// other row does the one its kind names.
|
||||||
|
const (
|
||||||
|
JobSearch = "search"
|
||||||
|
JobDownload = "download"
|
||||||
|
JobEnrichment = "enrichment"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocols: which client a row builds.
|
||||||
|
const (
|
||||||
|
ProtocolProwlarr = "prowlarr"
|
||||||
|
ProtocolTorznab = "torznab"
|
||||||
|
ProtocolQBittorrent = "qbittorrent"
|
||||||
|
ProtocolExchange = "exchange"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCategory files everything Minstrel adds to a download client.
|
||||||
|
const DefaultCategory = "minstrel"
|
||||||
|
|
||||||
|
// addable is what a new row of a protocol starts as.
|
||||||
|
type addable struct {
|
||||||
|
kind string
|
||||||
|
label string
|
||||||
|
category string
|
||||||
|
timeoutMs int32
|
||||||
|
perHour int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogue is every protocol the operator can add. Exchange starts unpaced
|
||||||
|
// (it searches its own database); the others at 120 searches an hour, which
|
||||||
|
// keeps a Prowlarr's indexers under the rate most trackers allow.
|
||||||
|
var catalogue = map[string]addable{
|
||||||
|
ProtocolProwlarr: {kind: KindSearch, label: "Prowlarr", timeoutMs: 60000, perHour: 120},
|
||||||
|
ProtocolTorznab: {kind: KindSearch, label: "Torznab feed", timeoutMs: 30000, perHour: 120},
|
||||||
|
ProtocolQBittorrent: {kind: KindDownload, label: "qBittorrent", category: DefaultCategory, timeoutMs: 15000, perHour: 0},
|
||||||
|
ProtocolExchange: {kind: KindDownload, label: "FabledExchange", category: DefaultCategory, timeoutMs: 30000, perHour: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addable says whether protocol is one the operator can add.
|
||||||
|
func Addable(protocol string) bool {
|
||||||
|
_, ok := catalogue[protocol]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searches says whether a row is a search source.
|
||||||
|
func Searches(row dbq.AcquisitionService) bool {
|
||||||
|
if row.Protocol == ProtocolExchange {
|
||||||
|
return row.ServesSearch
|
||||||
|
}
|
||||||
|
return row.Kind == KindSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches says whether a row is a download client.
|
||||||
|
func Fetches(row dbq.AcquisitionService) bool {
|
||||||
|
if row.Protocol == ProtocolExchange {
|
||||||
|
return row.ServesDownloads
|
||||||
|
}
|
||||||
|
return row.Kind == KindDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searcher is a search source.
|
||||||
|
type Searcher interface {
|
||||||
|
Search(ctx context.Context, q indexer.Query) ([]indexer.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetcher fetches a result's .torrent through the source that offered it.
|
||||||
|
type Fetcher interface {
|
||||||
|
Download(ctx context.Context, r indexer.Result) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source is one configured search source.
|
||||||
|
type Source struct {
|
||||||
|
Code string
|
||||||
|
Label string
|
||||||
|
// PerHour is the most searches the schedule sends it an hour; 0 = unpaced.
|
||||||
|
PerHour int
|
||||||
|
Search Searcher
|
||||||
|
// Fetch is nil for a source whose results are all magnets.
|
||||||
|
Fetch Fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceFor builds the search source a row describes.
|
||||||
|
func SourceFor(row dbq.AcquisitionService) Source {
|
||||||
|
src := Source{Code: row.Code, Label: row.Label, PerHour: int(row.SearchesPerHour)}
|
||||||
|
timeout := msDuration(row.TimeoutMs)
|
||||||
|
switch row.Protocol {
|
||||||
|
case ProtocolProwlarr:
|
||||||
|
c := &prowlarr.Client{Config: prowlarr.Config{
|
||||||
|
Code: row.Code, BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
|
||||||
|
}}
|
||||||
|
src.Search, src.Fetch = c, c
|
||||||
|
case ProtocolExchange:
|
||||||
|
src.Search = ExchangeFor(row)
|
||||||
|
default:
|
||||||
|
c := &indexer.Torznab{Config: indexer.TorznabConfig{
|
||||||
|
Code: row.Code, FeedURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
|
||||||
|
}}
|
||||||
|
src.Search, src.Fetch = c, c
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeFor builds the search and enrichment client for an exchange row.
|
||||||
|
func ExchangeFor(row dbq.AcquisitionService) *exchange.Client {
|
||||||
|
return &exchange.Client{
|
||||||
|
Code: row.Code,
|
||||||
|
Config: exchange.Config{
|
||||||
|
BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: msDuration(row.TimeoutMs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadClient builds the download client a row describes, wrapped in its
|
||||||
|
// path mapping.
|
||||||
|
func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
|
||||||
|
d, err := downloader.For(row.Protocol, downloader.Config{
|
||||||
|
BaseURL: row.BaseUrl, Username: row.Username, Password: row.Password,
|
||||||
|
APIKey: row.ApiKey, Category: row.Category, Timeout: msDuration(row.TimeoutMs),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return downloader.Mapped(d, downloader.PathMap{Local: row.LocalPath, Remote: row.RemotePath}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadClientFor builds the download client a grab went to, by the code
|
||||||
|
// of its acquisition_services row.
|
||||||
|
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
|
||||||
|
row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("the download client %q is no longer set up", code)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return DownloadClient(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources builds every enabled search source.
|
||||||
|
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
|
||||||
|
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Source, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = SourceFor(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is a code with no row.
|
||||||
|
var ErrNotFound = errors.New("no such service")
|
||||||
|
|
||||||
|
// ErrTooMany is a twenty-first row of one protocol.
|
||||||
|
var ErrTooMany = errors.New("there are already twenty of these")
|
||||||
|
|
||||||
|
// InvalidError is a setting the operator has to change, in words for the
|
||||||
|
// settings card.
|
||||||
|
type InvalidError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
func invalid(field, msg string) error { return &InvalidError{Field: field, Message: msg} }
|
||||||
|
|
||||||
|
// maxPerProtocol bounds the codes tried for a new row. Nobody runs twenty
|
||||||
|
// Prowlarrs; the bound keeps a loop over a full table finite.
|
||||||
|
const maxPerProtocol = 20
|
||||||
|
|
||||||
|
// Store reads and writes acquisition_services.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore returns a Store over pool.
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||||
|
|
||||||
|
// List returns every row without its secrets.
|
||||||
|
func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, error) {
|
||||||
|
return dbq.New(s.pool).ListAcquisitionServices(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one row, secrets included: for building a client only.
|
||||||
|
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
|
||||||
|
row, err := dbq.New(s.pool).GetAcquisitionService(ctx, code)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return row, ErrNotFound
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds a row of protocol, switched off and with no address. Its code
|
||||||
|
// is the protocol for the first, then protocol-2, protocol-3.
|
||||||
|
func (s *Store) Create(ctx context.Context, protocol string) (dbq.AcquisitionService, error) {
|
||||||
|
a, ok := catalogue[protocol]
|
||||||
|
if !ok {
|
||||||
|
return dbq.AcquisitionService{}, invalid("protocol", "One of prowlarr, torznab, qbittorrent, exchange.")
|
||||||
|
}
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
for n := 1; n <= maxPerProtocol; n++ {
|
||||||
|
code, label := protocol, a.label
|
||||||
|
if n > 1 {
|
||||||
|
code, label = fmt.Sprintf("%s-%d", protocol, n), fmt.Sprintf("%s %d", a.label, n)
|
||||||
|
}
|
||||||
|
row, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{
|
||||||
|
Code: code, Kind: a.kind, Protocol: protocol, Label: label,
|
||||||
|
Category: a.category, TimeoutMs: a.timeoutMs, SearchesPerHour: a.perHour,
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
return dbq.AcquisitionService{}, ErrTooMany
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is everything on the settings form except the credentials.
|
||||||
|
type Update struct {
|
||||||
|
Label string
|
||||||
|
Enabled bool
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Category string
|
||||||
|
TimeoutMs int32
|
||||||
|
ServesSearch bool
|
||||||
|
ServesDownloads bool
|
||||||
|
ServesEnrichment bool
|
||||||
|
LocalPath string
|
||||||
|
RemotePath string
|
||||||
|
SearchesPerHour int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save validates u and writes it to code's row.
|
||||||
|
func (s *Store) Save(ctx context.Context, code string, u Update) (dbq.AcquisitionService, error) {
|
||||||
|
u.Label = strings.TrimSpace(u.Label)
|
||||||
|
u.BaseURL = strings.TrimSpace(u.BaseURL)
|
||||||
|
u.Username = strings.TrimSpace(u.Username)
|
||||||
|
u.Category = strings.TrimSpace(u.Category)
|
||||||
|
u.LocalPath = strings.TrimSpace(u.LocalPath)
|
||||||
|
u.RemotePath = strings.TrimSpace(u.RemotePath)
|
||||||
|
if err := u.validate(); err != nil {
|
||||||
|
return dbq.AcquisitionService{}, err
|
||||||
|
}
|
||||||
|
row, err := dbq.New(s.pool).UpdateAcquisitionService(ctx, dbq.UpdateAcquisitionServiceParams{
|
||||||
|
Code: code, Label: u.Label, Enabled: u.Enabled, BaseUrl: u.BaseURL,
|
||||||
|
Username: u.Username, Category: u.Category, TimeoutMs: u.TimeoutMs,
|
||||||
|
ServesSearch: u.ServesSearch, ServesDownloads: u.ServesDownloads,
|
||||||
|
ServesEnrichment: u.ServesEnrichment, LocalPath: u.LocalPath,
|
||||||
|
RemotePath: u.RemotePath, SearchesPerHour: u.SearchesPerHour,
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return row, ErrNotFound
|
||||||
|
}
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate mirrors the table's CHECKs, so a bad value is named in the card's
|
||||||
|
// own terms before Postgres refuses it.
|
||||||
|
func (u Update) validate() error {
|
||||||
|
if u.Label == "" {
|
||||||
|
return invalid("label", "Name: give it a name.")
|
||||||
|
}
|
||||||
|
if u.Enabled && u.BaseURL == "" {
|
||||||
|
return invalid("base_url", "Address: give the address before switching this on.")
|
||||||
|
}
|
||||||
|
if u.BaseURL != "" {
|
||||||
|
parsed, err := url.Parse(u.BaseURL)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
return invalid("base_url", "Address: start with http:// or https://.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.TimeoutMs < 1000 || u.TimeoutMs > 300000 {
|
||||||
|
return invalid("timeout_ms", "Give up after: between 1000 and 300000 milliseconds.")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u.LocalPath, "/") {
|
||||||
|
return invalid("local_path", "Downloads folder as Minstrel sees it: a full path, starting with /.")
|
||||||
|
}
|
||||||
|
if u.RemotePath != "" && !strings.HasPrefix(u.RemotePath, "/") {
|
||||||
|
return invalid("remote_path", "Downloads folder as the client sees it: a full path starting with /, or empty when it is the same path.")
|
||||||
|
}
|
||||||
|
if u.SearchesPerHour < 0 || u.SearchesPerHour > 3600 {
|
||||||
|
return invalid("searches_per_hour", "Searches per hour: between 0 and 3600. 0 means no limit.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes code's row.
|
||||||
|
func (s *Store) Delete(ctx context.Context, code string) error {
|
||||||
|
n, err := dbq.New(s.pool).DeleteAcquisitionService(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAPIKey stores a key, trimmed; empty clears it.
|
||||||
|
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
|
||||||
|
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
|
||||||
|
Code: code, ApiKey: strings.TrimSpace(key),
|
||||||
|
})
|
||||||
|
return affected(n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword stores a password as typed (spaces are part of a password);
|
||||||
|
// empty clears it.
|
||||||
|
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
|
||||||
|
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
|
||||||
|
Code: code, Password: password,
|
||||||
|
})
|
||||||
|
return affected(n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func affected(n int64, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPool(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)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_AddsConfiguresAndRemoves_Integration(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := NewStore(newPool(t))
|
||||||
|
|
||||||
|
first, err := s.Create(ctx, ProtocolExchange)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
second, err := s.Create(ctx, ProtocolExchange)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a second: %v", err)
|
||||||
|
}
|
||||||
|
if first.Code != "exchange" || second.Code != "exchange-2" || second.Label != "FabledExchange 2" {
|
||||||
|
t.Errorf("codes = %q, %q (%q); want exchange, exchange-2", first.Code, second.Code, second.Label)
|
||||||
|
}
|
||||||
|
// A new exchange is off, does every job, files under minstrel and is
|
||||||
|
// unpaced.
|
||||||
|
if first.Enabled || first.Kind != KindDownload || !first.ServesSearch || !first.ServesDownloads ||
|
||||||
|
!first.ServesEnrichment || first.Category != DefaultCategory || first.SearchesPerHour != 0 || first.LocalPath != "/downloads" {
|
||||||
|
t.Errorf("a new exchange = %+v", first)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(ctx, "transmission"); !errors.As(err, new(*InvalidError)) {
|
||||||
|
t.Errorf("an unknown protocol: err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := Update{
|
||||||
|
Label: "Exchange", Enabled: true, BaseURL: "http://exchange:8080", Category: "minstrel",
|
||||||
|
TimeoutMs: 20000, ServesSearch: true, LocalPath: "/downloads", RemotePath: "/data/torrents",
|
||||||
|
}
|
||||||
|
saved, err := s.Save(ctx, first.Code, u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
if !saved.Enabled || saved.ServesDownloads || saved.RemotePath != "/data/torrents" {
|
||||||
|
t.Errorf("saved = %+v", saved)
|
||||||
|
}
|
||||||
|
if _, err := s.Save(ctx, "nope", u); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("saving a missing row: err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets are write-only: the list says whether one is set, never what.
|
||||||
|
if err := s.SetAPIKey(ctx, first.Code, " key "); err != nil {
|
||||||
|
t.Fatalf("set key: %v", err)
|
||||||
|
}
|
||||||
|
full, err := s.Get(ctx, first.Code)
|
||||||
|
if err != nil || full.ApiKey != "key" {
|
||||||
|
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
|
||||||
|
}
|
||||||
|
rows, err := s.List(ctx)
|
||||||
|
if err != nil || len(rows) != 2 {
|
||||||
|
t.Fatalf("list: %d rows, err %v", len(rows), err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Code == first.Code && !r.HasApiKey {
|
||||||
|
t.Error("the list should say a key is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the exchange with search switched on is a search source.
|
||||||
|
srcs, err := Sources(ctx, s.pool)
|
||||||
|
if err != nil || len(srcs) != 1 || srcs[0].Code != first.Code {
|
||||||
|
t.Errorf("sources = %+v, err %v; want only the enabled exchange", srcs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Delete(ctx, second.Code); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete(ctx, second.Code); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("deleting twice: err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package acquire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResult is what the settings card says after Test.
|
||||||
|
type TestResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// testQuery is a search every music indexer should answer.
|
||||||
|
const testQuery = "Radiohead"
|
||||||
|
|
||||||
|
// Test reaches the service a row describes, through the same builders real
|
||||||
|
// traffic uses. It runs whether or not the row is switched on: paste the
|
||||||
|
// address, test it, then switch it on.
|
||||||
|
func Test(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||||
|
if strings.TrimSpace(row.BaseUrl) == "" {
|
||||||
|
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
|
||||||
|
}
|
||||||
|
if row.Protocol == ProtocolExchange {
|
||||||
|
return testExchange(ctx, row)
|
||||||
|
}
|
||||||
|
if row.Kind == KindSearch {
|
||||||
|
return testSearch(ctx, row)
|
||||||
|
}
|
||||||
|
return testDownload(ctx, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testExchange runs each job the row has switched on and reports them all.
|
||||||
|
func testExchange(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||||
|
var parts []string
|
||||||
|
ok := true
|
||||||
|
run := func(name string, on bool, test func() TestResult) {
|
||||||
|
if !on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := test()
|
||||||
|
ok = ok && r.OK
|
||||||
|
parts = append(parts, name+": "+r.Detail)
|
||||||
|
}
|
||||||
|
run("Downloads", row.ServesDownloads, func() TestResult { return testDownload(ctx, row) })
|
||||||
|
run("Search", row.ServesSearch, func() TestResult { return testSearch(ctx, row) })
|
||||||
|
run("Release details", row.ServesEnrichment, func() TestResult { return testEnrichment(ctx, row) })
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return TestResult{Detail: "Every job of this FabledExchange is switched off, so there is nothing to test."}
|
||||||
|
}
|
||||||
|
return TestResult{OK: ok, Detail: strings.Join(parts, " ")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSearch runs a real search.
|
||||||
|
func testSearch(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||||
|
src := SourceFor(row)
|
||||||
|
results, err := src.Search.Search(ctx, indexer.Query{Text: testQuery, Categories: indexer.MusicCategories})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, indexer.ErrUnconfigured):
|
||||||
|
return TestResult{Detail: "Add this service's address, and the API key it shows on its own settings page."}
|
||||||
|
case errors.Is(err, indexer.ErrAuth):
|
||||||
|
return TestResult{Detail: indexer.FirstLine(err.Error()) + ". Check it on the service's own settings page."}
|
||||||
|
case err != nil:
|
||||||
|
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||||
|
case len(results) == 0:
|
||||||
|
return TestResult{OK: true, Detail: "Connected, but a test search for music found nothing. Check that it has an indexer with music categories."}
|
||||||
|
}
|
||||||
|
return TestResult{OK: true, Detail: fmt.Sprintf("Connected. %d results for a test search.", len(results))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testDownload lists the client's torrents, which proves the session and not
|
||||||
|
// only the login, then checks the downloads folder from Minstrel's side.
|
||||||
|
func testDownload(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||||
|
c, err := DownloadClient(row)
|
||||||
|
if err != nil {
|
||||||
|
return TestResult{Detail: err.Error()}
|
||||||
|
}
|
||||||
|
ts, err := c.Torrents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("Connected. %d torrents.", len(ts))
|
||||||
|
if row.Category != "" {
|
||||||
|
detail = fmt.Sprintf("Connected. %d torrents in the %q category.", len(ts), row.Category)
|
||||||
|
}
|
||||||
|
if up, err := c.Connected(ctx); err == nil && !up {
|
||||||
|
detail += " The client reports it is not connected to any peers, so downloads will stall."
|
||||||
|
}
|
||||||
|
if problem := landingProblem(row.LocalPath); problem != "" {
|
||||||
|
return TestResult{Detail: detail + " " + problem}
|
||||||
|
}
|
||||||
|
return TestResult{OK: true, Detail: detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
// landingProblem checks Minstrel can see and write the downloads folder: it
|
||||||
|
// moves finished downloads out of it. Only the local path can be checked;
|
||||||
|
// the client's own name for it cannot be seen from here.
|
||||||
|
func landingProblem(dir string) string {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Sprintf("But Minstrel cannot see its downloads folder at %s. Mount the download client's downloads folder there, or change where Minstrel looks for it.", dir)
|
||||||
|
case !info.IsDir():
|
||||||
|
return fmt.Sprintf("But %s is a file, not the downloads folder.", dir)
|
||||||
|
}
|
||||||
|
probe, err := os.CreateTemp(dir, ".minstrel-probe-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("But Minstrel cannot write to %s, so it could not move a finished download out of it. Check the mount is not read-only and Minstrel's user can write there.", dir)
|
||||||
|
}
|
||||||
|
name := probe.Name()
|
||||||
|
_ = probe.Close()
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnrichment asks about a hash no torrent has. A JSON "not crawled" is a
|
||||||
|
// healthy answer; a 404 from something that is not Exchange is not.
|
||||||
|
func testEnrichment(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||||
|
_, err := ExchangeFor(row).Torrent(ctx, strings.Repeat("0", 40))
|
||||||
|
switch {
|
||||||
|
case err == nil, errors.Is(err, exchange.ErrUnknown):
|
||||||
|
return TestResult{OK: true, Detail: "Connected. The index answered."}
|
||||||
|
case errors.Is(err, exchange.ErrUnconfigured):
|
||||||
|
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
|
||||||
|
}
|
||||||
|
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Indexers and download clients, admin side (M502 #5518). Rows the operator
|
||||||
|
// adds and configures; nothing is seeded. Secrets are write-only: no response
|
||||||
|
// carries one, only whether it is set.
|
||||||
|
|
||||||
|
// acquisitionBodyLimit caps a settings body. The largest is a full form of
|
||||||
|
// short strings.
|
||||||
|
const acquisitionBodyLimit = 64 << 10
|
||||||
|
|
||||||
|
type acquisitionServiceResp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
HasAPIKey bool `json:"has_api_key"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
TimeoutMs int32 `json:"timeout_ms"`
|
||||||
|
ServesSearch bool `json:"serves_search"`
|
||||||
|
ServesDownloads bool `json:"serves_downloads"`
|
||||||
|
ServesEnrichment bool `json:"serves_enrichment"`
|
||||||
|
LocalPath string `json:"local_path"`
|
||||||
|
RemotePath string `json:"remote_path"`
|
||||||
|
SearchesPerHour int32 `json:"searches_per_hour"`
|
||||||
|
// Usable is switched on and addressed: the state the jobs read.
|
||||||
|
Usable bool `json:"usable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquisitionRespOf(r dbq.AcquisitionService) acquisitionServiceResp {
|
||||||
|
return acquisitionServiceResp{
|
||||||
|
Code: r.Code, Kind: r.Kind, Protocol: r.Protocol, Label: r.Label,
|
||||||
|
Enabled: r.Enabled, BaseURL: r.BaseUrl,
|
||||||
|
HasAPIKey: r.ApiKey != "", HasPassword: r.Password != "",
|
||||||
|
Username: r.Username, Category: r.Category, TimeoutMs: r.TimeoutMs,
|
||||||
|
ServesSearch: r.ServesSearch, ServesDownloads: r.ServesDownloads,
|
||||||
|
ServesEnrichment: r.ServesEnrichment,
|
||||||
|
LocalPath: r.LocalPath, RemotePath: r.RemotePath,
|
||||||
|
SearchesPerHour: r.SearchesPerHour,
|
||||||
|
Usable: r.Enabled && r.BaseUrl != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAcquisitionServices implements GET /api/admin/acquisition/services.
|
||||||
|
func (h *handlers) handleListAcquisitionServices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.acquisition.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list acquisition services", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]acquisitionServiceResp, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
out[i] = acquisitionRespOf(dbq.AcquisitionService{
|
||||||
|
Code: row.Code, Kind: row.Kind, Protocol: row.Protocol, Label: row.Label,
|
||||||
|
Enabled: row.Enabled, BaseUrl: row.BaseUrl, Username: row.Username,
|
||||||
|
Category: row.Category, TimeoutMs: row.TimeoutMs,
|
||||||
|
ServesSearch: row.ServesSearch, ServesDownloads: row.ServesDownloads,
|
||||||
|
ServesEnrichment: row.ServesEnrichment, LocalPath: row.LocalPath,
|
||||||
|
RemotePath: row.RemotePath, SearchesPerHour: row.SearchesPerHour,
|
||||||
|
})
|
||||||
|
out[i].HasAPIKey, out[i].HasPassword = row.HasApiKey, row.HasPassword
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"services": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateAcquisitionService implements POST /api/admin/acquisition/services
|
||||||
|
// with {"protocol": "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row
|
||||||
|
// starts switched off, with no address.
|
||||||
|
func (h *handlers) handleCreateAcquisitionService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
if !decodeAcquisitionBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.acquisition.Create(r.Context(), body.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
h.writeAcquisitionErr(w, "admin: create acquisition service", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, acquisitionRespOf(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateAcquisitionBody struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
TimeoutMs int32 `json:"timeout_ms"`
|
||||||
|
ServesSearch bool `json:"serves_search"`
|
||||||
|
ServesDownloads bool `json:"serves_downloads"`
|
||||||
|
ServesEnrichment bool `json:"serves_enrichment"`
|
||||||
|
LocalPath string `json:"local_path"`
|
||||||
|
RemotePath string `json:"remote_path"`
|
||||||
|
SearchesPerHour int32 `json:"searches_per_hour"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateAcquisitionService implements PUT
|
||||||
|
// /api/admin/acquisition/services/{code}: the whole form except credentials.
|
||||||
|
func (h *handlers) handleUpdateAcquisitionService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body updateAcquisitionBody
|
||||||
|
if !decodeAcquisitionBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.acquisition.Save(r.Context(), chi.URLParam(r, "code"), acquire.Update(body))
|
||||||
|
if err != nil {
|
||||||
|
h.writeAcquisitionErr(w, "admin: update acquisition service", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, acquisitionRespOf(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteAcquisitionService implements DELETE
|
||||||
|
// /api/admin/acquisition/services/{code}.
|
||||||
|
func (h *handlers) handleDeleteAcquisitionService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.acquisition.Delete(r.Context(), chi.URLParam(r, "code")); err != nil {
|
||||||
|
h.writeAcquisitionErr(w, "admin: delete acquisition service", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestAcquisitionService implements POST
|
||||||
|
// /api/admin/acquisition/services/{code}/test. POST because it reaches
|
||||||
|
// another machine, which a prefetch must not trigger. Always 200 with
|
||||||
|
// {ok, detail}: a failed connection is the answer, not an error.
|
||||||
|
func (h *handlers) handleTestAcquisitionService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
row, err := h.acquisition.Get(r.Context(), chi.URLParam(r, "code"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeAcquisitionErr(w, "admin: test acquisition service", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, acquire.Test(r.Context(), row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetAcquisitionSecret implements PUT and DELETE on
|
||||||
|
// /api/admin/acquisition/services/{code}/api-key and .../password. PUT takes
|
||||||
|
// {"value": "..."}; DELETE clears it. 204 with no body either way: echoing
|
||||||
|
// anything here is how a secret ends up in a response.
|
||||||
|
func (h *handlers) handleSetAcquisitionSecret(field string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
value := ""
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
var body struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if !decodeAcquisitionBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Value == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", "Paste a value, or use Remove to clear the one that is set."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value = body.Value
|
||||||
|
}
|
||||||
|
code := chi.URLParam(r, "code")
|
||||||
|
var err error
|
||||||
|
if field == "password" {
|
||||||
|
err = h.acquisition.SetPassword(r.Context(), code, value)
|
||||||
|
} else {
|
||||||
|
err = h.acquisition.SetAPIKey(r.Context(), code, value)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.writeAcquisitionErr(w, "admin: set acquisition "+field, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeAcquisitionBody(w http.ResponseWriter, r *http.Request, into any) bool {
|
||||||
|
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, acquisitionBodyLimit)).Decode(into)
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) writeAcquisitionErr(w http.ResponseWriter, msg string, err error) {
|
||||||
|
var invalid *acquire.InvalidError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &invalid):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", invalid.Message))
|
||||||
|
case errors.Is(err, acquire.ErrNotFound):
|
||||||
|
writeErr(w, apierror.NotFound("service"))
|
||||||
|
case errors.Is(err, acquire.ErrTooMany):
|
||||||
|
writeErr(w, apierror.Conflict("too_many_services", "There are already twenty of these. Remove one you no longer use first."))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, msg, apierror.Internal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
||||||
@@ -23,6 +22,7 @@ import (
|
|||||||
type duplicateMemberView struct {
|
type duplicateMemberView struct {
|
||||||
TrackID string `json:"track_id"`
|
TrackID string `json:"track_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
ArtistID string `json:"artist_id"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumID string `json:"album_id"`
|
AlbumID string `json:"album_id"`
|
||||||
AlbumTitle string `json:"album_title"`
|
AlbumTitle string `json:"album_title"`
|
||||||
@@ -33,12 +33,11 @@ type duplicateMemberView struct {
|
|||||||
AddedAt string `json:"added_at"`
|
AddedAt string `json:"added_at"`
|
||||||
LikeCount int64 `json:"like_count"`
|
LikeCount int64 `json:"like_count"`
|
||||||
PlayCount int64 `json:"play_count"`
|
PlayCount int64 `json:"play_count"`
|
||||||
// LidarrState is Lidarr's view of the file as of the resolver's last pass
|
DiscNumber *int32 `json:"disc_number"`
|
||||||
// (M498): "tracked" (mapped to a track, so it cannot be removed without
|
TrackNumber *int32 `json:"track_number"`
|
||||||
// Lidarr downloading it again), "unmapped", or null when not asked.
|
// StreamURL lets the review play each copy, to hear whether they are the
|
||||||
LidarrState *string `json:"lidarr_state"`
|
// same song (#6168).
|
||||||
DiscNumber *int32 `json:"disc_number"`
|
StreamURL string `json:"stream_url"`
|
||||||
TrackNumber *int32 `json:"track_number"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
||||||
@@ -170,7 +169,9 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
|
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
|
||||||
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
|
var resolvedActions = []string{
|
||||||
|
string(audit.ActionDuplicateMerge), string(audit.ActionDuplicateDismiss), string(audit.ActionDuplicateWrongFile),
|
||||||
|
}
|
||||||
|
|
||||||
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
|
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
|
||||||
var c duplicateViewCounts
|
var c duplicateViewCounts
|
||||||
@@ -185,9 +186,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts,
|
|||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvedActivityView is one thing the resolver did on its own: a merge
|
// resolvedActivityView is one merge the resolver made on its own (action
|
||||||
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
|
// duplicate_merge). Details is the audit row's metadata, which names the files.
|
||||||
// Details is the audit row's metadata, which names the files or releases.
|
|
||||||
type resolvedActivityView struct {
|
type resolvedActivityView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
@@ -281,6 +281,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
|||||||
groups[n].Members = append(groups[n].Members, duplicateMemberView{
|
groups[n].Members = append(groups[n].Members, duplicateMemberView{
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
|
ArtistID: uuidToString(row.ArtistID),
|
||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
@@ -291,14 +292,13 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
|||||||
AddedAt: formatTimestamp(row.AddedAt),
|
AddedAt: formatTimestamp(row.AddedAt),
|
||||||
LikeCount: row.LikeCount,
|
LikeCount: row.LikeCount,
|
||||||
PlayCount: row.PlayCount,
|
PlayCount: row.PlayCount,
|
||||||
LidarrState: row.LidarrState,
|
|
||||||
DiscNumber: row.DiscNumber,
|
DiscNumber: row.DiscNumber,
|
||||||
TrackNumber: row.TrackNumber,
|
TrackNumber: row.TrackNumber,
|
||||||
|
StreamURL: streamURL(row.TrackID),
|
||||||
})
|
})
|
||||||
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
||||||
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
||||||
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
|
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
|
||||||
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range groups {
|
for i := range groups {
|
||||||
@@ -377,18 +377,16 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i
|
|||||||
// keeps the report's proposal.
|
// keeps the report's proposal.
|
||||||
type mergeDuplicateRequest struct {
|
type mergeDuplicateRequest struct {
|
||||||
SurvivorTrackID string `json:"survivor_track_id"`
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
Unmonitor bool `json:"unmonitor"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
|
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
|
||||||
// deleted from disk; the operator reads them to know exactly what went.
|
// deleted from disk; the operator reads them to know exactly what went.
|
||||||
type mergeDuplicateResponse struct {
|
type mergeDuplicateResponse struct {
|
||||||
SurvivorTrackID string `json:"survivor_track_id"`
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
RemovedPaths []string `json:"removed_paths"`
|
RemovedPaths []string `json:"removed_paths"`
|
||||||
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeRequestBodyLimit bounds the request body. It holds one id and a flag.
|
// mergeRequestBodyLimit bounds the request body. It holds one id.
|
||||||
const mergeRequestBodyLimit = 1 << 16
|
const mergeRequestBodyLimit = 1 << 16
|
||||||
|
|
||||||
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
|
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
|
||||||
@@ -398,10 +396,6 @@ const mergeRequestBodyLimit = 1 << 16
|
|||||||
// Errors:
|
// Errors:
|
||||||
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||||
// removed — nothing was changed (fileRemoveAPIError)
|
// removed — nothing was changed (fileRemoveAPIError)
|
||||||
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
|
|
||||||
// track: removing it would make Lidarr download it again (M498)
|
|
||||||
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
|
|
||||||
// cannot be checked
|
|
||||||
// - 404 duplicate_group_not_pending when the group was already resolved
|
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||||
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||||
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -427,7 +421,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor)
|
res, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if apiErr, ok := fileRemoveAPIError(err); ok {
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
||||||
@@ -439,11 +433,6 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
|||||||
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||||
case errors.Is(err, library.ErrCopyTrackedByLidarr):
|
|
||||||
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
|
|
||||||
case errors.Is(err, tracks.ErrLidarrUnavailable):
|
|
||||||
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
|
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -458,9 +447,92 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
|||||||
for _, c := range res.Removed {
|
for _, c := range res.Removed {
|
||||||
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
||||||
}
|
}
|
||||||
if body.Unmonitor && unmonitorFailed {
|
writeJSON(w, http.StatusOK, resp)
|
||||||
failed := true
|
}
|
||||||
resp.LidarrUnmonitorFailed = &failed
|
|
||||||
|
// refetchDuplicateRequest is the operator's answer for one recording filling
|
||||||
|
// several of an album's slots. keep_track_id is the copy whose title the
|
||||||
|
// recording is; empty means they cannot say, and every copy goes.
|
||||||
|
type refetchDuplicateRequest struct {
|
||||||
|
KeepTrackID string `json:"keep_track_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refetchDuplicateResponse reports what went. Blocked names the releases set
|
||||||
|
// aside; empty when the album came from before Minstrel downloaded anything.
|
||||||
|
type refetchDuplicateResponse struct {
|
||||||
|
KeptTrackID *string `json:"kept_track_id"`
|
||||||
|
RemovedPaths []string `json:"removed_paths"`
|
||||||
|
Blocked []string `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefetchDuplicateGroup implements POST
|
||||||
|
// /api/admin/library/duplicates/{id}/refetch (#6184): settle a group of one
|
||||||
|
// recording in several slots of an album the way the resolver would, with the
|
||||||
|
// operator's answer. The copies in slots that are not the recording's are
|
||||||
|
// removed and marked missing, the release they came from is set aside, and the
|
||||||
|
// album is wanted again. Merge is the wrong action there: it keeps one title
|
||||||
|
// whether or not it is the audio's, and fetches nothing.
|
||||||
|
//
|
||||||
|
// Errors:
|
||||||
|
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||||
|
// removed (fileRemoveAPIError)
|
||||||
|
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||||
|
// - 400 not_wrong_slot when the group is not one recording in several slots
|
||||||
|
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||||
|
func (h *handlers) handleRefetchDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body refetchDuplicateRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var keepID pgtype.UUID // invalid: every copy goes
|
||||||
|
if body.KeepTrackID != "" {
|
||||||
|
if keepID, ok = parseUUID(body.KeepTrackID); !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := library.SettleWrongSlot(r.Context(), h.pool, h.logger, groupID, keepID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, library.ErrDuplicateGroupNotPending):
|
||||||
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
|
case errors.Is(err, library.ErrNotWrongSlot):
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "not_wrong_slot")
|
||||||
|
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: refetch duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := refetchDuplicateResponse{RemovedPaths: make([]string, 0, len(res.Removed)), Blocked: res.Blocked}
|
||||||
|
if resp.Blocked == nil {
|
||||||
|
resp.Blocked = []string{}
|
||||||
|
}
|
||||||
|
if res.Kept != nil {
|
||||||
|
id := uuidToString(res.Kept.TrackID)
|
||||||
|
resp.KeptTrackID = &id
|
||||||
|
}
|
||||||
|
for _, c := range res.Removed {
|
||||||
|
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,27 +65,27 @@ func TestFoldDuplicateGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// M498: the proposal shown beside a group follows the resolver's rule. The copy
|
// M498: the proposal shown beside a group follows the resolver's rule. The copy
|
||||||
// Lidarr tracks is proposed even when the other is lossless, because removing
|
// whose tags fit the album is proposed even when the other is larger (only a
|
||||||
// the tracked one would make Lidarr download it again.
|
// lossless copy outranks it).
|
||||||
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
|
func TestFoldDuplicateGroups_ProposesTheCopyWhoseTagsFit(t *testing.T) {
|
||||||
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
tracked, unmapped := "tracked", "unmapped"
|
|
||||||
class := "same_release"
|
class := "same_release"
|
||||||
|
three := int32(3)
|
||||||
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
||||||
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
|
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
|
||||||
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
|
Title: "Song", FileFormat: "m4a", FileSize: 30_000_000, AddedAt: dupTS(at)},
|
||||||
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
|
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
|
||||||
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
|
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), TrackNumber: &three, HasMbid: true},
|
||||||
}
|
}
|
||||||
got := foldDuplicateGroups(rows)
|
got := foldDuplicateGroups(rows)
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("folded %d groups, want 1", len(got))
|
t.Fatalf("folded %d groups, want 1", len(got))
|
||||||
}
|
}
|
||||||
g := got[0]
|
g := got[0]
|
||||||
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
|
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "tags fit the album" {
|
||||||
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
|
t.Errorf("survivor = (%s, %q), want the tagged mp3", g.SurvivorTrackID, g.SurvivorReason)
|
||||||
}
|
}
|
||||||
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
|
if g.Class == nil || *g.Class != "same_release" {
|
||||||
t.Errorf("class or lidarr state not carried: %+v", g)
|
t.Errorf("class not carried: %+v", g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Downloads and their imports (M502 #5522): the Wanted view's Activity tab,
|
||||||
|
// a person's "import anyway", "refuse" and "retry", and the Blocked tab.
|
||||||
|
|
||||||
|
// downloadView is one grab and what its import made of it.
|
||||||
|
type downloadView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WantID *string `json:"want_id"`
|
||||||
|
WantTitle string `json:"want_title"`
|
||||||
|
WantArtist string `json:"want_artist"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
Client string `json:"client"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
GrabbedAt string `json:"grabbed_at"`
|
||||||
|
EndedAt *string `json:"ended_at"`
|
||||||
|
EndReason string `json:"end_reason"`
|
||||||
|
Progress float32 `json:"progress"`
|
||||||
|
ImportState string `json:"import_state"`
|
||||||
|
ImportReason string `json:"import_reason"`
|
||||||
|
ImportedAt *string `json:"imported_at"`
|
||||||
|
ImportedFiles int32 `json:"imported_files"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Edition string `json:"edition,omitempty"`
|
||||||
|
Evidence *importer.Evidence `json:"evidence,omitempty"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
RehomedFrom string `json:"rehomed_from,omitempty"`
|
||||||
|
// EndDetail says why an ended download ended (#5523).
|
||||||
|
EndDetail string `json:"end_detail"`
|
||||||
|
// Seeding: imported and still sharing; Ratio its share ratio as last
|
||||||
|
// read; SeedReleasedAt when it was removed once it had shared enough.
|
||||||
|
Seeding bool `json:"seeding"`
|
||||||
|
Ratio float32 `json:"ratio"`
|
||||||
|
SeedReleasedAt *string `json:"seed_released_at"`
|
||||||
|
// Condition is what is wrong with it now, if anything.
|
||||||
|
Condition *conditionView `json:"condition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditionView is an open row of the intervention ledger: what was seen,
|
||||||
|
// since when, and how many times it was retried.
|
||||||
|
type conditionView struct {
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Retries int32 `json:"retries"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFrom(g dbq.ListGrabsRow) downloadView {
|
||||||
|
v := downloadView{
|
||||||
|
ID: uuidToString(g.ID), Name: g.Name, Indexer: g.Indexer, Client: g.Client, Quality: g.Quality,
|
||||||
|
Origin: g.Origin, GrabbedAt: formatTimestamp(g.GrabbedAt), EndReason: g.EndReason,
|
||||||
|
Progress: g.Progress, ImportState: g.ImportState, ImportReason: g.ImportReason,
|
||||||
|
ImportedFiles: g.ImportedFiles, Destination: g.Destination, Files: []string{},
|
||||||
|
EndDetail: g.EndDetail, Seeding: g.Seeding, Ratio: g.Ratio,
|
||||||
|
}
|
||||||
|
if g.SeedReleasedAt.Valid {
|
||||||
|
at := formatTimestamp(g.SeedReleasedAt)
|
||||||
|
v.SeedReleasedAt = &at
|
||||||
|
}
|
||||||
|
if g.Condition != nil {
|
||||||
|
v.Condition = &conditionView{Trigger: *g.Condition, Detail: deref(g.ConditionDetail),
|
||||||
|
Since: formatTimestamp(g.ConditionSince)}
|
||||||
|
if g.ConditionRetries != nil {
|
||||||
|
v.Condition.Retries = *g.ConditionRetries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.WantID.Valid {
|
||||||
|
id := uuidToString(g.WantID)
|
||||||
|
v.WantID = &id
|
||||||
|
}
|
||||||
|
if g.WantTitle != nil {
|
||||||
|
v.WantTitle = *g.WantTitle
|
||||||
|
}
|
||||||
|
if g.WantArtist != nil {
|
||||||
|
v.WantArtist = *g.WantArtist
|
||||||
|
}
|
||||||
|
if g.EndedAt.Valid {
|
||||||
|
at := formatTimestamp(g.EndedAt)
|
||||||
|
v.EndedAt = &at
|
||||||
|
}
|
||||||
|
if g.ImportedAt.Valid {
|
||||||
|
at := formatTimestamp(g.ImportedAt)
|
||||||
|
v.ImportedAt = &at
|
||||||
|
}
|
||||||
|
var det importer.Detail
|
||||||
|
if len(g.ImportDetail) > 0 && json.Unmarshal(g.ImportDetail, &det) == nil && det.Verdict.Outcome != "" {
|
||||||
|
ev := det.Verdict.Evidence
|
||||||
|
v.Evidence, v.Edition, v.Skipped, v.RehomedFrom = &ev, det.Edition, det.Skipped, det.RehomedFrom
|
||||||
|
if det.Files != nil {
|
||||||
|
v.Files = det.Files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadsPage struct {
|
||||||
|
Items []downloadView `json:"items"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
// ClientsDown are the download clients not answering now (#5523).
|
||||||
|
ClientsDown []clientDownView `json:"clients_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientDownView struct {
|
||||||
|
Client string `json:"client"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListDownloads implements GET /api/admin/downloads?offset=: running
|
||||||
|
// downloads first, then the rest newest first.
|
||||||
|
func (h *handlers) handleListDownloads(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: int32(limit), Skip: int32(offset)})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin downloads: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
down, err := dbq.New(h.pool).ListClientsDown(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin downloads: clients down", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset,
|
||||||
|
ClientsDown: make([]clientDownView, 0, len(down))}
|
||||||
|
for _, g := range rows {
|
||||||
|
out.Items = append(out.Items, downloadFrom(g))
|
||||||
|
}
|
||||||
|
for _, c := range down {
|
||||||
|
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
|
||||||
|
Since: formatTimestamp(c.FirstSeen)})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbandonDownload implements POST /api/admin/downloads/{id}/abandon: a
|
||||||
|
// running download is given up, its release set aside, the download removed
|
||||||
|
// and the album searched again.
|
||||||
|
func (h *handlers) handleAbandonDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.lifecycle.Abandon(r.Context(), id, admin.ID); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, lifecycle.ErrNotAbandonable):
|
||||||
|
writeErr(w, apierror.Conflict("not_running", "that download is no longer running"))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin downloads: abandon", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleImportAnyway implements POST /api/admin/downloads/{id}/import: a
|
||||||
|
// download the check held for a person (or one that failed) is imported on
|
||||||
|
// the next pass whatever the check says.
|
||||||
|
func (h *handlers) handleImportAnyway(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(h.pool).ForceGrabImport(r.Context(), id); err != nil {
|
||||||
|
h.writeImportErr(w, "import anyway", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRetryImport implements POST /api/admin/downloads/{id}/retry.
|
||||||
|
func (h *handlers) handleRetryImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(h.pool).RetryGrabImport(r.Context(), id); err != nil {
|
||||||
|
h.writeImportErr(w, "retry", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefuseDownload implements POST /api/admin/downloads/{id}/refuse: the
|
||||||
|
// release is blocked, the download removed and the album searched again.
|
||||||
|
func (h *handlers) handleRefuseDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.importer.Refuse(r.Context(), id, admin.ID); err != nil {
|
||||||
|
h.writeImportErr(w, "refuse", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) writeImportErr(w http.ResponseWriter, what string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, importer.ErrNotDecidable):
|
||||||
|
writeErr(w, apierror.Conflict("not_decidable", "that download is not waiting on a decision"))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin downloads: "+what, apierror.Internal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseBlockView struct {
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
BlockedAt string `json:"blocked_at"`
|
||||||
|
// ExpiresAt is when a release given up on may be taken again; null is
|
||||||
|
// for good (#5523).
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
WantTitle string `json:"want_title"`
|
||||||
|
WantArtist string `json:"want_artist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseBlocksPage struct {
|
||||||
|
Items []releaseBlockView `json:"items"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListReleaseBlocks implements GET /api/admin/release-blocks?offset=:
|
||||||
|
// every release set aside, by a person or by a check, newest first.
|
||||||
|
func (h *handlers) handleListReleaseBlocks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListReleaseBlocksPage(r.Context(), dbq.ListReleaseBlocksPageParams{
|
||||||
|
MaxRows: int32(limit), Skip: int32(offset)})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin release blocks: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := releaseBlocksPage{Items: make([]releaseBlockView, 0, len(rows)), Limit: limit, Offset: offset}
|
||||||
|
for _, b := range rows {
|
||||||
|
v := releaseBlockView{InfoHash: b.InfoHash, Name: b.Name, Reason: b.Reason, Detail: b.Detail,
|
||||||
|
BlockedAt: formatTimestamp(b.BlockedAt)}
|
||||||
|
if b.ExpiresAt.Valid {
|
||||||
|
at := formatTimestamp(b.ExpiresAt)
|
||||||
|
v.ExpiresAt = &at
|
||||||
|
}
|
||||||
|
if b.WantTitle != nil {
|
||||||
|
v.WantTitle = *b.WantTitle
|
||||||
|
}
|
||||||
|
if b.WantArtist != nil {
|
||||||
|
v.WantArtist = *b.WantArtist
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The library's folders (#6129): where the scan, the watcher and the import
|
||||||
|
// look for music, set here rather than only by MINSTREL_LIBRARY_SCAN_PATHS.
|
||||||
|
|
||||||
|
type libraryFolderView struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Present is whether the server can see the folder right now; a folder
|
||||||
|
// whose mount went away reads false here before anything else notices.
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Tracks int64 `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type libraryFoldersView struct {
|
||||||
|
Folders []libraryFolderView `json:"folders"`
|
||||||
|
// Saved is false while the folders still come from
|
||||||
|
// MINSTREL_LIBRARY_SCAN_PATHS.
|
||||||
|
Saved bool `json:"saved"`
|
||||||
|
// Moved is how many tracks a save moved to a new folder.
|
||||||
|
Moved int64 `json:"moved,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) libraryFolders(ctx context.Context) (libraryFoldersView, error) {
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
row, err := q.GetLibrarySettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return libraryFoldersView{}, err
|
||||||
|
}
|
||||||
|
v := libraryFoldersView{Folders: []libraryFolderView{}, Saved: len(row.ScanPaths) > 0}
|
||||||
|
for _, root := range h.scanner.Roots() {
|
||||||
|
n, err := q.CountTracksUnder(ctx, root)
|
||||||
|
if err != nil {
|
||||||
|
return libraryFoldersView{}, err
|
||||||
|
}
|
||||||
|
info, statErr := os.Stat(root)
|
||||||
|
v.Folders = append(v.Folders, libraryFolderView{Path: root, Present: statErr == nil && info.IsDir(), Tracks: n})
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetLibraryFolders implements GET /api/admin/library/folders.
|
||||||
|
func (h *handlers) handleGetLibraryFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, err := h.libraryFolders(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type putLibraryFoldersBody struct {
|
||||||
|
Folders []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Was is the folder this one replaces, whose files are now at Path;
|
||||||
|
// "" is a folder added.
|
||||||
|
Was string `json:"was"`
|
||||||
|
} `json:"folders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutLibraryFolders implements PUT /api/admin/library/folders.
|
||||||
|
//
|
||||||
|
// The whole list. A folder whose path changed moves its tracks with it, so
|
||||||
|
// their plays, likes and playlists survive; one left out is no longer read,
|
||||||
|
// and its tracks are marked missing by the next scan. After a save a scan
|
||||||
|
// starts, to pick up anything the old folders did not hold.
|
||||||
|
func (h *handlers) handlePutLibraryFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body putLibraryFoldersBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.scanner == nil {
|
||||||
|
writeErr(w, apierror.Internal(errors.New("no scanner")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changes := make([]library.RootChange, 0, len(body.Folders))
|
||||||
|
for _, f := range body.Folders {
|
||||||
|
changes = append(changes, library.RootChange{Path: f.Path, Was: f.Was})
|
||||||
|
}
|
||||||
|
res, err := h.scanner.SetRoots(r.Context(), changes)
|
||||||
|
if err != nil {
|
||||||
|
var re *library.RootsError
|
||||||
|
if errors.As(err, &re) {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_folders", re.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin library folders: put", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, _, err := library.TryStartScan(context.Background(), h.pool, h.scanner, h.coverart,
|
||||||
|
h.logger.With("source", "library_folders"), h.scanCfg); err != nil {
|
||||||
|
h.logger.Warn("admin library folders: scan after save", "err", err)
|
||||||
|
}
|
||||||
|
v, err := h.libraryFolders(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.Moved = res.Tracks
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// missingTrackView is one track whose file the scan could not find.
|
// missingTrackView is one track whose file the scan could not find.
|
||||||
@@ -30,19 +29,16 @@ type missingTrackView struct {
|
|||||||
LastPlayedAt *string `json:"last_played_at"`
|
LastPlayedAt *string `json:"last_played_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// reacquisitionStateView is what the sweeper has done about one album
|
// reacquisitionStateView is what the wanted list says about one album
|
||||||
// (milestone #290), attached to the group so the operator can tell "nothing
|
// (milestone #290, M502 #5520): when it was wanted back, and when the library
|
||||||
// has happened yet" from "asked twice, still nothing" without cross-checking
|
// held it again. A missing album with no want yet is still inside the grace
|
||||||
// the Requests queue.
|
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
|
||||||
//
|
// sends it, so the Missing page can show its search and download and act on
|
||||||
// NextAttemptAt is computed rather than stored: the schedule is a function of
|
// it (M504).
|
||||||
// the attempt count and the current settings, so persisting it would go stale
|
|
||||||
// the moment an operator edited the backoff.
|
|
||||||
type reacquisitionStateView struct {
|
type reacquisitionStateView struct {
|
||||||
Attempts int `json:"attempts"`
|
WantedAt *string `json:"wanted_at"`
|
||||||
LastAttemptAt *string `json:"last_attempt_at"`
|
HeldAt *string `json:"held_at"`
|
||||||
NextAttemptAt *string `json:"next_attempt_at"`
|
Want *wantView `json:"want"`
|
||||||
GaveUpAt *string `json:"gave_up_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// missingGroupView is a directory's worth of missing tracks.
|
// missingGroupView is a directory's worth of missing tracks.
|
||||||
@@ -159,15 +155,14 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachReacquisitionState decorates each group with what the sweeper has
|
// attachReacquisitionState decorates each group with its album's want.
|
||||||
// done about its album (milestone #290).
|
|
||||||
//
|
//
|
||||||
// Best-effort: this is context on a list whose primary job is showing what is
|
// Best-effort: this is context on a list whose primary job is showing what is
|
||||||
// missing, so a failure here leaves the groups bare rather than failing the
|
// missing, so a failure here leaves the groups bare rather than failing the
|
||||||
// page. One batched query for the whole page, not one per group.
|
// page. One batched query for the whole page, not one per group.
|
||||||
//
|
//
|
||||||
// A group is keyed by directory while re-acquisition is keyed by album, and
|
// A group is keyed by directory while a want is keyed by album, and those
|
||||||
// those line up in practice (an album's files live in one folder) but are not
|
// line up in practice (an album's files live in one folder) but are not
|
||||||
// guaranteed to — a directory holding two albums takes the first album's
|
// guaranteed to — a directory holding two albums takes the first album's
|
||||||
// state, which is the same album its first track belongs to.
|
// state, which is the same album its first track belongs to.
|
||||||
func (h *handlers) attachReacquisitionState(
|
func (h *handlers) attachReacquisitionState(
|
||||||
@@ -183,7 +178,13 @@ func (h *handlers) attachReacquisitionState(
|
|||||||
// groupMissingByDirectory folded them in.
|
// groupMissingByDirectory folded them in.
|
||||||
dirAlbum := make(map[string]pgtype.UUID, len(groups))
|
dirAlbum := make(map[string]pgtype.UUID, len(groups))
|
||||||
ids := make([]pgtype.UUID, 0, len(groups))
|
ids := make([]pgtype.UUID, 0, len(groups))
|
||||||
|
// The latest loss in each folder: a want held before it is the album
|
||||||
|
// as it was before its files went, not the library holding it again.
|
||||||
|
dirLost := make(map[string]time.Time, len(groups))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
|
if row.MissingSince.Valid && row.MissingSince.Time.After(dirLost[row.Directory]) {
|
||||||
|
dirLost[row.Directory] = row.MissingSince.Time
|
||||||
|
}
|
||||||
if _, seen := dirAlbum[row.Directory]; seen {
|
if _, seen := dirAlbum[row.Directory]; seen {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -191,60 +192,71 @@ func (h *handlers) attachReacquisitionState(
|
|||||||
ids = append(ids, row.AlbumID)
|
ids = append(ids, row.AlbumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
states, err := q.GetReacquisitionForAlbums(r.Context(), ids)
|
wants, err := q.GetWantsForAlbums(r.Context(), ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err)
|
h.logger.Warn("admin missing: want lookup failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
byAlbum := make(map[string]dbq.MissingReacquisition, len(states))
|
// Newest want first, so the first seen per album is its current one.
|
||||||
for _, s := range states {
|
byAlbum := make(map[string]dbq.GetWantsForAlbumsRow, len(wants))
|
||||||
byAlbum[uuidToString(s.AlbumID)] = s
|
for _, w := range wants {
|
||||||
}
|
if _, seen := byAlbum[uuidToString(w.AlbumID)]; !seen {
|
||||||
|
byAlbum[uuidToString(w.AlbumID)] = w
|
||||||
// The settings service is optional in contexts that only wire routing
|
}
|
||||||
// (tests), and the backoff projection is decoration on a list whose real
|
|
||||||
// job is elsewhere — so fall back to the shipped defaults rather than
|
|
||||||
// making this a nil-pointer waiting to happen (rule #48).
|
|
||||||
cfg := reacquisition.Defaults
|
|
||||||
if h.reacqSettings != nil {
|
|
||||||
cfg = h.reacqSettings.Get()
|
|
||||||
}
|
}
|
||||||
|
full := h.wantViewsByID(r, q, byAlbum)
|
||||||
for i := range groups {
|
for i := range groups {
|
||||||
albumID, ok := dirAlbum[groups[i].Directory]
|
albumID, ok := dirAlbum[groups[i].Directory]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
state, ok := byAlbum[uuidToString(albumID)]
|
w, ok := byAlbum[uuidToString(albumID)]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts))
|
// Held before its files went (every library album is on the list,
|
||||||
|
// held, since #5567): not wanted back yet, which the sweep does once
|
||||||
|
// the grace window has passed.
|
||||||
|
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view := &reacquisitionStateView{}
|
||||||
|
wantedAt := formatTimestamp(w.WantedAt)
|
||||||
|
view.WantedAt = &wantedAt
|
||||||
|
if w.FulfilledAt.Valid {
|
||||||
|
heldAt := formatTimestamp(w.FulfilledAt)
|
||||||
|
view.HeldAt = &heldAt
|
||||||
|
}
|
||||||
|
if v, ok := full[uuidToString(w.WantID)]; ok {
|
||||||
|
view.Want = &v
|
||||||
|
}
|
||||||
|
groups[i].Reacquisition = view
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildReacquisitionState renders one album's attempt record, projecting the
|
// wantViewsByID reads each album's current want in full, keyed by want id.
|
||||||
// next attempt from the last one plus the backoff the current settings imply.
|
// Best-effort like the state it decorates: a failure leaves the groups with
|
||||||
func buildReacquisitionState(
|
// their dates and no want.
|
||||||
state dbq.MissingReacquisition,
|
func (h *handlers) wantViewsByID(
|
||||||
backoff time.Duration,
|
r *http.Request,
|
||||||
) *reacquisitionStateView {
|
q *dbq.Queries,
|
||||||
out := &reacquisitionStateView{Attempts: int(state.Attempts)}
|
byAlbum map[string]dbq.GetWantsForAlbumsRow,
|
||||||
if state.LastAttemptAt.Valid {
|
) map[string]wantView {
|
||||||
s := formatTimestamp(state.LastAttemptAt)
|
ids := make([]pgtype.UUID, 0, len(byAlbum))
|
||||||
out.LastAttemptAt = &s
|
for _, w := range byAlbum {
|
||||||
// Only meaningful while more attempts remain; an album that has given
|
ids = append(ids, w.WantID)
|
||||||
// up has no next attempt to promise.
|
|
||||||
if !state.GaveUpAt.Valid {
|
|
||||||
next := formatTimestamp(pgtype.Timestamptz{
|
|
||||||
Time: state.LastAttemptAt.Time.Add(backoff),
|
|
||||||
Valid: true,
|
|
||||||
})
|
|
||||||
out.NextAttemptAt = &next
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if state.GaveUpAt.Valid {
|
out := make(map[string]wantView, len(ids))
|
||||||
s := formatTimestamp(state.GaveUpAt)
|
if len(ids) == 0 {
|
||||||
out.GaveUpAt = &s
|
return out
|
||||||
|
}
|
||||||
|
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: "all", WantIds: ids, MaxRows: int32(len(ids))})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("admin missing: want detail lookup failed", "err", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
out[uuidToString(row.ID)] = wantViewOf(row)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedMissingWant marks a track's file missing for two days and puts its
|
||||||
|
// album on the wanted list, held at heldDaysAgo (0 for an open want) and
|
||||||
|
// last searched with nothing found.
|
||||||
|
func seedMissingWant(t *testing.T, h *handlers, trackID, albumID pgtype.UUID, group string, heldDaysAgo int) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := h.pool.Exec(ctx,
|
||||||
|
`UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1`, trackID); err != nil {
|
||||||
|
t.Fatalf("mark missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := h.pool.Exec(ctx, `DELETE FROM wants WHERE release_group_mbid = $1`, group); err != nil {
|
||||||
|
t.Fatalf("clear want: %v", err)
|
||||||
|
}
|
||||||
|
var id pgtype.UUID
|
||||||
|
if err := h.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id,
|
||||||
|
wanted_at, fulfilled_at, searched_at, search_outcome)
|
||||||
|
VALUES ($1, 'artist-'||$1, 'Artist', 'Album '||$1, 'library', $2,
|
||||||
|
now() - interval '30 days',
|
||||||
|
CASE WHEN $3::int > 0 THEN now() - make_interval(days => $3::int) END,
|
||||||
|
now() - interval '1 hour', 'nothing')
|
||||||
|
RETURNING id`, group, albumID, heldDaysAgo).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("insert want: %v", err)
|
||||||
|
}
|
||||||
|
return uuidToString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder's album that was held before its files went is not wanted back
|
||||||
|
// yet (every library album is on the list, held, since #5567); an open want
|
||||||
|
// comes with its search, so the Missing page can show it and act on it (M504).
|
||||||
|
func TestListMissingTracks_WantState(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
|
|
||||||
|
heldTrack, _ := seedTrackOnDisk(t, h, "missheld")
|
||||||
|
openTrack, _ := seedTrackOnDisk(t, h, "missopen")
|
||||||
|
seedMissingWant(t, h, heldTrack.ID, heldTrack.AlbumID, "rg-missing-held", 10)
|
||||||
|
openID := seedMissingWant(t, h, openTrack.ID, openTrack.AlbumID, "rg-missing-open", 0)
|
||||||
|
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/library/missing", nil), admin)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListMissingTracks(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp adminMissingResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Groups) != 2 {
|
||||||
|
t.Fatalf("groups = %d, want 2", len(resp.Groups))
|
||||||
|
}
|
||||||
|
byTitle := map[string]missingGroupView{}
|
||||||
|
for _, g := range resp.Groups {
|
||||||
|
byTitle[g.Tracks[0].Title] = g
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := byTitle["AdmQ Track missheld"].Reacquisition; r != nil {
|
||||||
|
t.Errorf("held before the loss: reacquisition = %+v, want none", r)
|
||||||
|
}
|
||||||
|
r := byTitle["AdmQ Track missopen"].Reacquisition
|
||||||
|
if r == nil || r.WantedAt == nil || r.HeldAt != nil {
|
||||||
|
t.Fatalf("open want: reacquisition = %+v, want wanted and not held", r)
|
||||||
|
}
|
||||||
|
if r.Want == nil || r.Want.ID != openID {
|
||||||
|
t.Fatalf("open want: want = %+v, want id %s", r.Want, openID)
|
||||||
|
}
|
||||||
|
if r.Want.Search == nil || r.Want.Search.Outcome != "nothing" {
|
||||||
|
t.Errorf("open want: search = %+v, want outcome nothing", r.Want.Search)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
|
|
||||||
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
|
|
||||||
type lidarrConfigView struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
DefaultQualityProfileID int `json:"default_quality_profile_id"`
|
|
||||||
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
|
|
||||||
DefaultRootFolderPath string `json:"default_root_folder_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskAPIKey converts an api_key for external response: non-empty keys become
|
|
||||||
// "***"; empty (never set) keys remain "".
|
|
||||||
func maskAPIKey(key string) string {
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "***"
|
|
||||||
}
|
|
||||||
|
|
||||||
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
|
|
||||||
// api_key masked.
|
|
||||||
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
|
|
||||||
return lidarrConfigView{
|
|
||||||
Enabled: cfg.Enabled,
|
|
||||||
BaseURL: cfg.BaseURL,
|
|
||||||
APIKey: maskAPIKey(cfg.APIKey),
|
|
||||||
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
|
|
||||||
DefaultMetadataProfileID: cfg.DefaultMetadataProfileID,
|
|
||||||
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
|
|
||||||
// Returns the current Lidarr config with api_key masked.
|
|
||||||
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg, err := h.lidarrCfg.Get(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin: get lidarr config", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, configToView(cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
|
|
||||||
type putLidarrConfigBody struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
DefaultQualityProfileID int `json:"default_quality_profile_id"`
|
|
||||||
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
|
|
||||||
DefaultRootFolderPath string `json:"default_root_folder_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
|
|
||||||
// Empty api_key in the body preserves the currently saved api_key.
|
|
||||||
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var body putLidarrConfigBody
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the effective api_key: empty in body = preserve saved value.
|
|
||||||
apiKey := body.APIKey
|
|
||||||
if apiKey == "" {
|
|
||||||
saved, err := h.lidarrCfg.Get(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin: put lidarr config: load saved", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apiKey = saved.APIKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate: enabled=true requires base_url AND api_key.
|
|
||||||
if body.Enabled {
|
|
||||||
if body.BaseURL == "" || apiKey == "" {
|
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Validate that the URL parses.
|
|
||||||
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
|
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Defaults must be present so future Approve calls don't dispatch
|
|
||||||
// invalid POST bodies to Lidarr (which produces 4xx with field
|
|
||||||
// errors). The frontend pre-selects first values so this almost
|
|
||||||
// never fires for non-malicious traffic, but it's the right gate.
|
|
||||||
if body.DefaultQualityProfileID == 0 ||
|
|
||||||
body.DefaultMetadataProfileID == 0 ||
|
|
||||||
body.DefaultRootFolderPath == "" {
|
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := lidarrconfig.Config{
|
|
||||||
Enabled: body.Enabled,
|
|
||||||
BaseURL: body.BaseURL,
|
|
||||||
APIKey: apiKey,
|
|
||||||
DefaultQualityProfileID: body.DefaultQualityProfileID,
|
|
||||||
DefaultMetadataProfileID: body.DefaultMetadataProfileID,
|
|
||||||
DefaultRootFolderPath: body.DefaultRootFolderPath,
|
|
||||||
}
|
|
||||||
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
|
|
||||||
h.logger.Error("admin: put lidarr config: save", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, configToView(cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
|
|
||||||
type testLidarrBody struct {
|
|
||||||
BaseURL string `json:"base_url"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test.
|
|
||||||
// All fields except Ok are optional. On a failed connection (Ok=false), only
|
|
||||||
// Ok and Error are populated. On success (Ok=true), Version is populated;
|
|
||||||
// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr
|
|
||||||
// data and ListErrors maps any per-list fetch failures by list name. The
|
|
||||||
// integrations page consumes the lists to pre-fill its dropdowns on
|
|
||||||
// first-time setup, fixing the chicken-and-egg where the dropdowns
|
|
||||||
// previously gated on cfg.Enabled.
|
|
||||||
type testLidarrResponse struct {
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
Version string `json:"version,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"`
|
|
||||||
MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"`
|
|
||||||
RootFolders []rootFolderView `json:"root_folders,omitempty"`
|
|
||||||
ListErrors map[string]string `json:"list_errors,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
|
|
||||||
// Always returns 200; the ok/error fields in the response body indicate
|
|
||||||
// connection success or failure.
|
|
||||||
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var body testLidarrBody
|
|
||||||
// Ignore decode errors — an empty body is valid (all fields optional).
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
||||||
|
|
||||||
// Fall back to saved config for any empty field.
|
|
||||||
baseURL := body.BaseURL
|
|
||||||
apiKey := body.APIKey
|
|
||||||
if baseURL == "" || apiKey == "" {
|
|
||||||
saved, err := h.lidarrCfg.Get(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin: test lidarr: load config", "err", err)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = saved.BaseURL
|
|
||||||
}
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = saved.APIKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client := lidarr.NewClient(baseURL, apiKey)
|
|
||||||
result, err := client.Ping(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
errCode := lidarrErrCode(err)
|
|
||||||
// Warn (not Error): a failed connection test is expected when the
|
|
||||||
// admin is configuring the integration and may have a typo'd URL
|
|
||||||
// or wrong key. Without this log line the underlying *net.DNSError
|
|
||||||
// / *net.OpError / TLS error is invisible to operators — they only
|
|
||||||
// see the bucket code in the UI. base_url is safe to log; api_key
|
|
||||||
// must NEVER be logged.
|
|
||||||
h.logger.Warn("admin: test lidarr ping failed",
|
|
||||||
"err", err, "base_url", baseURL, "code", errCode)
|
|
||||||
writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client)
|
|
||||||
writeJSON(w, http.StatusOK, testLidarrResponse{
|
|
||||||
Ok: true,
|
|
||||||
Version: result.Version,
|
|
||||||
QualityProfiles: quality,
|
|
||||||
MetadataProfiles: metadata,
|
|
||||||
RootFolders: folders,
|
|
||||||
ListErrors: listErrs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchLidarrLists fans out the three list calls in parallel and returns
|
|
||||||
// the lists plus a map of any per-list errors. Empty map (returned as nil
|
|
||||||
// to omit the JSON field) if all succeeded. Used by the test handler to
|
|
||||||
// pre-populate the integrations page dropdowns on the same round-trip as
|
|
||||||
// the connection check.
|
|
||||||
func fetchLidarrLists(ctx context.Context, client *lidarr.Client) (
|
|
||||||
[]qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string,
|
|
||||||
) {
|
|
||||||
var (
|
|
||||||
quality []qualityProfileView
|
|
||||||
metadata []metadataProfileView
|
|
||||||
folders []rootFolderView
|
|
||||||
errMu sync.Mutex
|
|
||||||
errs = map[string]string{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
)
|
|
||||||
recordErr := func(name string, err error) {
|
|
||||||
errMu.Lock()
|
|
||||||
errs[name] = lidarrErrCode(err)
|
|
||||||
errMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(3)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
ps, err := client.ListQualityProfiles(ctx)
|
|
||||||
if err != nil {
|
|
||||||
recordErr("quality_profiles", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
quality = make([]qualityProfileView, len(ps))
|
|
||||||
for i, p := range ps {
|
|
||||||
quality[i] = qualityProfileView{ID: p.ID, Name: p.Name}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
ps, err := client.ListMetadataProfiles(ctx)
|
|
||||||
if err != nil {
|
|
||||||
recordErr("metadata_profiles", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
metadata = make([]metadataProfileView, len(ps))
|
|
||||||
for i, p := range ps {
|
|
||||||
metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
fs, err := client.ListRootFolders(ctx)
|
|
||||||
if err != nil {
|
|
||||||
recordErr("root_folders", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
folders = make([]rootFolderView, len(fs))
|
|
||||||
for i, f := range fs {
|
|
||||||
folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if len(errs) == 0 {
|
|
||||||
return quality, metadata, folders, nil
|
|
||||||
}
|
|
||||||
return quality, metadata, folders, errs
|
|
||||||
}
|
|
||||||
|
|
||||||
// lidarrErrCode maps a lidarr client error to the stable string code used in
|
|
||||||
// admin API responses.
|
|
||||||
func lidarrErrCode(err error) string {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, lidarr.ErrUnreachable):
|
|
||||||
return "lidarr_unreachable"
|
|
||||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
|
||||||
return "lidarr_auth_failed"
|
|
||||||
case errors.Is(err, lidarr.ErrLookupFailed):
|
|
||||||
return "lidarr_lookup_failed"
|
|
||||||
default:
|
|
||||||
return "internal_error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// qualityProfileView is the JSON shape for a single quality profile in the
|
|
||||||
// GET /api/admin/lidarr/quality-profiles response.
|
|
||||||
type qualityProfileView struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
|
|
||||||
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, client, ok := h.lidarrClientFromConfig(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles, err := client.ListQualityProfiles(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := make([]qualityProfileView, len(profiles))
|
|
||||||
for i, p := range profiles {
|
|
||||||
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// metadataProfileView is the JSON shape for a single metadata profile in the
|
|
||||||
// GET /api/admin/lidarr/metadata-profiles response.
|
|
||||||
type metadataProfileView struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles.
|
|
||||||
func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, client, ok := h.lidarrClientFromConfig(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles, err := client.ListMetadataProfiles(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := make([]metadataProfileView, len(profiles))
|
|
||||||
for i, p := range profiles {
|
|
||||||
out[i] = metadataProfileView{ID: p.ID, Name: p.Name}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rootFolderView is the JSON shape for a single root folder in the
|
|
||||||
// GET /api/admin/lidarr/root-folders response.
|
|
||||||
type rootFolderView struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Accessible bool `json:"accessible"`
|
|
||||||
FreeSpace int64 `json:"free_space"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
|
|
||||||
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, client, ok := h.lidarrClientFromConfig(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
folders, err := client.ListRootFolders(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out := make([]rootFolderView, len(folders))
|
|
||||||
for i, f := range folders {
|
|
||||||
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
|
|
||||||
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
|
|
||||||
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
|
|
||||||
// when a response has already been written.
|
|
||||||
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
|
|
||||||
cfg, err := h.lidarrCfg.Get(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("admin: lidarr proxy: load config", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
||||||
return lidarrconfig.Config{}, nil, false
|
|
||||||
}
|
|
||||||
if !cfg.Enabled {
|
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
|
||||||
return lidarrconfig.Config{}, nil, false
|
|
||||||
}
|
|
||||||
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
|
|
||||||
// mirrors the shape the RequireAdmin middleware uses and that the spec
|
|
||||||
// defines for /api/admin/* errors.
|
|
||||||
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
|
|
||||||
}
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newAdminLidarrRouter builds a test chi router with the five admin/lidarr
|
|
||||||
// endpoints. RequireAdmin middleware is applied. Tests inject a user into
|
|
||||||
// context directly (skipping RequireUser).
|
|
||||||
func newAdminLidarrRouter(h *handlers) chi.Router {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Route("/api/admin", func(admin chi.Router) {
|
|
||||||
admin.Use(auth.RequireAdmin())
|
|
||||||
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
|
|
||||||
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
|
|
||||||
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
|
|
||||||
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
|
|
||||||
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedAdminUser creates a test user with is_admin=true.
|
|
||||||
func seedAdminUser(t *testing.T, h *handlers) dbq.User {
|
|
||||||
t.Helper()
|
|
||||||
return seedUser(t, h.pool, "admin", "pw", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doAdminReq fires an HTTP request at the admin router with the given user
|
|
||||||
// injected into context (bypassing RequireUser).
|
|
||||||
func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
var reqBody *bytes.Buffer
|
|
||||||
if body != nil {
|
|
||||||
reqBody = bytes.NewBuffer(body)
|
|
||||||
} else {
|
|
||||||
reqBody = bytes.NewBuffer(nil)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(method, path, reqBody)
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
req = withUser(req, user)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
newAdminLidarrRouter(h).ServeHTTP(w, req)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveLidarrConfigFull writes a lidarr config with the given key directly
|
|
||||||
// (for admin tests that need precise api_key control).
|
|
||||||
func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) {
|
|
||||||
t.Helper()
|
|
||||||
svc := lidarrconfig.New(h.pool)
|
|
||||||
if err := svc.Save(context.Background(), cfg); err != nil {
|
|
||||||
t.Fatalf("saveLidarrConfigFull: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is
|
|
||||||
// returned as "***" and never in plaintext.
|
|
||||||
func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: "http://lidarr.lan:8686",
|
|
||||||
APIKey: "secret123",
|
|
||||||
})
|
|
||||||
|
|
||||||
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp lidarrConfigView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp.APIKey == "secret123" {
|
|
||||||
t.Error("api_key leaked in response; want ***")
|
|
||||||
}
|
|
||||||
if resp.APIKey != "***" {
|
|
||||||
t.Errorf("api_key = %q, want ***", resp.APIKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is
|
|
||||||
// unset in the DB, GET returns api_key="" (not "***").
|
|
||||||
func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
// Save config with empty api_key (null in DB via lidarrconfig.Service).
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: false,
|
|
||||||
BaseURL: "",
|
|
||||||
APIKey: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp lidarrConfigView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp.APIKey != "" {
|
|
||||||
t.Errorf("api_key = %q, want empty string", resp.APIKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with
|
|
||||||
// api_key="" does not overwrite the saved api_key.
|
|
||||||
func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
// Seed a config with a known api_key.
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: "http://lidarr.lan:8686",
|
|
||||||
APIKey: "originalkey",
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT with empty api_key — should preserve "originalkey". enabled=true
|
|
||||||
// requires the defaults gate (missing_defaults) to be satisfied.
|
|
||||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
|
|
||||||
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm the saved api_key is still "originalkey" by reading raw config.
|
|
||||||
svc := lidarrconfig.New(h.pool)
|
|
||||||
saved, err := svc.Get(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get saved config: %v", err)
|
|
||||||
}
|
|
||||||
if saved.APIKey != "originalkey" {
|
|
||||||
t.Errorf("saved api_key = %q, want originalkey", saved.APIKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling
|
|
||||||
// Lidarr without a base_url returns 400 with missing_required_field.
|
|
||||||
func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`)
|
|
||||||
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]string
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["error"] != "missing_required_field" {
|
|
||||||
t.Errorf("error = %q, want missing_required_field", resp["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with
|
|
||||||
// the updated config and the api_key masked.
|
|
||||||
func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
|
|
||||||
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp lidarrConfigView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if !resp.Enabled {
|
|
||||||
t.Error("enabled = false, want true")
|
|
||||||
}
|
|
||||||
if resp.BaseURL != "http://lidarr.lan:8686" {
|
|
||||||
t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL)
|
|
||||||
}
|
|
||||||
if resp.APIKey != "***" {
|
|
||||||
t.Errorf("api_key = %q, want ***", resp.APIKey)
|
|
||||||
}
|
|
||||||
if resp.DefaultQualityProfileID != 2 {
|
|
||||||
t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID)
|
|
||||||
}
|
|
||||||
if resp.DefaultRootFolderPath != "/music" {
|
|
||||||
t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a
|
|
||||||
// stub returning valid status JSON responds 200 with ok=true and the version.
|
|
||||||
func TestHandleTestLidarrConnection_HappyPath(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/system/status":
|
|
||||||
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
|
|
||||||
case "/api/v1/qualityprofile":
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`))
|
|
||||||
case "/api/v1/metadataprofile":
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
|
||||||
case "/api/v1/rootfolder":
|
|
||||||
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: stub.URL,
|
|
||||||
APIKey: "test-key",
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST with empty body — falls back to saved config.
|
|
||||||
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["ok"] != true {
|
|
||||||
t.Errorf("ok = %v, want true", resp["ok"])
|
|
||||||
}
|
|
||||||
if resp["version"] != "2.0.5" {
|
|
||||||
t.Errorf("version = %v, want 2.0.5", resp["version"])
|
|
||||||
}
|
|
||||||
qp, ok := resp["quality_profiles"].([]any)
|
|
||||||
if !ok || len(qp) != 2 {
|
|
||||||
t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"])
|
|
||||||
}
|
|
||||||
mp, ok := resp["metadata_profiles"].([]any)
|
|
||||||
if !ok || len(mp) != 1 {
|
|
||||||
t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"])
|
|
||||||
}
|
|
||||||
rf, ok := resp["root_folders"].([]any)
|
|
||||||
if !ok || len(rf) != 1 {
|
|
||||||
t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"])
|
|
||||||
}
|
|
||||||
if _, present := resp["list_errors"]; present {
|
|
||||||
t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test
|
|
||||||
// against a Lidarr that responds OK to the ping + two of three lists still
|
|
||||||
// reports ok=true with the failing list omitted and named in list_errors.
|
|
||||||
func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v1/system/status":
|
|
||||||
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
|
|
||||||
case "/api/v1/qualityprofile":
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`))
|
|
||||||
case "/api/v1/metadataprofile":
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
case "/api/v1/rootfolder":
|
|
||||||
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
|
|
||||||
body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`)
|
|
||||||
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["ok"] != true {
|
|
||||||
t.Errorf("ok = %v, want true", resp["ok"])
|
|
||||||
}
|
|
||||||
if _, present := resp["metadata_profiles"]; present {
|
|
||||||
t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"])
|
|
||||||
}
|
|
||||||
listErrs, ok := resp["list_errors"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"])
|
|
||||||
}
|
|
||||||
if _, present := listErrs["metadata_profiles"]; !present {
|
|
||||||
t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against
|
|
||||||
// an unreachable URL returns 200 with ok=false and lidarr_unreachable.
|
|
||||||
func TestHandleTestLidarrConnection_Unreachable(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
// Point at a URL that will immediately refuse connections.
|
|
||||||
body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`)
|
|
||||||
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["ok"] != false {
|
|
||||||
t.Errorf("ok = %v, want false", resp["ok"])
|
|
||||||
}
|
|
||||||
if resp["error"] != "lidarr_unreachable" {
|
|
||||||
t.Errorf("error = %v, want lidarr_unreachable", resp["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles
|
|
||||||
// proxies and normalizes the Lidarr response.
|
|
||||||
func TestHandleListQualityProfiles_HappyPath(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`)
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: stub.URL,
|
|
||||||
APIKey: "test-key",
|
|
||||||
})
|
|
||||||
|
|
||||||
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var profiles []qualityProfileView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(profiles) != 2 {
|
|
||||||
t.Fatalf("len(profiles) = %d, want 2", len(profiles))
|
|
||||||
}
|
|
||||||
if profiles[0].ID != 1 || profiles[0].Name != "Lossless" {
|
|
||||||
t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0])
|
|
||||||
}
|
|
||||||
if profiles[1].ID != 2 || profiles[1].Name != "Standard" {
|
|
||||||
t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies
|
|
||||||
// and normalizes the Lidarr response.
|
|
||||||
func TestHandleListRootFolders_HappyPath(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
admin := seedAdminUser(t, h)
|
|
||||||
|
|
||||||
stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`)
|
|
||||||
saveLidarrConfigFull(t, h, lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: stub.URL,
|
|
||||||
APIKey: "test-key",
|
|
||||||
})
|
|
||||||
|
|
||||||
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var folders []rootFolderView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(folders) != 1 {
|
|
||||||
t.Fatalf("len(folders) = %d, want 1", len(folders))
|
|
||||||
}
|
|
||||||
f := folders[0]
|
|
||||||
if f.Path != "/music" {
|
|
||||||
t.Errorf("path = %q, want /music", f.Path)
|
|
||||||
}
|
|
||||||
if !f.Accessible {
|
|
||||||
t.Error("accessible = false, want true")
|
|
||||||
}
|
|
||||||
if f.FreeSpace != 1234567890 {
|
|
||||||
t.Errorf("free_space = %d, want 1234567890", f.FreeSpace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every
|
|
||||||
// admin endpoint returns 403 with not_authorized for a non-admin user.
|
|
||||||
func TestAdminEndpoints_NonAdmin403(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body []byte
|
|
||||||
}{
|
|
||||||
{http.MethodGet, "/api/admin/lidarr/config", nil},
|
|
||||||
{http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)},
|
|
||||||
{http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)},
|
|
||||||
{http.MethodGet, "/api/admin/lidarr/quality-profiles", nil},
|
|
||||||
{http.MethodGet, "/api/admin/lidarr/root-folders", nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
||||||
w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]string
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["error"] != "not_authorized" {
|
|
||||||
t.Errorf("error = %q, want not_authorized", resp["error"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,22 +10,20 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminQueueRowView is a single aggregated row in the admin queue response.
|
// adminQueueRowView is a single aggregated row in the admin queue response.
|
||||||
type adminQueueRowView struct {
|
type adminQueueRowView struct {
|
||||||
TrackID string `json:"track_id"`
|
TrackID string `json:"track_id"`
|
||||||
TrackTitle string `json:"track_title"`
|
TrackTitle string `json:"track_title"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumTitle string `json:"album_title"`
|
AlbumTitle string `json:"album_title"`
|
||||||
AlbumID string `json:"album_id"`
|
AlbumID string `json:"album_id"`
|
||||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
ReportCount int32 `json:"report_count"`
|
||||||
ReportCount int32 `json:"report_count"`
|
LatestAt string `json:"latest_at"`
|
||||||
LatestAt string `json:"latest_at"`
|
ReasonCounts map[string]int `json:"reason_counts"`
|
||||||
ReasonCounts map[string]int `json:"reason_counts"`
|
Reports []adminQueueReportView `json:"reports"`
|
||||||
Reports []adminQueueReportView `json:"reports"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminQueueReportView is one user's report under an aggregated admin row.
|
// 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.
|
// handleListAdminQuarantine implements GET /api/admin/quarantine.
|
||||||
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
|
rows, err := h.quarantine.ListAdminQueue(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("admin: list quarantine", "err", err)
|
h.logger.Error("admin: list quarantine", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -66,27 +64,25 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
out = append(out, adminQueueRowView{
|
out = append(out, adminQueueRowView{
|
||||||
TrackID: uuidToString(row.TrackID),
|
TrackID: uuidToString(row.TrackID),
|
||||||
TrackTitle: row.TrackTitle,
|
TrackTitle: row.TrackTitle,
|
||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
LidarrAlbumMBID: row.LidarrAlbumMBID,
|
ReportCount: row.ReportCount,
|
||||||
ReportCount: row.ReportCount,
|
LatestAt: formatTimestamp(row.LatestAt),
|
||||||
LatestAt: formatTimestamp(row.LatestAt),
|
ReasonCounts: row.ReasonCounts,
|
||||||
ReasonCounts: row.ReasonCounts,
|
Reports: reports,
|
||||||
Reports: reports,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// actionResultView is the response shape for the three admin destructive
|
// actionResultView is the response shape for the admin actions (resolve,
|
||||||
// actions (resolve, delete-file, delete-via-lidarr).
|
// delete-file).
|
||||||
type actionResultView struct {
|
type actionResultView struct {
|
||||||
ActionID string `json:"action_id"`
|
ActionID string `json:"action_id"`
|
||||||
AffectedUsers int32 `json:"affected_users"`
|
AffectedUsers int32 `json:"affected_users"`
|
||||||
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
|
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
|
||||||
@@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
|
|||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
|
action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
|
if errors.Is(err, quarantine.ErrTrackNotFound) {
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
||||||
// message is the part that tells the operator which directory and uid.
|
// message is the part that tells the operator which directory and uid.
|
||||||
@@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
case errors.Is(err, quarantine.ErrTrackNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: delete file", "err", err)
|
h.logger.Error("admin: delete file", "err", err)
|
||||||
@@ -158,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.
|
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
|
||||||
type actionLogView struct {
|
type actionLogView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackID string `json:"track_id"`
|
TrackID string `json:"track_id"`
|
||||||
TrackTitle string `json:"track_title"`
|
TrackTitle string `json:"track_title"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumTitle *string `json:"album_title,omitempty"`
|
AlbumTitle *string `json:"album_title,omitempty"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
AdminID *string `json:"admin_id,omitempty"`
|
AdminID *string `json:"admin_id,omitempty"`
|
||||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
// AlbumMBID is set on the older deleted_album rows: the album removed.
|
||||||
AffectedUsers int32 `json:"affected_users"`
|
AlbumMBID *string `json:"album_mbid,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
AffectedUsers int32 `json:"affected_users"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
|
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
|
||||||
@@ -241,16 +196,16 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
|
|||||||
adminID = &s
|
adminID = &s
|
||||||
}
|
}
|
||||||
out = append(out, actionLogView{
|
out = append(out, actionLogView{
|
||||||
ID: uuidToString(row.ID),
|
ID: uuidToString(row.ID),
|
||||||
TrackID: uuidToString(row.TrackID),
|
TrackID: uuidToString(row.TrackID),
|
||||||
TrackTitle: row.TrackTitle,
|
TrackTitle: row.TrackTitle,
|
||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
Action: string(row.Action),
|
Action: string(row.Action),
|
||||||
AdminID: adminID,
|
AdminID: adminID,
|
||||||
LidarrAlbumMBID: row.LidarrAlbumMbid,
|
AlbumMBID: row.AlbumMbid,
|
||||||
AffectedUsers: row.AffectedUsers,
|
AffectedUsers: row.AffectedUsers,
|
||||||
CreatedAt: formatTimestamp(row.CreatedAt),
|
CreatedAt: formatTimestamp(row.CreatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newAdminQuarantineRouter builds a test chi router with the five admin
|
// newAdminQuarantineRouter builds a test chi router with the five admin
|
||||||
@@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router {
|
|||||||
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
||||||
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||||||
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
||||||
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
|
||||||
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
|
|
||||||
// with a real clientFn pointing at the (current) saved lidarr_config. This
|
|
||||||
// mirrors testHandlersWithClientFn for the requests test file.
|
|
||||||
func installQuarantineClientFn(t *testing.T, h *handlers) {
|
|
||||||
t.Helper()
|
|
||||||
cfg := lidarrconfig.New(h.pool)
|
|
||||||
clientFn := func() *lidarr.Client {
|
|
||||||
c, err := cfg.Get(context.Background())
|
|
||||||
if err != nil || !c.Enabled || c.BaseURL == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
|
||||||
}
|
|
||||||
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
||||||
// Service for setup. Used to construct admin queue scenarios.
|
// Service for setup. Used to construct admin queue scenarios.
|
||||||
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
|
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
|
if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
|
||||||
t.Fatalf("flagDirect: %v", err)
|
t.Fatalf("flagDirect: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
|
|||||||
return tr, path
|
return tr, path
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
|
|
||||||
// required for delete-via-lidarr scenarios.
|
|
||||||
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
|
|
||||||
t.Helper()
|
|
||||||
artist := seedArtist(t, h.pool, "DvL Artist "+unique)
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "track-"+unique+".mp3")
|
|
||||||
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write file: %v", err)
|
|
||||||
}
|
|
||||||
mbid := albumMBID
|
|
||||||
album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
||||||
Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
|
|
||||||
ArtistID: artist.ID, Mbid: &mbid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpsertAlbum: %v", err)
|
|
||||||
}
|
|
||||||
one := int32(1)
|
|
||||||
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
||||||
Title: "DvL Track " + unique,
|
|
||||||
AlbumID: album.ID,
|
|
||||||
ArtistID: artist.ID,
|
|
||||||
TrackNumber: &one,
|
|
||||||
DurationMs: 30000,
|
|
||||||
FilePath: path,
|
|
||||||
FileSize: 5,
|
|
||||||
FileFormat: "mp3",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpsertTrack: %v", err)
|
|
||||||
}
|
|
||||||
return tr, path
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
|
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
|
||||||
// track with mixed reasons. Verifies one aggregated row with report_count=3,
|
// track with mixed reasons. Verifies one aggregated row with report_count=3,
|
||||||
// reason_counts populated, and a Reports list of length 3.
|
// reason_counts populated, and a Reports list of length 3.
|
||||||
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
|
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
@@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
|
|||||||
func TestResolveQuarantine_HappyPath(t *testing.T) {
|
func TestResolveQuarantine_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
|
|||||||
if len(actions) != 1 {
|
if len(actions) != 1 {
|
||||||
t.Fatalf("audit rows = %d, want 1", len(actions))
|
t.Fatalf("audit rows = %d, want 1", len(actions))
|
||||||
}
|
}
|
||||||
if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
|
if actions[0].Action != dbq.QuarantineActionKindResolved {
|
||||||
t.Errorf("audit action = %v, want resolved", actions[0].Action)
|
t.Errorf("audit action = %v, want resolved", actions[0].Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
|
|||||||
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
|
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
@@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
|
|
||||||
// a single album for the lookup and 200 for the delete. Verifies the
|
|
||||||
// audit row is written and the local track row is deleted.
|
|
||||||
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
truncateLibrary(t, pool)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
|
|
||||||
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
|
|
||||||
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
|
|
||||||
|
|
||||||
w := doAdminQuarantineReq(t, h, http.MethodPost,
|
|
||||||
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var got actionResultView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if got.AffectedUsers != 1 {
|
|
||||||
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
|
|
||||||
}
|
|
||||||
if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
|
|
||||||
t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track row should be gone.
|
|
||||||
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
|
|
||||||
t.Error("track row still exists after delete-via-lidarr")
|
|
||||||
}
|
|
||||||
// Audit row written with the lidarr_album_mbid.
|
|
||||||
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list actions: %v", err)
|
|
||||||
}
|
|
||||||
if len(actions) != 1 {
|
|
||||||
t.Fatalf("audit rows = %d, want 1", len(actions))
|
|
||||||
}
|
|
||||||
if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
|
|
||||||
t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
|
|
||||||
}
|
|
||||||
if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
|
|
||||||
t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
|
|
||||||
// not configured.
|
|
||||||
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
truncateLibrary(t, pool)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
|
|
||||||
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
|
|
||||||
|
|
||||||
// Lidarr config is reset (disabled).
|
|
||||||
w := doAdminQuarantineReq(t, h, http.MethodPost,
|
|
||||||
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
|
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]string
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["error"] != "lidarr_disabled" {
|
|
||||||
t.Errorf("error = %q, want lidarr_disabled", resp["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
|
|
||||||
// when the parent album has no mbid.
|
|
||||||
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
truncateLibrary(t, pool)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
// Track whose album has NO mbid.
|
|
||||||
track, _ := seedTrackOnDisk(t, h, "no-mbid")
|
|
||||||
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
|
|
||||||
|
|
||||||
w := doAdminQuarantineReq(t, h, http.MethodPost,
|
|
||||||
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp map[string]string
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp["error"] != "album_mbid_missing" {
|
|
||||||
t.Errorf("error = %q, want album_mbid_missing", resp["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
|
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
|
||||||
// endpoints reject non-admin callers with 403.
|
// endpoints reject non-admin callers with 403.
|
||||||
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
installQuarantineClientFn(t, h)
|
|
||||||
|
|
||||||
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
||||||
fakeID := "00000000-0000-0000-0000-000000000001"
|
fakeID := "00000000-0000-0000-0000-000000000001"
|
||||||
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
|||||||
{http.MethodGet, "/api/admin/quarantine"},
|
{http.MethodGet, "/api/admin/quarantine"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
|
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
|
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
|
||||||
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
|
|
||||||
{http.MethodGet, "/api/admin/quarantine/actions"},
|
{http.MethodGet, "/api/admin/quarantine/actions"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -13,18 +13,14 @@ import (
|
|||||||
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
|
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
|
||||||
//
|
//
|
||||||
// Carries more than the stored settings: [UnnameableAlbums] is the count of
|
// Carries more than the stored settings: [UnnameableAlbums] is the count of
|
||||||
// albums with missing files that can never be auto-requested because neither
|
// albums with missing files that can never be wanted automatically because
|
||||||
// they nor their artist has an MBID. The card states that number rather than
|
// neither they nor their artist has an MBID. The card states that number
|
||||||
// leaving the operator to wonder why some rows never get a request — a
|
// rather than leaving the operator to wonder why some rows never get wanted —
|
||||||
// feature that silently does nothing for part of its input reads as broken.
|
// a feature that silently does nothing for part of its input reads as broken.
|
||||||
type reacquisitionSettingsResp struct {
|
type reacquisitionSettingsResp struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
GraceHours int32 `json:"grace_hours"`
|
GraceHours int32 `json:"grace_hours"`
|
||||||
BackoffBaseHours int32 `json:"backoff_base_hours"`
|
MaxPerPass int32 `json:"max_per_pass"`
|
||||||
BackoffMaxHours int32 `json:"backoff_max_hours"`
|
|
||||||
MaxAttempts int32 `json:"max_attempts"`
|
|
||||||
MaxPerPass int32 `json:"max_per_pass"`
|
|
||||||
AutoApprove bool `json:"auto_approve"`
|
|
||||||
|
|
||||||
UnnameableAlbums int64 `json:"unnameable_albums"`
|
UnnameableAlbums int64 `json:"unnameable_albums"`
|
||||||
}
|
}
|
||||||
@@ -42,13 +38,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{
|
_, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
GraceHours: req.GraceHours,
|
GraceHours: req.GraceHours,
|
||||||
BackoffBaseHours: req.BackoffBaseHours,
|
MaxPerPass: req.MaxPerPass,
|
||||||
BackoffMaxHours: req.BackoffMaxHours,
|
|
||||||
MaxAttempts: req.MaxAttempts,
|
|
||||||
MaxPerPass: req.MaxPerPass,
|
|
||||||
AutoApprove: req.AutoApprove,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The Go-side validation mirrors migration 0056's CHECKs so the
|
// The Go-side validation mirrors migration 0056's CHECKs so the
|
||||||
@@ -69,13 +61,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
|
|||||||
func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp {
|
func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp {
|
||||||
cur := h.reacqSettings.Get()
|
cur := h.reacqSettings.Get()
|
||||||
out := reacquisitionSettingsResp{
|
out := reacquisitionSettingsResp{
|
||||||
Enabled: cur.Enabled,
|
Enabled: cur.Enabled,
|
||||||
GraceHours: cur.GraceHours,
|
GraceHours: cur.GraceHours,
|
||||||
BackoffBaseHours: cur.BackoffBaseHours,
|
MaxPerPass: cur.MaxPerPass,
|
||||||
BackoffMaxHours: cur.BackoffMaxHours,
|
|
||||||
MaxAttempts: cur.MaxAttempts,
|
|
||||||
MaxPerPass: cur.MaxPerPass,
|
|
||||||
AutoApprove: cur.AutoApprove,
|
|
||||||
}
|
}
|
||||||
// Best-effort: the settings are the point of this endpoint, and failing
|
// Best-effort: the settings are the point of this endpoint, and failing
|
||||||
// the whole card because a count query hiccuped would be the wrong trade.
|
// the whole card because a count query hiccuped would be the wrong trade.
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validRequestStatuses is the set of allowed values for the ?status= param.
|
// validRequestStatuses is the set of allowed values for the ?status= param.
|
||||||
@@ -50,7 +51,7 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
|
|||||||
limit = v
|
limit = v
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.lidarrRequests.ListByStatus(r.Context(), status, int32(limit))
|
rows, err := h.requests.ListByStatus(r.Context(), status, int32(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("admin: list requests", "err", err)
|
h.logger.Error("admin: list requests", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -68,12 +69,15 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
|
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
|
||||||
|
// profile_id picks the release-type profile an artist request is followed
|
||||||
|
// on; omitted, the default profile.
|
||||||
type approveRequestBody struct {
|
type approveRequestBody struct {
|
||||||
QualityProfileID int `json:"quality_profile_id"`
|
ProfileID string `json:"profile_id"`
|
||||||
RootFolderPath string `json:"root_folder_path"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleApproveRequest implements POST /api/admin/requests/:id/approve.
|
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
|
||||||
|
// request goes on the wanted list (M502 #5520): an album or track request
|
||||||
|
// wants its album, an artist request follows the artist.
|
||||||
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
admin, ok := auth.UserFromContext(r.Context())
|
admin, ok := auth.UserFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -90,35 +94,25 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
var body approveRequestBody
|
var body approveRequestBody
|
||||||
// Body is entirely optional; ignore decode errors.
|
// Body is entirely optional; ignore decode errors.
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
var profile pgtype.UUID
|
||||||
|
if body.ProfileID != "" {
|
||||||
|
if profile, ok = parseUUID(body.ProfileID); !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
row, err := h.lidarrRequests.Approve(r.Context(), id, admin.ID, lidarrrequests.ApproveOverrides{
|
row, err := h.requests.Approve(r.Context(), id, admin.ID, profile)
|
||||||
QualityProfileID: body.QualityProfileID,
|
|
||||||
RootFolderPath: body.RootFolderPath,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrrequests.ErrLidarrDisabled):
|
case errors.Is(err, requests.ErrNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
|
||||||
case errors.Is(err, lidarrrequests.ErrDefaultsIncomplete):
|
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "lidarr_defaults_incomplete")
|
|
||||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
||||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
case errors.Is(err, requests.ErrNotPending):
|
||||||
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
||||||
case errors.Is(err, lidarr.ErrUnreachable):
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
|
writeAdminJSONErr(w, http.StatusBadRequest, "profile_not_found")
|
||||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
case errors.Is(err, wanted.ErrNotFollowable):
|
||||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
|
writeAdminJSONErr(w, http.StatusBadRequest, "not_followable")
|
||||||
case errors.Is(err, lidarr.ErrServerError):
|
|
||||||
// Lidarr itself 5xx'd — its response body is in err's wrapped
|
|
||||||
// message (see lidarr.readBodySnippet). Logged below for ops.
|
|
||||||
h.logger.Error("admin: approve request: lidarr 5xx", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_server_error")
|
|
||||||
case errors.Is(err, lidarr.ErrLookupFailed):
|
|
||||||
// Lidarr returned 4xx — usually "artist already exists" or
|
|
||||||
// "no metadata found." Log so ops can see the actual reason.
|
|
||||||
h.logger.Warn("admin: approve request: lidarr 4xx", "err", err)
|
|
||||||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_rejected")
|
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: approve request", "err", err)
|
h.logger.Error("admin: approve request", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -153,12 +147,12 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body rejectRequestBody
|
var body rejectRequestBody
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
row, err := h.lidarrRequests.Reject(r.Context(), id, admin.ID, body.Notes)
|
row, err := h.requests.Reject(r.Context(), id, admin.ID, body.Notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
case errors.Is(err, requests.ErrNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
||||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
case errors.Is(err, requests.ErrNotPending):
|
||||||
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: reject request", "err", err)
|
h.logger.Error("admin: reject request", "err", err)
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newAdminRequestsRouter builds a test chi router with the three admin/requests
|
// newAdminRequestsRouter builds a test chi router with the three admin/requests
|
||||||
@@ -52,24 +48,7 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// testHandlersWithClientFn creates a handlers instance with a real clientFn
|
// seedPendingArtistRequest creates a pending artist-kind request row
|
||||||
// so Service.Approve actually calls Lidarr during tests.
|
|
||||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) {
|
|
||||||
t.Helper()
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
lidarrCfg := lidarrconfig.New(h.pool)
|
|
||||||
clientFn := func() *lidarr.Client {
|
|
||||||
cfg, err := lidarrCfg.Get(context.Background())
|
|
||||||
if err != nil || !cfg.Enabled || cfg.BaseURL == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
|
||||||
}
|
|
||||||
h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil)
|
|
||||||
return h, lidarrCfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
|
|
||||||
// for the given user and returns its parsed requestView.
|
// for the given user and returns its parsed requestView.
|
||||||
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -79,8 +58,8 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB
|
|||||||
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
|
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
|
||||||
// and verifies that GET without ?status returns only the 2 pending rows.
|
// and verifies that GET without ?status returns only the 2 pending rows.
|
||||||
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
@@ -90,7 +69,7 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
|||||||
|
|
||||||
// Reject one request directly via service.
|
// Reject one request directly via service.
|
||||||
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
|
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
|
||||||
_, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reject seed: %v", err)
|
t.Fatalf("reject seed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,8 +96,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
|||||||
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
|
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
|
||||||
// only rejected rows.
|
// only rejected rows.
|
||||||
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
@@ -126,7 +105,7 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
|||||||
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
|
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
|
||||||
|
|
||||||
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
|
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
|
||||||
_, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reject seed: %v", err)
|
t.Fatalf("reject seed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -151,8 +130,8 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
|||||||
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
|
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
|
||||||
// ?status returns 400.
|
// ?status returns 400.
|
||||||
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
|
||||||
@@ -169,190 +148,11 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending
|
|
||||||
// artist request, and verifies POST /approve → 200 with row status=approved.
|
|
||||||
func TestHandleApproveRequest_HappyPath(t *testing.T) {
|
|
||||||
h, _ := testHandlersWithClientFn(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
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)
|
|
||||||
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me")
|
|
||||||
|
|
||||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var got requestView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if got.Status != "approved" {
|
|
||||||
t.Errorf("status = %q, want approved", got.Status)
|
|
||||||
}
|
|
||||||
if got.ID != rv.ID {
|
|
||||||
t.Errorf("id mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m",
|
|
||||||
// approves with override qp=5/root="/other", verifies the stub received those
|
|
||||||
// values and the row reflects the overrides.
|
|
||||||
func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
|
|
||||||
h, _ := testHandlersWithClientFn(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
var capturedBody []byte
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
n, _ := r.Body.Read(buf)
|
|
||||||
capturedBody = buf[:n]
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Save config with default qp=1, root="/m".
|
|
||||||
if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{
|
|
||||||
Enabled: true,
|
|
||||||
BaseURL: stub.URL,
|
|
||||||
APIKey: "test-key",
|
|
||||||
DefaultQualityProfileID: 1,
|
|
||||||
DefaultRootFolderPath: "/m",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me")
|
|
||||||
|
|
||||||
body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`)
|
|
||||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify stub received the override values.
|
|
||||||
var stubPayload map[string]any
|
|
||||||
if err := json.Unmarshal(capturedBody, &stubPayload); err != nil {
|
|
||||||
t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody)
|
|
||||||
}
|
|
||||||
if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 {
|
|
||||||
t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"])
|
|
||||||
}
|
|
||||||
if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" {
|
|
||||||
t.Errorf("stub rootFolderPath = %q, want /other", rf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the returned row reflects the overrides.
|
|
||||||
var got requestView
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("decode response: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if got.QualityProfileID == nil || *got.QualityProfileID != 5 {
|
|
||||||
t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID)
|
|
||||||
}
|
|
||||||
if got.RootFolderPath == nil || *got.RootFolderPath != "/other" {
|
|
||||||
t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
|
|
||||||
// at an unreachable port and verifies the durable-approve contract: POST
|
|
||||||
// /approve still succeeds (200) and the request becomes approved with the
|
|
||||||
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
|
|
||||||
// retry — the admin's decision is never lost to a transient Lidarr outage.
|
|
||||||
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
|
|
||||||
h, _ := testHandlersWithClientFn(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
|
|
||||||
|
|
||||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must no longer be pending.
|
|
||||||
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list pending: %v", err)
|
|
||||||
}
|
|
||||||
for _, r := range pending {
|
|
||||||
if r.ID == rv.ID {
|
|
||||||
t.Error("row still pending after durable approve")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be approved with the add deferred (confirmed_at NULL) so the
|
|
||||||
// reconciler retries it — not failed, not lost.
|
|
||||||
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list approved: %v", err)
|
|
||||||
}
|
|
||||||
var got *dbq.LidarrRequest
|
|
||||||
for i := range approved {
|
|
||||||
if approved[i].ID == rv.ID {
|
|
||||||
got = &approved[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
|
|
||||||
}
|
|
||||||
if got.LidarrAddConfirmedAt.Valid {
|
|
||||||
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
|
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
|
||||||
// POST /approve → 409 request_not_pending.
|
// POST /approve → 409 request_not_pending.
|
||||||
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
// Need an enabled Lidarr config so Approve reaches the not-pending check.
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(`{"id":1}`))
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
@@ -360,7 +160,7 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
|||||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
|
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
|
||||||
|
|
||||||
// Pre-reject the row.
|
// Pre-reject the row.
|
||||||
_, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("pre-reject: %v", err)
|
t.Fatalf("pre-reject: %v", err)
|
||||||
}
|
}
|
||||||
@@ -381,8 +181,8 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
|||||||
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
|
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
|
||||||
// 200 and the row transitions to rejected with notes saved.
|
// 200 and the row transitions to rejected with notes saved.
|
||||||
func TestHandleRejectRequest_HappyPath(t *testing.T) {
|
func TestHandleRejectRequest_HappyPath(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
@@ -410,8 +210,8 @@ func TestHandleRejectRequest_HappyPath(t *testing.T) {
|
|||||||
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
|
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
|
||||||
// returns 200 (notes are optional).
|
// returns 200 (notes are optional).
|
||||||
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
|
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
@@ -435,8 +235,8 @@ func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
|
|||||||
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
|
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
|
||||||
// all 3 admin request endpoints reject non-admin users with 403.
|
// all 3 admin request endpoints reject non-admin users with 403.
|
||||||
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
|
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
|
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
|
||||||
|
|
||||||
@@ -470,3 +270,62 @@ 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, _ := testHandlers(t)
|
||||||
|
resetRequests(t, h)
|
||||||
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
|
||||||
|
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var rv requestView
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &rv)
|
||||||
|
|
||||||
|
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no want recorded: %v", err)
|
||||||
|
}
|
||||||
|
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
|
||||||
|
t.Errorf("want = %+v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, _ := testHandlers(t)
|
||||||
|
resetRequests(t, h)
|
||||||
|
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
|
||||||
|
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
|
||||||
|
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
|
||||||
|
|
||||||
|
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
|
||||||
|
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
|
||||||
|
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
|
||||||
|
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Searching for wants and grabbing releases (M502 #5521): the schedule's
|
||||||
|
// settings, each want's candidates, and a person's Search, Grab and "never
|
||||||
|
// this one".
|
||||||
|
|
||||||
|
// searchTimeout bounds a search a person waits on: a Prowlarr fanning out to
|
||||||
|
// many trackers can take most of a minute.
|
||||||
|
const searchTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
type searchSettingsView struct {
|
||||||
|
AutoGrab bool `json:"auto_grab"`
|
||||||
|
SearchIntervalHours int32 `json:"search_interval_hours"`
|
||||||
|
AllowedQualities []string `json:"allowed_qualities"`
|
||||||
|
Tiers []release.TierInfo `json:"tiers"`
|
||||||
|
LastPassAt *string `json:"last_pass_at"`
|
||||||
|
LastPass json.RawMessage `json:"last_pass"`
|
||||||
|
// ImportRoot is the library folder new music is filed into (#5522); ""
|
||||||
|
// is the first of ScanPaths, the library's folders.
|
||||||
|
ImportRoot string `json:"import_root"`
|
||||||
|
ScanPaths []string `json:"scan_paths"`
|
||||||
|
// The download lifecycle (#5523): seed an imported download to this
|
||||||
|
// ratio or for this many days; give up on one nobody shares after this
|
||||||
|
// many hours. 0 is: remove once imported, no time limit, never.
|
||||||
|
SeedRatio float32 `json:"seed_ratio"`
|
||||||
|
SeedDays int32 `json:"seed_days"`
|
||||||
|
StallHours int32 `json:"stall_hours"`
|
||||||
|
// UpgradeUntil is the tier a held album is upgraded to (#5524); "" is
|
||||||
|
// no quality upgrades.
|
||||||
|
UpgradeUntil string `json:"upgrade_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
|
||||||
|
v := searchSettingsView{
|
||||||
|
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
|
||||||
|
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
|
||||||
|
ImportRoot: s.ImportRoot, ScanPaths: roots,
|
||||||
|
SeedRatio: s.SeedRatio, SeedDays: s.SeedDays, StallHours: s.StallHours,
|
||||||
|
UpgradeUntil: s.UpgradeUntil,
|
||||||
|
}
|
||||||
|
if v.ScanPaths == nil {
|
||||||
|
v.ScanPaths = []string{}
|
||||||
|
}
|
||||||
|
if v.AllowedQualities == nil {
|
||||||
|
v.AllowedQualities = []string{}
|
||||||
|
}
|
||||||
|
if len(v.LastPass) == 0 {
|
||||||
|
v.LastPass = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
if s.LastPassAt.Valid {
|
||||||
|
at := formatTimestamp(s.LastPassAt)
|
||||||
|
v.LastPassAt = &at
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetSearchSettings implements GET /api/admin/acquisition/settings.
|
||||||
|
func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s, err := dbq.New(h.pool).GetAcquisitionSettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type putSearchSettingsBody struct {
|
||||||
|
AutoGrab bool `json:"auto_grab"`
|
||||||
|
SearchIntervalHours int32 `json:"search_interval_hours"`
|
||||||
|
AllowedQualities []string `json:"allowed_qualities"`
|
||||||
|
ImportRoot string `json:"import_root"`
|
||||||
|
SeedRatio float32 `json:"seed_ratio"`
|
||||||
|
SeedDays int32 `json:"seed_days"`
|
||||||
|
StallHours int32 `json:"stall_hours"`
|
||||||
|
UpgradeUntil string `json:"upgrade_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
|
||||||
|
func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body putSearchSettingsBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SearchIntervalHours < 0 || body.SearchIntervalHours > 720 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_interval", "search_interval_hours must be between 0 and 720"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.AllowedQualities) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_quality", "allow at least one quality"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range body.AllowedQualities {
|
||||||
|
if !release.KnownTier(t) {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_quality", "unknown quality "+t))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.UpgradeUntil != "" && !release.KnownTier(body.UpgradeUntil) {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots(), body.ImportRoot) {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SeedRatio < 0 || body.SeedRatio > 100 || body.SeedDays < 0 || body.SeedDays > 365 ||
|
||||||
|
body.StallHours < 0 || body.StallHours > 720 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_lifecycle",
|
||||||
|
"seed ratio must be 0 to 100, seed days 0 to 365 and stall hours 0 to 720"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
|
||||||
|
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
|
||||||
|
ImportRoot: body.ImportRoot, SeedRatio: body.SeedRatio, SeedDays: body.SeedDays, StallHours: body.StallHours,
|
||||||
|
UpgradeUntil: body.UpgradeUntil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateView is one release a want's last search found.
|
||||||
|
type candidateView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Seeders int32 `json:"seeders"`
|
||||||
|
FilesCount int32 `json:"files_count"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Year int32 `json:"year"`
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Reasons json.RawMessage `json:"reasons"`
|
||||||
|
FoldedFrom []string `json:"folded_from"`
|
||||||
|
SearchedAt string `json:"searched_at"`
|
||||||
|
Downloading bool `json:"downloading"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListCandidates implements GET /api/admin/wanted/{id}/candidates.
|
||||||
|
func (h *handlers) handleListCandidates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.ListWantCandidates(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: candidates", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
live, err := q.GetLiveGrab(r.Context(), id)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: live grab", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]candidateView, 0, len(rows))
|
||||||
|
for _, c := range rows {
|
||||||
|
v := candidateView{
|
||||||
|
Key: c.Key, Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer, SizeBytes: c.SizeBytes,
|
||||||
|
Seeders: c.Seeders, FilesCount: c.FilesCount, Format: c.Format, Quality: c.Quality, Year: c.Year,
|
||||||
|
Verdict: c.Verdict, Reason: c.Reason, Reasons: c.Reasons, FoldedFrom: c.FoldedFrom,
|
||||||
|
SearchedAt: formatTimestamp(c.SearchedAt),
|
||||||
|
Downloading: live.ID.Valid && live.Key == c.Key,
|
||||||
|
}
|
||||||
|
if v.FoldedFrom == nil {
|
||||||
|
v.FoldedFrom = []string{}
|
||||||
|
}
|
||||||
|
if len(v.Reasons) == 0 {
|
||||||
|
v.Reasons = json.RawMessage("[]")
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearchWant implements POST /api/admin/wanted/{id}/search: search
|
||||||
|
// now, as the schedule would, taking the best release when auto-grab is on.
|
||||||
|
func (h *handlers) handleSearchWant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
res, err := h.searcher.SearchNow(ctx, id)
|
||||||
|
if h.writeSearchErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grabBody struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// grabView is what came of a person's grab.
|
||||||
|
type grabView struct {
|
||||||
|
Added bool `json:"added"`
|
||||||
|
Refused bool `json:"refused"`
|
||||||
|
Gone bool `json:"gone"`
|
||||||
|
Unconfigured bool `json:"unconfigured"`
|
||||||
|
Missing string `json:"missing,omitempty"`
|
||||||
|
Kept bool `json:"kept"`
|
||||||
|
Replaced bool `json:"replaced"`
|
||||||
|
Verdict string `json:"verdict,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGrabCandidate implements POST /api/admin/wanted/{id}/grab: take the
|
||||||
|
// release named by key. The search runs again first, so the indexer's link
|
||||||
|
// never leaves the server; force takes what the verdict would not.
|
||||||
|
func (h *handlers) handleGrabCandidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body grabBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
took, err := h.searcher.Take(ctx, id, body.Key, body.Force, admin.ID)
|
||||||
|
if h.writeSearchErr(w, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := grabView{
|
||||||
|
Added: took.Added, Refused: took.Refused, Gone: took.Gone, Unconfigured: took.Unconfigured,
|
||||||
|
Missing: took.Missing, Error: took.Error,
|
||||||
|
}
|
||||||
|
if took.Chosen != nil {
|
||||||
|
v.Verdict, v.Reason = string(took.Chosen.Verdict), took.Chosen.Reason
|
||||||
|
}
|
||||||
|
if took.Contest != nil {
|
||||||
|
v.Kept, v.Replaced = took.Contest.Kept, took.Contest.Replaced
|
||||||
|
if took.Contest.Kept {
|
||||||
|
v.Reason = took.Contest.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockBody struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBlockCandidate implements POST /api/admin/wanted/{id}/block: never
|
||||||
|
// take this release again, for any want.
|
||||||
|
func (h *handlers) handleBlockCandidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body blockBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := dbq.New(h.pool).GetWantCandidate(r.Context(), dbq.GetWantCandidateParams{WantID: id, Key: body.Key})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, apierror.NotFound("candidate"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.InfoHash == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("no_infohash", "this release has no infohash to set aside by"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := acquire.NewMemory(h.pool).Block(r.Context(), dbq.BlockReleaseParams{
|
||||||
|
InfoHash: c.InfoHash, Name: c.Name, Reason: acquire.BlockOperator, WantID: id, BlockedBy: admin.ID,
|
||||||
|
}); err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnblockRelease implements DELETE /api/admin/release-blocks/{hash}.
|
||||||
|
func (h *handlers) handleUnblockRelease(w http.ResponseWriter, r *http.Request) {
|
||||||
|
removed, err := acquire.NewMemory(h.pool).Unblock(r.Context(), chi.URLParam(r, "hash"))
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: unblock", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
writeErr(w, apierror.NotFound("block"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSearchErr answers a Searcher error, and says whether there was one.
|
||||||
|
func (h *handlers) writeSearchErr(w http.ResponseWriter, err error) bool {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return false
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
writeErr(w, apierror.NotFound("want"))
|
||||||
|
case errors.Is(err, wanted.ErrWantHeld):
|
||||||
|
writeErr(w, apierror.Conflict("want_held", "the library already holds this album, and it isn't wanted for a better copy"))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: search", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The admin Overview's status sections (M504): after the verdict, who is
|
||||||
|
// listening, what is downloading, what the library holds and how much room
|
||||||
|
// it has, and how the server itself is doing. One read for the page, polled
|
||||||
|
// while it is open.
|
||||||
|
|
||||||
|
type listeningView struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
Client string `json:"client"`
|
||||||
|
DeviceClass string `json:"device_class"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listeningStatusView struct {
|
||||||
|
Now []listeningView `json:"now"`
|
||||||
|
// The last day: how many plays, by how many people, the latest when.
|
||||||
|
PlaysDay int64 `json:"plays_day"`
|
||||||
|
ListenersDay int64 `json:"listeners_day"`
|
||||||
|
LastPlayAt *string `json:"last_play_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadsStatusView struct {
|
||||||
|
Running int64 `json:"running"`
|
||||||
|
NeedsYou int64 `json:"needs_you"`
|
||||||
|
NeedsYouSince *string `json:"needs_you_since"`
|
||||||
|
ImportedDay int64 `json:"imported_day"`
|
||||||
|
GivenUpDay int64 `json:"given_up_day"`
|
||||||
|
// Live is the downloads not yet ended, the ones needing a person first.
|
||||||
|
Live []downloadView `json:"live"`
|
||||||
|
ClientsDown []clientDownView `json:"clients_down"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskView is the filesystem one library folder, or the data folder, sits
|
||||||
|
// on.
|
||||||
|
type diskView struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
// Data marks Minstrel's own data folder (covers, fingerprints), beside
|
||||||
|
// the library's.
|
||||||
|
Data bool `json:"data,omitempty"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
FreeBytes uint64 `json:"free_bytes"`
|
||||||
|
// Error says why the folder could not be read, which is itself the
|
||||||
|
// news: a drive that did not mount.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type libraryStatusView struct {
|
||||||
|
Tracks int64 `json:"tracks"`
|
||||||
|
Albums int64 `json:"albums"`
|
||||||
|
Artists int64 `json:"artists"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Disks []diskView `json:"disks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverErrorView struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Attrs string `json:"attrs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverStatusView struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
// Migration is the database schema's version, and Dirty whether its
|
||||||
|
// last migration stopped part way.
|
||||||
|
Migration int64 `json:"migration"`
|
||||||
|
Dirty bool `json:"dirty"`
|
||||||
|
// Errors is the newest few errors logged since the start, newest first;
|
||||||
|
// ErrorCount is all of them.
|
||||||
|
Errors []serverErrorView `json:"errors"`
|
||||||
|
ErrorCount int64 `json:"error_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminStatusView struct {
|
||||||
|
Listening listeningStatusView `json:"listening"`
|
||||||
|
Downloads downloadsStatusView `json:"downloads"`
|
||||||
|
Library libraryStatusView `json:"library"`
|
||||||
|
Server serverStatusView `json:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewLiveDownloads is how many running downloads the Overview lists;
|
||||||
|
// Activity has the rest.
|
||||||
|
const overviewLiveDownloads = 5
|
||||||
|
|
||||||
|
// overviewErrors is how many recent errors the Overview lists.
|
||||||
|
const overviewErrors = 10
|
||||||
|
|
||||||
|
// handleGetAdminStatus implements GET /api/admin/status.
|
||||||
|
func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
ctx := r.Context()
|
||||||
|
var out adminStatusView
|
||||||
|
|
||||||
|
now, err := q.ListListeningNow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin status: listening", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Listening.Now = make([]listeningView, 0, len(now))
|
||||||
|
for _, p := range now {
|
||||||
|
out.Listening.Now = append(out.Listening.Now, listeningView{
|
||||||
|
Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle,
|
||||||
|
ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle,
|
||||||
|
DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt),
|
||||||
|
Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
day, err := q.PlaysLastDay(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin status: plays", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners
|
||||||
|
if day.LastAt.Valid {
|
||||||
|
at := formatTimestamp(day.LastAt)
|
||||||
|
out.Listening.LastPlayAt = &at
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.downloadsStatus(r, q, &out.Downloads); err != nil {
|
||||||
|
h.logger.Error("admin status: downloads", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lib, err := q.LibraryStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin status: library", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
|
||||||
|
Bytes: lib.Bytes, Disks: []diskView{}}
|
||||||
|
if h.importer != nil {
|
||||||
|
for _, root := range h.importer.Roots() {
|
||||||
|
out.Library.Disks = append(out.Library.Disks, diskOf(root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.dataDir != "" {
|
||||||
|
d := diskOf(h.dataDir)
|
||||||
|
d.Data = true
|
||||||
|
out.Library.Disks = append(out.Library.Disks, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Server = serverStatus(logging.Errors)
|
||||||
|
// golang-migrate's own table, outside sqlc's schema.
|
||||||
|
if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).
|
||||||
|
Scan(&out.Server.Migration, &out.Server.Dirty); err != nil {
|
||||||
|
h.logger.Warn("admin status: migration version", "err", err)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error {
|
||||||
|
st, err := q.DownloadsStatus(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou,
|
||||||
|
ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay,
|
||||||
|
Live: []downloadView{}, ClientsDown: []clientDownView{}}
|
||||||
|
if st.NeedsYou > 0 && st.NeedsYouSince.Valid {
|
||||||
|
since := formatTimestamp(st.NeedsYouSince)
|
||||||
|
out.NeedsYouSince = &since
|
||||||
|
}
|
||||||
|
// ListGrabs puts the ones needing a person first, then the running ones.
|
||||||
|
rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, g := range rows {
|
||||||
|
if !g.EndedAt.Valid {
|
||||||
|
out.Live = append(out.Live, downloadFrom(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
down, err := q.ListClientsDown(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range down {
|
||||||
|
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
|
||||||
|
Since: formatTimestamp(c.FirstSeen)})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskOf reads the size and free space of the filesystem path is on.
|
||||||
|
func diskOf(path string) diskView {
|
||||||
|
var st syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &st); err != nil {
|
||||||
|
return diskView{Path: path, Error: err.Error()}
|
||||||
|
}
|
||||||
|
bsize := uint64(st.Bsize)
|
||||||
|
return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverStatus(ring *logging.ErrorRing) serverStatusView {
|
||||||
|
kept, total := ring.Snapshot()
|
||||||
|
v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel,
|
||||||
|
StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total,
|
||||||
|
Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))}
|
||||||
|
for i, e := range kept {
|
||||||
|
if i == overviewErrors {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339),
|
||||||
|
Message: e.Message, Attrs: e.Attrs})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openPlay records a play by user of track that started ago and has not
|
||||||
|
// ended.
|
||||||
|
func openPlay(t *testing.T, h *handlers, user, track pgtype.UUID, ago string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var session pgtype.UUID
|
||||||
|
if err := h.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id`,
|
||||||
|
user).Scan(&session); err != nil {
|
||||||
|
t.Fatalf("session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := h.pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, client_id)
|
||||||
|
VALUES ($1, $2, $3, now() - $4::interval, 'web')`, user, track, session, ago); err != nil {
|
||||||
|
t.Fatalf("play: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Overview's status (M504): a play still inside its track's length is
|
||||||
|
// listening now; one long past it, never closed by its client, is not.
|
||||||
|
func TestAdminStatus_ListeningAndServer(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
|
|
||||||
|
fresh, _ := seedTrackOnDisk(t, h, "statusfresh") // 30 s long
|
||||||
|
stale, _ := seedTrackOnDisk(t, h, "statusstale")
|
||||||
|
openPlay(t, h, admin.ID, fresh.ID, "10 seconds")
|
||||||
|
openPlay(t, h, admin.ID, stale.ID, "1 hour")
|
||||||
|
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/status", nil), admin)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleGetAdminStatus(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got adminStatusView
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawFresh, sawStale bool
|
||||||
|
for _, p := range got.Listening.Now {
|
||||||
|
switch p.TrackID {
|
||||||
|
case uuidToString(fresh.ID):
|
||||||
|
sawFresh = true
|
||||||
|
if p.Username != admin.Username || p.Client != "web" || p.Title != "AdmQ Track statusfresh" {
|
||||||
|
t.Errorf("listening row = %+v", p)
|
||||||
|
}
|
||||||
|
case uuidToString(stale.ID):
|
||||||
|
sawStale = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawFresh || sawStale {
|
||||||
|
t.Errorf("listening now: fresh %v (want true), stale %v (want false)", sawFresh, sawStale)
|
||||||
|
}
|
||||||
|
if got.Listening.PlaysDay < 2 || got.Listening.LastPlayAt == nil {
|
||||||
|
t.Errorf("last day: plays %d, last %v", got.Listening.PlaysDay, got.Listening.LastPlayAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Library.Tracks != 2 || got.Library.Albums != 2 || got.Library.Bytes != 10 {
|
||||||
|
t.Errorf("library = %+v, want 2 tracks, 2 albums, 10 bytes", got.Library)
|
||||||
|
}
|
||||||
|
if got.Downloads.Live == nil || got.Downloads.ClientsDown == nil || got.Library.Disks == nil {
|
||||||
|
t.Error("lists must be [], never null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Server.Version != buildinfo.Version || got.Server.StartedAt == "" || got.Server.Migration < 92 {
|
||||||
|
t.Errorf("server = %+v", got.Server)
|
||||||
|
}
|
||||||
|
if _, total := logging.Errors.Snapshot(); got.Server.ErrorCount != total {
|
||||||
|
t.Errorf("error count = %d, ring says %d", got.Server.ErrorCount, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminQueueView is one queue that waits on a person: how many, and since
|
||||||
|
// when the oldest of them has waited (null when the queue is empty).
|
||||||
|
type adminQueueView struct {
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Since *string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSummaryView is what the admin rail and the Overview's verdict read
|
||||||
|
// (#5605): every queue an admin acts on, in one response, so each admin page
|
||||||
|
// pays one cheap read for its rail.
|
||||||
|
type adminSummaryView struct {
|
||||||
|
Requests adminQueueView `json:"requests"`
|
||||||
|
Reports adminQueueView `json:"reports"`
|
||||||
|
Duplicates adminQueueView `json:"duplicates"`
|
||||||
|
Suspect adminQueueView `json:"suspect"`
|
||||||
|
Missing adminQueueView `json:"missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueView(count int64, since pgtype.Timestamptz) adminQueueView {
|
||||||
|
v := adminQueueView{Count: count}
|
||||||
|
if count > 0 && since.Valid {
|
||||||
|
s := formatTimestamp(since)
|
||||||
|
v.Since = &s
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminSummaryFrom(row dbq.AdminSummaryRow) adminSummaryView {
|
||||||
|
return adminSummaryView{
|
||||||
|
Requests: queueView(row.Requests, row.RequestsSince),
|
||||||
|
Reports: queueView(row.Reports, row.ReportsSince),
|
||||||
|
Duplicates: queueView(row.Duplicates, row.DuplicatesSince),
|
||||||
|
Suspect: queueView(row.Suspect, row.SuspectSince),
|
||||||
|
Missing: queueView(row.Missing, row.MissingSince),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAdminSummary implements GET /api/admin/summary.
|
||||||
|
func (h *handlers) handleGetAdminSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
row, err := dbq.New(h.pool).AdminSummary(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: summary", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, adminSummaryFrom(row))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummaryView) {
|
||||||
|
t.Helper()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/summary", h.handleGetAdminSummary)
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, "/api/admin/summary", nil), user))
|
||||||
|
var out adminSummaryView
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, _ := testHandlers(t)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
listener := seedUser(t, h.pool, "listener", "pw", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
code, empty := getAdminSummary(t, h, admin)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", code)
|
||||||
|
}
|
||||||
|
if empty.Requests.Count != 0 || empty.Requests.Since != nil || empty.Missing.Count != 0 {
|
||||||
|
t.Errorf("empty summary = %+v", empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dbq.New(h.pool).CreateRequest(ctx, dbq.CreateRequestParams{
|
||||||
|
UserID: listener.ID, Kind: dbq.RequestKindArtist, ArtistMbid: "a0000000-0000-4000-8000-000000000001",
|
||||||
|
ArtistName: "Annadale",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, got := getAdminSummary(t, h, admin)
|
||||||
|
if got.Requests.Count != 1 || got.Requests.Since == nil {
|
||||||
|
t.Errorf("requests = %+v, want 1 with a since", got.Requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, _ := getAdminSummary(t, h, listener); code != http.StatusForbidden {
|
||||||
|
t.Errorf("a listener got %d, want 403", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates count what needs review: a cross-release group is kept and
|
||||||
|
// linked by the resolver and waits on nobody (operator, 2026-10-19: the
|
||||||
|
// headline said 3,479 when 536 needed review).
|
||||||
|
func TestAdminSummary_DuplicatesCountOnlyWhatNeedsReview(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
truncateLibrary(t, h.pool)
|
||||||
|
admin := seedUser(t, h.pool, "dupsum-admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
a := seedQuarantineTrack(t, h, "dupsum-a")
|
||||||
|
b := seedQuarantineTrack(t, h, "dupsum-b")
|
||||||
|
for _, g := range []struct{ key, class string }{{"dupsum-review", "review"}, {"dupsum-cross", "cross_release"}} {
|
||||||
|
var id [16]byte
|
||||||
|
if err := h.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO duplicate_groups (member_key, tier, class) VALUES ($1, 'acoustic', $2) RETURNING id`,
|
||||||
|
g.key, g.class).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := h.pool.Exec(ctx, `INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`,
|
||||||
|
id, a.ID, b.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, got := getAdminSummary(t, h, admin)
|
||||||
|
if got.Duplicates.Count != 1 {
|
||||||
|
t.Errorf("duplicates = %d, want 1: the cross-release group needs nobody", got.Duplicates.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ type suspectTrackView struct {
|
|||||||
// first pass (M498 #5439). VerdictAt is when it was last set.
|
// first pass (M498 #5439). VerdictAt is when it was last set.
|
||||||
Verdict *string `json:"verdict"`
|
Verdict *string `json:"verdict"`
|
||||||
VerdictAt *string `json:"verdict_at"`
|
VerdictAt *string `json:"verdict_at"`
|
||||||
|
// Replacing: the album is on the wanted list for a clean copy (#5524);
|
||||||
|
// SearchedAt is when one was last searched for, null before the first.
|
||||||
|
Replacing bool `json:"replacing"`
|
||||||
|
SearchedAt *string `json:"searched_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// suspectGroupView is a folder's worth of flagged tracks. As on the
|
// suspectGroupView is a folder's worth of flagged tracks. As on the
|
||||||
@@ -112,6 +116,11 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
|
|||||||
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
|
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
|
||||||
t.VerdictAt = &at
|
t.VerdictAt = &at
|
||||||
}
|
}
|
||||||
|
t.Replacing = row.WantUpgrade == "rip"
|
||||||
|
if t.Replacing && row.SearchedAt.Valid {
|
||||||
|
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
|
||||||
|
t.SearchedAt = &at
|
||||||
|
}
|
||||||
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
|
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
|
||||||
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
|
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -11,27 +10,22 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The three
|
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two
|
||||||
// optional fields are omitted unless the corresponding cleanup actually
|
// optional fields are omitted unless the corresponding cleanup actually
|
||||||
// happened, so the response stays minimal in the common case.
|
// happened, so the response stays minimal in the common case.
|
||||||
type removeTrackResponse struct {
|
type removeTrackResponse struct {
|
||||||
DeletedTrackID string `json:"deleted_track_id"`
|
DeletedTrackID string `json:"deleted_track_id"`
|
||||||
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
|
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
|
||||||
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
|
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
|
||||||
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}.
|
||||||
//
|
//
|
||||||
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
|
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
|
||||||
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
|
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
|
||||||
// nothing at all when the file cannot be removed (#3918). When
|
// nothing at all when the file cannot be removed (#3918).
|
||||||
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
|
||||||
// — failure there is non-fatal (the destructive part already completed) and
|
|
||||||
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
|
||||||
//
|
//
|
||||||
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
// The codes this handler emits are not_found,
|
||||||
// wire error codes. The codes this handler emits are not_found,
|
|
||||||
// library_not_writable (409) and file_delete_failed when the file could not be
|
// library_not_writable (409) and file_delete_failed when the file could not be
|
||||||
// removed, server_error, plus the auth codes the middleware emits upstream.
|
// removed, server_error, plus the auth codes the middleware emits upstream.
|
||||||
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
unmonitor := false
|
|
||||||
if v := r.URL.Query().Get("unmonitor"); v != "" {
|
|
||||||
if parsed, err := strconv.ParseBool(v); err == nil {
|
|
||||||
unmonitor = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive: RequireUser+RequireAdmin should have run upstream. If
|
// Defensive: RequireUser+RequireAdmin should have run upstream. If
|
||||||
// we got here without a user in context the routing is broken.
|
// we got here without a user in context the routing is broken.
|
||||||
admin, ok := requireUser(w, r)
|
admin, ok := requireUser(w, r)
|
||||||
@@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack(
|
deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID)
|
||||||
r.Context(), trackID, admin.ID, unmonitor,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, tracks.ErrNotFound) {
|
if errors.Is(err, tracks.ErrNotFound) {
|
||||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
||||||
@@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
s := uuidToString(*deletedArtist)
|
s := uuidToString(*deletedArtist)
|
||||||
resp.DeletedArtistID = &s
|
resp.DeletedArtistID = &s
|
||||||
}
|
}
|
||||||
// Only emit the flag when unmonitor was requested AND it failed.
|
|
||||||
// Avoids "false" cluttering the wire when the operator didn't ask
|
|
||||||
// for an unmonitor in the first place.
|
|
||||||
if unmonitor && lidarrUnmonitorFailed {
|
|
||||||
t := true
|
|
||||||
resp.LidarrUnmonitorFailed = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,41 +14,12 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
|
|
||||||
// tests dictate the return error. Used to drive both the Lidarr-success and
|
|
||||||
// Lidarr-failure branches without spinning up a stub HTTP server.
|
|
||||||
type fakeLidarrUnmonitorer struct {
|
|
||||||
calls []fakeUnmonitorCall
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeUnmonitorCall struct {
|
|
||||||
trackMbid string
|
|
||||||
albumMbid string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
|
|
||||||
f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
|
|
||||||
return f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
|
|
||||||
// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
|
|
||||||
// assert call counts and inject error returns.
|
|
||||||
func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
|
|
||||||
t.Helper()
|
|
||||||
fake := &fakeLidarrUnmonitorer{}
|
|
||||||
h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
|
|
||||||
return fake
|
|
||||||
}
|
|
||||||
|
|
||||||
// newAdminTracksRouter builds a test chi router with the admin-tracks
|
// newAdminTracksRouter builds a test chi router with the admin-tracks
|
||||||
// endpoint. RequireAdmin middleware is applied; tests inject the user into
|
// endpoint. RequireAdmin middleware is applied; tests inject the user into
|
||||||
// context manually (RequireUser is bypassed). Mirrors the pattern from
|
// context manually (RequireUser is bypassed). Mirrors the pattern from
|
||||||
// admin_lidarr_test.go and admin_quarantine_test.go.
|
// admin_quarantine_test.go.
|
||||||
func newAdminTracksRouter(h *handlers) chi.Router {
|
func newAdminTracksRouter(h *handlers) chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Route("/api/admin", func(admin chi.Router) {
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
@@ -90,8 +60,8 @@ func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
|
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
|
||||||
// returns the track row. The mbid string is set on both track and album
|
// returns the track row. The mbids are set on the track and album when
|
||||||
// when non-empty so the unmonitor branch has the data it needs.
|
// non-empty.
|
||||||
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
|
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
|
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
|
||||||
@@ -206,11 +176,9 @@ func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
|
// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
|
||||||
// when the operator does NOT request unmonitor: 200 with deleted_track_id
|
// deleted_track_id and the optional cascade fields when they fired.
|
||||||
// (and the optional cascade fields when they fired) but NO
|
func TestHandleRemoveTrack_HappyPath(t *testing.T) {
|
||||||
// lidarr_unmonitor_failed key in the body.
|
|
||||||
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
truncateLibrary(t, pool)
|
truncateLibrary(t, pool)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
@@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
|
|||||||
if got.DeletedArtistID == nil {
|
if got.DeletedArtistID == nil {
|
||||||
t.Error("deleted_artist_id missing; expected sole-track cascade")
|
t.Error("deleted_artist_id missing; expected sole-track cascade")
|
||||||
}
|
}
|
||||||
if got.LidarrUnmonitorFailed != nil {
|
|
||||||
t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
|
|
||||||
*got.LidarrUnmonitorFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
|
|
||||||
// on a *bool drops it, but we double-check the wire to catch
|
|
||||||
// regressions.
|
|
||||||
var raw map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
|
||||||
t.Fatalf("decode raw: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := raw["lidarr_unmonitor_failed"]; ok {
|
|
||||||
t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
|
|
||||||
}
|
|
||||||
|
|
||||||
// File is gone, DB row is gone.
|
// File is gone, DB row is gone.
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
@@ -261,50 +214,3 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
|
|||||||
t.Error("track row still exists")
|
t.Error("track row still exists")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
|
|
||||||
// operator sends ?unmonitor=true AND the Lidarr call fails, the response
|
|
||||||
// is still 200 (file + DB delete completed) but carries
|
|
||||||
// lidarr_unmonitor_failed: true.
|
|
||||||
func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
truncateLibrary(t, pool)
|
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
|
||||||
|
|
||||||
fake := installTracksLidarrStub(t, h)
|
|
||||||
fake.err = errors.New("lidarr exploded")
|
|
||||||
|
|
||||||
tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
|
|
||||||
w := doAdminTracksReq(t, h, http.MethodDelete,
|
|
||||||
"/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var got removeTrackResponse
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if got.DeletedTrackID != uuidToString(tr.ID) {
|
|
||||||
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
|
|
||||||
}
|
|
||||||
if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
|
|
||||||
t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lidarr was actually called with the right mbids.
|
|
||||||
if len(fake.calls) != 1 {
|
|
||||||
t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
|
|
||||||
}
|
|
||||||
if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
|
|
||||||
t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destructive part still happened.
|
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("file still exists: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
|
|
||||||
t.Error("track row still exists")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,820 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The wanted list's admin surface (M502 #5520): wants, follows and
|
||||||
|
// release-type profiles.
|
||||||
|
|
||||||
|
// wantView is one row of GET /api/admin/wanted.
|
||||||
|
type wantView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ReleaseGroupMBID string `json:"release_group_mbid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
PrimaryType string `json:"primary_type"`
|
||||||
|
SecondaryTypes []string `json:"secondary_types"`
|
||||||
|
Year string `json:"year"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
RequestedBy string `json:"requested_by,omitempty"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
WantedAt string `json:"wanted_at"`
|
||||||
|
HeldAt *string `json:"held_at"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
// Upgrade: why a held album is wanted again, quality or rip, and
|
||||||
|
// HeldQuality what the library's copy is (#5524).
|
||||||
|
Upgrade string `json:"upgrade,omitempty"`
|
||||||
|
HeldQuality string `json:"held_quality,omitempty"`
|
||||||
|
// Search is the schedule's last look; Grab the download running (#5521).
|
||||||
|
Search *wantSearchView `json:"search"`
|
||||||
|
Grab *wantGrabView `json:"grab"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wantSearchView struct {
|
||||||
|
SearchedAt string `json:"searched_at"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Considered int32 `json:"considered"`
|
||||||
|
BestRelease string `json:"best_release"`
|
||||||
|
BestVerdict string `json:"best_verdict"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantGrabView is the want's latest download, running or ended, and what
|
||||||
|
// its import made of it (#5522).
|
||||||
|
type wantGrabView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
GrabbedAt string `json:"grabbed_at"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Live bool `json:"live"`
|
||||||
|
EndReason string `json:"end_reason"`
|
||||||
|
Progress float32 `json:"progress"`
|
||||||
|
ImportState string `json:"import_state"`
|
||||||
|
ImportReason string `json:"import_reason"`
|
||||||
|
EndDetail string `json:"end_detail"`
|
||||||
|
ImportedAt *string `json:"imported_at"`
|
||||||
|
ImportedFiles int32 `json:"imported_files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantCounts sizes each tab of the Wanted page. Artists: how many artists
|
||||||
|
// the list holds albums of, its main page (#5572).
|
||||||
|
type wantCounts struct {
|
||||||
|
Artists int64 `json:"artists"`
|
||||||
|
// Unmatched: library albums waiting to be matched by hand (#5579).
|
||||||
|
Unmatched int64 `json:"unmatched"`
|
||||||
|
Wanted int64 `json:"wanted"`
|
||||||
|
Missing int64 `json:"missing"`
|
||||||
|
Held int64 `json:"held"`
|
||||||
|
// Upgrades: held albums wanted for a better copy (#5524).
|
||||||
|
Upgrades int64 `json:"upgrades"`
|
||||||
|
Following int64 `json:"following"`
|
||||||
|
// Activity: downloads running; Attention: of those, the ones waiting on
|
||||||
|
// a person; Blocked: releases set aside (#5522).
|
||||||
|
Activity int64 `json:"activity"`
|
||||||
|
Attention int64 `json:"attention"`
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wantsPage struct {
|
||||||
|
Items []wantView `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Counts wantCounts `json:"counts"`
|
||||||
|
// Library is the one-time listing of the library's albums (#5567).
|
||||||
|
Library libraryListingView `json:"library"`
|
||||||
|
// Artist heads one artist's page, when the list is narrowed to them.
|
||||||
|
Artist *wantArtistView `json:"artist,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantArtistView is one artist on the Wanted view's main page (#5572): how
|
||||||
|
// many of their albums the list holds, of how many.
|
||||||
|
type wantArtistView struct {
|
||||||
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
SortName string `json:"sort_name"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Albums int32 `json:"albums"`
|
||||||
|
Held int32 `json:"held"`
|
||||||
|
Wanted int32 `json:"wanted"`
|
||||||
|
Missing int32 `json:"missing"`
|
||||||
|
Downloading int32 `json:"downloading"`
|
||||||
|
Following bool `json:"following"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wantArtistsPage struct {
|
||||||
|
Items []wantArtistView `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Counts wantCounts `json:"counts"`
|
||||||
|
Library libraryListingView `json:"library"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryListingView: whether the library's albums are all on the list
|
||||||
|
// yet, and how many could not be matched to MusicBrainz (#5569); those are
|
||||||
|
// listed in the Collection's Unmatched section (#5579).
|
||||||
|
type libraryListingView struct {
|
||||||
|
ListedAt *string `json:"listed_at"`
|
||||||
|
Unlisted int32 `json:"unlisted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmatchedAlbumView is one album in the Collection's Unmatched section
|
||||||
|
// (#5579): why no MusicBrainz album matched it, or that a person left it
|
||||||
|
// off.
|
||||||
|
type unmatchedAlbumView struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Tracks int32 `json:"tracks"`
|
||||||
|
LeftOff bool `json:"left_off"`
|
||||||
|
DecidedAt string `json:"decided_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmatchedShown bounds the Unmatched section: a library a listing could
|
||||||
|
// not match more of than this has a bigger problem than one page shows.
|
||||||
|
const unmatchedShown = 500
|
||||||
|
|
||||||
|
const wantsPageSize = 50
|
||||||
|
|
||||||
|
// wantArtistsPageSize fills a few rows of the artist grid a page.
|
||||||
|
const wantArtistsPageSize = 96
|
||||||
|
|
||||||
|
// wantsHeader is what every Collection page carries: each section's count,
|
||||||
|
// and how the one-time library listing went.
|
||||||
|
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries) (wantCounts, libraryListingView, error) {
|
||||||
|
counts, err := q.CountWants(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return wantCounts{}, libraryListingView{}, err
|
||||||
|
}
|
||||||
|
listing, err := q.GetLibraryListing(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return wantCounts{}, libraryListingView{}, err
|
||||||
|
}
|
||||||
|
lib := libraryListingView{Unlisted: listing.LibraryUnlisted}
|
||||||
|
if listing.LibraryListedAt.Valid {
|
||||||
|
at := formatTimestamp(listing.LibraryListedAt)
|
||||||
|
lib.ListedAt = &at
|
||||||
|
}
|
||||||
|
return wantCounts{Artists: counts.Artists, Unmatched: counts.Unmatched, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
|
||||||
|
Upgrades: counts.Upgrades, Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func offsetParam(r *http.Request) int {
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
return max(offset, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
|
||||||
|
v := wantArtistView{
|
||||||
|
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
|
||||||
|
ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
|
||||||
|
Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
|
||||||
|
Downloading: row.Downloading, Following: row.Following,
|
||||||
|
}
|
||||||
|
if row.LibraryArtistID.Valid {
|
||||||
|
v.ArtistID = uuidToString(row.LibraryArtistID)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
|
||||||
|
// the Wanted view's main page, every artist on the list A–Z (#5572).
|
||||||
|
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
offset := offsetParam(r)
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
|
||||||
|
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, lib, err := h.wantsHeader(r, q)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
|
||||||
|
Counts: counts, Library: lib}
|
||||||
|
for _, row := range rows {
|
||||||
|
out.Total = row.Total
|
||||||
|
out.Items = append(out.Items, wantArtistViewOf(row))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListWants implements
|
||||||
|
// GET /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
|
||||||
|
// wanted is everything open but missing files; upgrade the held albums
|
||||||
|
// wanted for a better copy (#5524). Each list runs artist by
|
||||||
|
// artist, A–Z, like the library (#5572); q narrows it to an artist or
|
||||||
|
// album, and artist to one artist's page, which it then heads.
|
||||||
|
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
if state == "" {
|
||||||
|
state = "wanted"
|
||||||
|
}
|
||||||
|
if state != "wanted" && state != "missing" && state != "upgrade" && state != "held" && state != "all" {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, upgrade, held or all"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset := offsetParam(r)
|
||||||
|
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
|
||||||
|
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, lib, err := h.wantsHeader(r, q)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := wantsPage{Library: lib, Counts: counts,
|
||||||
|
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
|
||||||
|
if artist != "" {
|
||||||
|
heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(heads) == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("artist"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := wantArtistViewOf(heads[0])
|
||||||
|
out.Artist = &v
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
out.Total = row.Total
|
||||||
|
out.Items = append(out.Items, wantViewOf(row))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantViewOf is one ListWants row as the wanted list and the Missing page
|
||||||
|
// send it.
|
||||||
|
func wantViewOf(row dbq.ListWantsRow) wantView {
|
||||||
|
v := wantView{
|
||||||
|
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
||||||
|
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
||||||
|
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||||
|
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
||||||
|
WantedAt: formatTimestamp(row.WantedAt), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
|
||||||
|
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
||||||
|
}
|
||||||
|
if row.LibraryArtistID.Valid {
|
||||||
|
v.ArtistID = uuidToString(row.LibraryArtistID)
|
||||||
|
}
|
||||||
|
if row.AlbumID.Valid {
|
||||||
|
v.AlbumID = uuidToString(row.AlbumID)
|
||||||
|
}
|
||||||
|
if row.RequestedBy != nil {
|
||||||
|
v.RequestedBy = *row.RequestedBy
|
||||||
|
}
|
||||||
|
if row.FulfilledAt.Valid {
|
||||||
|
held := formatTimestamp(row.FulfilledAt)
|
||||||
|
v.HeldAt = &held
|
||||||
|
}
|
||||||
|
if v.SecondaryTypes == nil {
|
||||||
|
v.SecondaryTypes = []string{}
|
||||||
|
}
|
||||||
|
if row.SearchedAt.Valid {
|
||||||
|
v.Search = &wantSearchView{
|
||||||
|
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
|
||||||
|
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
|
||||||
|
BestVerdict: row.BestVerdict, Error: row.SearchError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.GrabName != nil {
|
||||||
|
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
|
||||||
|
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
|
||||||
|
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
|
||||||
|
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
|
||||||
|
EndDetail: deref(row.GrabEndDetail)}
|
||||||
|
if row.GrabProgress != nil {
|
||||||
|
v.Grab.Progress = *row.GrabProgress
|
||||||
|
}
|
||||||
|
if row.GrabImportedFiles != nil {
|
||||||
|
v.Grab.ImportedFiles = *row.GrabImportedFiles
|
||||||
|
}
|
||||||
|
if row.GrabImportedAt.Valid {
|
||||||
|
at := formatTimestamp(row.GrabImportedAt)
|
||||||
|
v.Grab.ImportedAt = &at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantTracksView is an album's track list for the Wanted view (#5572): the
|
||||||
|
// library's own tracks for an album it holds or held, else MusicBrainz's
|
||||||
|
// standard edition of what is wanted.
|
||||||
|
type wantTracksView struct {
|
||||||
|
// library, musicbrainz, or none when MusicBrainz lists no edition.
|
||||||
|
Source string `json:"source"`
|
||||||
|
// Edition names the MusicBrainz release the list is from.
|
||||||
|
Edition string `json:"edition,omitempty"`
|
||||||
|
Tracks []wantTrackView `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wantTrackView struct {
|
||||||
|
Disc int32 `json:"disc"`
|
||||||
|
Number int32 `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
LengthMs int32 `json:"length_ms"`
|
||||||
|
// Missing: the library's file for it is gone.
|
||||||
|
Missing bool `json:"missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWantTracks implements GET /api/admin/wanted/{id}/tracks.
|
||||||
|
func (h *handlers) handleWantTracks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
want, err := q.GetWantAlbum(r.Context(), id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeErr(w, apierror.NotFound("want"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: tracks", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := wantTracksView{Tracks: []wantTrackView{}}
|
||||||
|
if want.AlbumID.Valid {
|
||||||
|
rows, err := q.ListLibraryAlbumTracks(r.Context(), want.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: library tracks", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Source = "library"
|
||||||
|
for _, t := range rows {
|
||||||
|
v := wantTrackView{Title: t.Title, LengthMs: t.DurationMs, Missing: t.Missing}
|
||||||
|
if t.DiscNumber != nil {
|
||||||
|
v.Disc = *t.DiscNumber
|
||||||
|
}
|
||||||
|
if t.TrackNumber != nil {
|
||||||
|
v.Number = *t.TrackNumber
|
||||||
|
}
|
||||||
|
out.Tracks = append(out.Tracks, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eds, err := h.catalogue.Releases(r.Context(), want.ReleaseGroupMbid)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, catalogue.ErrNotFound):
|
||||||
|
out.Source = "none"
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return
|
||||||
|
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
|
||||||
|
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: editions", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, ok := standardEdition(eds.Editions)
|
||||||
|
if !ok {
|
||||||
|
out.Source = "none"
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Source = "musicbrainz"
|
||||||
|
out.Edition = editionLabel(e.Release)
|
||||||
|
for _, t := range e.Tracks {
|
||||||
|
out.Tracks = append(out.Tracks, wantTrackView{Disc: t.MediumPosition, Number: t.Position, Title: t.Title, LengthMs: t.LengthMs})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// standardEdition is the edition a person would call the album: an official
|
||||||
|
// one with the fewest tracks, else any with the fewest. The catalogue lists
|
||||||
|
// official editions first by date, so a tie goes to the earliest.
|
||||||
|
func standardEdition(eds []catalogue.Edition) (catalogue.Edition, bool) {
|
||||||
|
best := -1
|
||||||
|
for i, e := range eds {
|
||||||
|
if len(e.Tracks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
best = i
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := eds[best]
|
||||||
|
bo, eo := b.Release.Status == "Official", e.Release.Status == "Official"
|
||||||
|
if (eo && !bo) || (eo == bo && len(e.Tracks) < len(b.Tracks)) {
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return catalogue.Edition{}, false
|
||||||
|
}
|
||||||
|
return eds[best], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// editionLabel names a release for a person: "2023 · CD · GB".
|
||||||
|
func editionLabel(r dbq.MbRelease) string {
|
||||||
|
var parts []string
|
||||||
|
for _, p := range []string{first4(r.ReleaseDate), r.Formats, r.Country} {
|
||||||
|
if p != "" {
|
||||||
|
parts = append(parts, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func first4(date string) string {
|
||||||
|
if len(date) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return date[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
|
||||||
|
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removed, err := h.wanted.RemoveWant(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
writeErr(w, apierror.NotFound("want"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followView is a followed artist.
|
||||||
|
type followView struct {
|
||||||
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ProfileID string `json:"profile_id"`
|
||||||
|
ProfileName string `json:"profile_name"`
|
||||||
|
FollowedAt string `json:"followed_at"`
|
||||||
|
CheckedAt *string `json:"checked_at"`
|
||||||
|
OpenWants int64 `json:"open_wants"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListFollows implements GET /api/admin/follows.
|
||||||
|
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListFollows(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]followView, 0, len(rows))
|
||||||
|
for _, f := range rows {
|
||||||
|
v := followView{
|
||||||
|
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
|
||||||
|
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
||||||
|
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
|
||||||
|
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
|
||||||
|
}
|
||||||
|
if f.ArtistID.Valid {
|
||||||
|
v.ArtistID = uuidToString(f.ArtistID)
|
||||||
|
}
|
||||||
|
if f.CheckedAt.Valid {
|
||||||
|
checked := formatTimestamp(f.CheckedAt)
|
||||||
|
v.CheckedAt = &checked
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followStateView answers whether one artist is followed, for the control on
|
||||||
|
// an artist page.
|
||||||
|
type followStateView struct {
|
||||||
|
Following bool `json:"following"`
|
||||||
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
|
ProfileName string `json:"profile_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFollow implements GET /api/admin/follows/{mbid}.
|
||||||
|
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeJSON(w, http.StatusOK, followStateView{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, followStateView{
|
||||||
|
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type putFollowBody struct {
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
ProfileID string `json:"profile_id"`
|
||||||
|
WantExisting bool `json:"want_existing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
|
||||||
|
// or change the profile it is followed on. want_existing also wants what the
|
||||||
|
// artist has already released that the profile passes and isn't held.
|
||||||
|
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body putFollowBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var profile pgtype.UUID
|
||||||
|
if body.ProfileID != "" {
|
||||||
|
if profile, ok = parseUUID(body.ProfileID); !ok {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mbid := chi.URLParam(r, "mbid")
|
||||||
|
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
|
||||||
|
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
|
||||||
|
By: admin.ID, WantExisting: body.WantExisting,
|
||||||
|
}); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, wanted.ErrNotFollowable):
|
||||||
|
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
|
||||||
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||||
|
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleGetFollow(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
|
||||||
|
// follow already wanted stays on the list.
|
||||||
|
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
writeErr(w, apierror.NotFound("follow"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseProfileView is a release-type profile.
|
||||||
|
type releaseProfileView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PrimaryTypes []string `json:"primary_types"`
|
||||||
|
SecondaryTypes []string `json:"secondary_types"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
FollowCount int64 `json:"follow_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
|
||||||
|
// offers exactly what a profile may hold.
|
||||||
|
type releaseProfilesResponse struct {
|
||||||
|
Profiles []releaseProfileView `json:"profiles"`
|
||||||
|
PrimaryTypes []string `json:"primary_types"`
|
||||||
|
SecondaryTypes []string `json:"secondary_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
|
||||||
|
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := releaseProfilesResponse{
|
||||||
|
Profiles: make([]releaseProfileView, 0, len(rows)),
|
||||||
|
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
|
||||||
|
}
|
||||||
|
for _, p := range rows {
|
||||||
|
out.Profiles = append(out.Profiles, releaseProfileView{
|
||||||
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||||
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseProfileBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PrimaryTypes []string `json:"primary_types"`
|
||||||
|
SecondaryTypes []string `json:"secondary_types"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b releaseProfileBody) input() wanted.ProfileInput {
|
||||||
|
return wanted.ProfileInput{
|
||||||
|
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
|
||||||
|
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body releaseProfileBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.wanted.CreateProfile(r.Context(), body.input())
|
||||||
|
if err != nil {
|
||||||
|
h.writeProfileErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, releaseProfileView{
|
||||||
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||||
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
|
||||||
|
// A follow on the profile judges new releases by the new types from its next
|
||||||
|
// check; releases it has already judged are not judged again.
|
||||||
|
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body releaseProfileBody
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
|
||||||
|
if err != nil {
|
||||||
|
h.writeProfileErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, releaseProfileView{
|
||||||
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||||
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
|
||||||
|
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
|
||||||
|
h.writeProfileErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, wanted.ErrProfileInvalid):
|
||||||
|
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
|
||||||
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||||
|
writeErr(w, apierror.NotFound("profile"))
|
||||||
|
case errors.Is(err, wanted.ErrProfileNameUsed):
|
||||||
|
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
|
||||||
|
case errors.Is(err, wanted.ErrProfileInUse):
|
||||||
|
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
|
||||||
|
case errors.Is(err, wanted.ErrProfileDefault):
|
||||||
|
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
|
||||||
|
default:
|
||||||
|
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deref is a nullable column's text, or "".
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListUnmatched implements GET /api/admin/wanted/unmatched: the
|
||||||
|
// Collection's Unmatched section (#5579), the albums no MusicBrainz album
|
||||||
|
// matched and, after them, the ones a person left off.
|
||||||
|
func (h *handlers) handleListUnmatched(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := struct {
|
||||||
|
Items []unmatchedAlbumView `json:"items"`
|
||||||
|
}{Items: make([]unmatchedAlbumView, 0, len(rows))}
|
||||||
|
for _, u := range rows {
|
||||||
|
out.Items = append(out.Items, unmatchedAlbumView{
|
||||||
|
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||||
|
Tracks: u.Tracks, LeftOff: u.Outcome == "dismissed", DecidedAt: formatTimestamp(u.DecidedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualMatchView struct {
|
||||||
|
ReleaseGroupMBID string `json:"release_group_mbid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Agree int `json:"agree"`
|
||||||
|
Tracks int `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMatchUnmatched implements POST /api/admin/wanted/unmatched/{id}/match
|
||||||
|
// {"ref": "<MusicBrainz link or id>"}: the album goes on the list as held,
|
||||||
|
// as that MusicBrainz album.
|
||||||
|
func (h *handlers) handleMatchUnmatched(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "expected {\"ref\": \"…\"}"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := h.wanted.MatchAlbumTo(r.Context(), id, body.Ref)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, wanted.ErrMatchRef):
|
||||||
|
writeErr(w, apierror.BadRequest("bad_ref", "That isn't a MusicBrainz album link or id."))
|
||||||
|
return
|
||||||
|
case errors.Is(err, wanted.ErrMatchNoGroup):
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusUnprocessableEntity, Code: "no_such_album",
|
||||||
|
Message: "MusicBrainz has no album at that link.", Cause: err})
|
||||||
|
return
|
||||||
|
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
|
||||||
|
writeErr(w, apierror.NotFound("unmatched album"))
|
||||||
|
return
|
||||||
|
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "musicbrainz_unreachable",
|
||||||
|
Message: "MusicBrainz can't be reached", Cause: err})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: match by hand", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, manualMatchView{ReleaseGroupMBID: m.Group, Title: m.Title, Artist: m.Artist,
|
||||||
|
Agree: m.Agree, Tracks: m.Tracks})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLeaveUnmatchedOff implements POST /api/admin/wanted/unmatched/{id}/leave-off,
|
||||||
|
// and handlePutUnmatchedBack its undoing, POST …/{id}/put-back.
|
||||||
|
func (h *handlers) handleLeaveUnmatchedOff(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setUnmatched(w, r, h.wanted.LeaveAlbumOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handlePutUnmatchedBack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setUnmatched(w, r, h.wanted.PutAlbumBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) setUnmatched(w http.ResponseWriter, r *http.Request, set func(context.Context, pgtype.UUID) error) {
|
||||||
|
id, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := set(r.Context(), id)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
|
||||||
|
writeErr(w, apierror.NotFound("unmatched album"))
|
||||||
|
case err != nil:
|
||||||
|
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
|
)
|
||||||
|
|
||||||
|
func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return doWanted(t, h, http.MethodGet, path, "", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doWanted(t *testing.T, h *handlers, method, path, body string, user dbq.User) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/wanted", h.handleListWants)
|
||||||
|
admin.Get("/wanted/artists", h.handleListWantArtists)
|
||||||
|
admin.Get("/wanted/unmatched", h.handleListUnmatched)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
|
||||||
|
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
|
||||||
|
})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, withUser(httptest.NewRequest(method, path, strings.NewReader(body)), user))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want {
|
||||||
|
t.Helper()
|
||||||
|
w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{
|
||||||
|
GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title,
|
||||||
|
PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
|
||||||
|
t.Helper()
|
||||||
|
w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var page wantsPage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list runs like the library (#5572): artists A–Z by the library's sort
|
||||||
|
// 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, _ := testHandlers(t)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
beatles := "a0000000-0000-4000-8000-000000000002"
|
||||||
|
if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001")
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26")
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22")
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
|
||||||
|
|
||||||
|
page := listWanted(t, h, admin, "")
|
||||||
|
var got []string
|
||||||
|
for _, it := range page.Items {
|
||||||
|
got = append(got, it.ArtistName+"/"+it.Title)
|
||||||
|
}
|
||||||
|
want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]"
|
||||||
|
if fmt.Sprint(got) != want {
|
||||||
|
t.Errorf("order = %v, want %s", got, want)
|
||||||
|
}
|
||||||
|
if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" {
|
||||||
|
t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
page = listWanted(t, h, admin, "BEATLES")
|
||||||
|
if len(page.Items) != 2 || page.Total != 2 {
|
||||||
|
t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total)
|
||||||
|
}
|
||||||
|
page = listWanted(t, h, admin, "moon")
|
||||||
|
if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 {
|
||||||
|
t.Errorf("q=moon: %+v, total %d", page.Items, page.Total)
|
||||||
|
}
|
||||||
|
if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 {
|
||||||
|
t.Errorf("no match: %d items, total %d", len(page.Items), page.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Collection's catalogue (#5572): every artist on the list A–Z, each
|
||||||
|
// 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, _ := testHandlers(t)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
beatles := "a0000000-0000-4000-8000-000000000002"
|
||||||
|
camel := "a0000000-0000-4000-8000-000000000001"
|
||||||
|
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
|
||||||
|
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
|
||||||
|
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
|
||||||
|
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
|
||||||
|
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var page wantArtistsPage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
|
||||||
|
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
|
||||||
|
}
|
||||||
|
b, c := page.Items[0], page.Items[1]
|
||||||
|
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
|
||||||
|
b.ArtistID == "" || b.Following || b.ImageURL == "" {
|
||||||
|
t.Errorf("Beatles = %+v", b)
|
||||||
|
}
|
||||||
|
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
|
||||||
|
t.Errorf("Camel = %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// q finds an artist by an album's title.
|
||||||
|
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
|
||||||
|
page = wantArtistsPage{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
|
||||||
|
t.Errorf("q=moon: %+v", page.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Beatles' page: both albums, held or not, headed by the artist.
|
||||||
|
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
|
||||||
|
var albums wantsPage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
|
||||||
|
albums.Artist == nil || albums.Artist.Held != 1 {
|
||||||
|
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
|
||||||
|
}
|
||||||
|
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeEditions is a MusicBrainz holding one group with two editions, the
|
||||||
|
// shorter official one the standard, or 503 when down is set.
|
||||||
|
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if down {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/release-group/"+group:
|
||||||
|
_, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group)
|
||||||
|
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group:
|
||||||
|
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
|
||||||
|
{"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB",
|
||||||
|
"media": [{"position": 1, "format": "CD", "tracks": [
|
||||||
|
{"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
|
||||||
|
{"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}},
|
||||||
|
{"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]},
|
||||||
|
{"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB",
|
||||||
|
"media": [{"position": 1, "format": "12\" Vinyl", "tracks": [
|
||||||
|
{"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
|
||||||
|
{"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) {
|
||||||
|
t.Helper()
|
||||||
|
w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user)
|
||||||
|
var out wantTracksView
|
||||||
|
if w.Code == http.StatusOK {
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// An album not yet held shows MusicBrainz's standard edition: the shortest
|
||||||
|
// official one.
|
||||||
|
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
|
||||||
|
const group = "g0000000-0000-4000-8000-000000000010"
|
||||||
|
for _, down := range []bool{false, true} {
|
||||||
|
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)
|
||||||
|
w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
|
||||||
|
|
||||||
|
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
|
||||||
|
if down {
|
||||||
|
if code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("MusicBrainz down: status %d, want 503", code)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 {
|
||||||
|
t.Fatalf("status %d, %+v: want the two-track original", code, out)
|
||||||
|
}
|
||||||
|
if out.Edition != "1976 · 12\" Vinyl · GB" {
|
||||||
|
t.Errorf("edition = %q", out.Edition)
|
||||||
|
}
|
||||||
|
if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 {
|
||||||
|
t.Errorf("track 2 = %+v", tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A held album shows the library's own tracks, a lost file marked.
|
||||||
|
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
mbid := "a0000000-0000-4000-8000-000000000001"
|
||||||
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, title := range []string{"Aristillus", "Song Within a Song"} {
|
||||||
|
num := int32(i + 1)
|
||||||
|
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000,
|
||||||
|
FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976")
|
||||||
|
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
|
||||||
|
if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 {
|
||||||
|
t.Fatalf("status %d, %+v", code, out)
|
||||||
|
}
|
||||||
|
if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 {
|
||||||
|
t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Unmatched section (#5579): the albums matching could not place, a
|
||||||
|
// 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, _ := testHandlers(t)
|
||||||
|
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Annadale", SortName: "Annadale"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Dial Tone", SortTitle: "Dial Tone", ArtistID: artist.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: "Intro", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 120000,
|
||||||
|
FilePath: "/music/annadale/01.flac", FileSize: 1, FileFormat: "flac",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
|
||||||
|
AlbumID: album.ID, Outcome: "unmatched", Detail: "MusicBrainz has no album by that name", Matcher: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := uuidToString(album.ID)
|
||||||
|
|
||||||
|
list := func() []unmatchedAlbumView {
|
||||||
|
t.Helper()
|
||||||
|
w := doWantedReq(t, h, "/api/admin/wanted/unmatched", admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Items []unmatchedAlbumView `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out.Items
|
||||||
|
}
|
||||||
|
if got := list(); len(got) != 1 || got[0].AlbumID != id || got[0].Artist != "Annadale" || got[0].Tracks != 1 ||
|
||||||
|
got[0].LeftOff || got[0].Detail == "" || got[0].DecidedAt == "" {
|
||||||
|
t.Fatalf("unmatched = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
path, body string
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{"/api/admin/wanted/unmatched/" + id + "/match", `{"ref":"Dial Tone"}`, http.StatusBadRequest},
|
||||||
|
{"/api/admin/wanted/unmatched/" + id + "/match", `nope`, http.StatusBadRequest},
|
||||||
|
{"/api/admin/wanted/unmatched/not-a-uuid/leave-off", "", http.StatusBadRequest},
|
||||||
|
{"/api/admin/wanted/unmatched/" + id + "/put-back", "", http.StatusNotFound},
|
||||||
|
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNoContent},
|
||||||
|
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
if w := doWanted(t, h, http.MethodPost, c.path, c.body, admin); w.Code != c.code {
|
||||||
|
t.Errorf("POST %s %s = %d, want %d; body = %s", c.path, c.body, w.Code, c.code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := list(); len(got) != 1 || !got[0].LeftOff {
|
||||||
|
t.Fatalf("left off = %+v", got)
|
||||||
|
}
|
||||||
|
if w := doWanted(t, h, http.MethodPost, "/api/admin/wanted/unmatched/"+id+"/put-back", "", admin); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("put back = %d; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := list(); len(got) != 1 || got[0].LeftOff {
|
||||||
|
t.Errorf("put back = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := seedUser(t, h.pool, "listener", "pw", false)
|
||||||
|
if w := doWantedReq(t, h, "/api/admin/wanted/unmatched", user); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("a listener reads the unmatched section: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-33
@@ -13,30 +13,34 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
setupToken, err := auth.NewSetupToken()
|
setupToken, err := auth.NewSetupToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -45,13 +49,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
panic("api: mint setup token: " + err.Error())
|
panic("api: mint setup token: " + err.Error())
|
||||||
}
|
}
|
||||||
logSetupTokenIfNeeded(pool, logger, setupToken)
|
logSetupTokenIfNeeded(pool, logger, setupToken)
|
||||||
|
cat := catalogue.New(pool, nil, logger)
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
recSettings: recSettings,
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
requests: requestSvc,
|
||||||
lidarrRequests: lidarrReqs,
|
quarantine: quarantineSvc,
|
||||||
lidarrQuarantine: lidarrQuar,
|
|
||||||
tracks: tracksSvc,
|
tracks: tracksSvc,
|
||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
@@ -70,6 +74,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
fingerprintSettings: fpSettings,
|
fingerprintSettings: fpSettings,
|
||||||
loudnessSettings: loudSettings,
|
loudnessSettings: loudSettings,
|
||||||
acoustIDLookup: acoustIDLookup,
|
acoustIDLookup: acoustIDLookup,
|
||||||
|
acquisition: acquire.NewStore(pool),
|
||||||
|
catalogue: cat,
|
||||||
|
wanted: wanted.New(pool, cat, logger),
|
||||||
|
searcher: wanted.NewSearcher(pool, cat, logger),
|
||||||
|
importer: importer.New(pool, scanner, cat, nil, scannerRoots(scanner), logger),
|
||||||
|
lifecycle: lifecycle.New(pool, logger),
|
||||||
|
catalogueSearches: newSearchCache(),
|
||||||
|
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
|
||||||
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||||
librarySize: recommendation.NewLibrarySize(nil),
|
librarySize: recommendation.NewLibrarySize(nil),
|
||||||
loginGuard: auth.NewLoginGuard(),
|
loginGuard: auth.NewLoginGuard(),
|
||||||
@@ -182,7 +194,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/likes/artists", h.handleListLikedArtists)
|
authed.Get("/likes/artists", h.handleListLikedArtists)
|
||||||
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
||||||
|
|
||||||
authed.Get("/lidarr/search", h.handleLidarrSearch)
|
// The release catalogue (M502 #5519): request search asks
|
||||||
|
// MusicBrainz, and art for what isn't held comes through
|
||||||
|
// Minstrel from the enabled cover-art sources.
|
||||||
|
authed.Get("/catalogue/search", h.handleCatalogueSearch)
|
||||||
|
authed.Get("/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
|
||||||
|
|
||||||
authed.Post("/requests", h.handleCreateRequest)
|
authed.Post("/requests", h.handleCreateRequest)
|
||||||
authed.Get("/requests", h.handleListRequests)
|
authed.Get("/requests", h.handleListRequests)
|
||||||
@@ -211,23 +227,62 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
|
|
||||||
authed.Route("/admin", func(admin chi.Router) {
|
authed.Route("/admin", func(admin chi.Router) {
|
||||||
admin.Use(auth.RequireAdmin())
|
admin.Use(auth.RequireAdmin())
|
||||||
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
|
admin.Get("/acquisition/services", h.handleListAcquisitionServices)
|
||||||
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
|
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
|
||||||
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
|
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
|
||||||
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
|
admin.Delete("/acquisition/services/{code}", h.handleDeleteAcquisitionService)
|
||||||
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
admin.Post("/acquisition/services/{code}/test", h.handleTestAcquisitionService)
|
||||||
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
admin.Put("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
|
||||||
|
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
|
||||||
|
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
||||||
|
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
||||||
|
// The queues that wait on an admin, for the rail and the Overview (#5605).
|
||||||
|
admin.Get("/summary", h.handleGetAdminSummary)
|
||||||
|
admin.Get("/status", h.handleGetAdminStatus)
|
||||||
admin.Get("/requests", h.handleListAdminRequests)
|
admin.Get("/requests", h.handleListAdminRequests)
|
||||||
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
||||||
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
||||||
|
|
||||||
|
// The wanted list (M502 #5520): what Minstrel is looking
|
||||||
|
// for, the artists it follows, and the release-type
|
||||||
|
// profiles that decide which of their releases count.
|
||||||
|
admin.Get("/wanted", h.handleListWants)
|
||||||
|
admin.Get("/wanted/artists", h.handleListWantArtists)
|
||||||
|
admin.Get("/wanted/unmatched", h.handleListUnmatched)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
|
||||||
|
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
|
||||||
|
admin.Delete("/wanted/{id}", h.handleRemoveWant)
|
||||||
|
// Searching and grabbing (M502 #5521).
|
||||||
|
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
|
||||||
|
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
|
||||||
|
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
|
||||||
|
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
|
||||||
|
admin.Post("/wanted/{id}/search", h.handleSearchWant)
|
||||||
|
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
|
||||||
|
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
|
||||||
|
admin.Delete("/release-blocks/{hash}", h.handleUnblockRelease)
|
||||||
|
admin.Get("/release-blocks", h.handleListReleaseBlocks)
|
||||||
|
admin.Get("/downloads", h.handleListDownloads)
|
||||||
|
admin.Post("/downloads/{id}/import", h.handleImportAnyway)
|
||||||
|
admin.Post("/downloads/{id}/retry", h.handleRetryImport)
|
||||||
|
admin.Post("/downloads/{id}/refuse", h.handleRefuseDownload)
|
||||||
|
admin.Post("/downloads/{id}/abandon", h.handleAbandonDownload)
|
||||||
|
admin.Get("/follows", h.handleListFollows)
|
||||||
|
admin.Get("/follows/{mbid}", h.handleGetFollow)
|
||||||
|
admin.Put("/follows/{mbid}", h.handlePutFollow)
|
||||||
|
admin.Delete("/follows/{mbid}", h.handleDeleteFollow)
|
||||||
|
admin.Get("/release-profiles", h.handleListReleaseProfiles)
|
||||||
|
admin.Post("/release-profiles", h.handleCreateReleaseProfile)
|
||||||
|
admin.Put("/release-profiles/{id}", h.handleUpdateReleaseProfile)
|
||||||
|
admin.Delete("/release-profiles/{id}", h.handleDeleteReleaseProfile)
|
||||||
|
|
||||||
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
||||||
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||||||
|
|
||||||
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
||||||
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
||||||
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
||||||
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
|
||||||
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
||||||
|
|
||||||
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
||||||
@@ -238,13 +293,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
||||||
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
||||||
|
|
||||||
// Policy for turning a missing file back into a Lidarr
|
// Policy for wanting back an album whose files went missing
|
||||||
// request (#290). Sits beside the missing-files list it
|
// (#290). Sits beside the missing-files list it governs,
|
||||||
// governs rather than under /lidarr, because the operator
|
// because the operator meets it on that surface.
|
||||||
// meets it on the missing-files surface.
|
|
||||||
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
||||||
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
||||||
|
|
||||||
|
// Where the library lives (#6129). A changed folder moves its
|
||||||
|
// tracks rather than re-adding them.
|
||||||
|
admin.Get("/library/folders", h.handleGetLibraryFolders)
|
||||||
|
admin.Put("/library/folders", h.handlePutLibraryFolders)
|
||||||
|
|
||||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
// Sits under /library rather than /tracks because what it
|
// Sits under /library rather than /tracks because what it
|
||||||
@@ -274,6 +333,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
||||||
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
||||||
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
||||||
|
admin.Post("/library/duplicates/{id}/refetch", h.handleRefetchDuplicateGroup)
|
||||||
|
|
||||||
admin.Get("/invites", h.handleListInvites)
|
admin.Get("/invites", h.handleListInvites)
|
||||||
admin.Post("/invites", h.handleCreateInvite)
|
admin.Post("/invites", h.handleCreateInvite)
|
||||||
@@ -340,20 +400,19 @@ type handlers struct {
|
|||||||
// librarySize memoises the track count that sizes the candidate pool
|
// librarySize memoises the track count that sizes the candidate pool
|
||||||
// (#3880). Held here rather than counted per request: the count is a
|
// (#3880). Held here rather than counted per request: the count is a
|
||||||
// full table scan, and library size only moves when a scan runs.
|
// full table scan, and library size only moves when a scan runs.
|
||||||
librarySize *recommendation.LibrarySize
|
librarySize *recommendation.LibrarySize
|
||||||
lidarrCfg *lidarrconfig.Service
|
requests *requests.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
quarantine *quarantine.Service
|
||||||
lidarrQuarantine *lidarrquarantine.Service
|
tracks *tracks.Service
|
||||||
tracks *tracks.Service
|
playlists *playlists.Service
|
||||||
playlists *playlists.Service
|
coverart *coverart.Enricher
|
||||||
coverart *coverart.Enricher
|
coverSettings *coverart.SettingsService
|
||||||
coverSettings *coverart.SettingsService
|
tagSettings *tags.SettingsService
|
||||||
tagSettings *tags.SettingsService
|
scanner *library.Scanner
|
||||||
scanner *library.Scanner
|
scanCfg library.RunScanConfig
|
||||||
scanCfg library.RunScanConfig
|
dataDir string
|
||||||
dataDir string
|
mailer mailer.Sender
|
||||||
mailer mailer.Sender
|
eventbus *eventbus.Bus
|
||||||
eventbus *eventbus.Bus
|
|
||||||
// notifier writes the notifications inbox (M489). Nil-safe: a nil
|
// notifier writes the notifications inbox (M489). Nil-safe: a nil
|
||||||
// notifier records nothing, which is what most handler tests want.
|
// notifier records nothing, which is what most handler tests want.
|
||||||
notifier *notifications.Notifier
|
notifier *notifications.Notifier
|
||||||
@@ -372,6 +431,26 @@ type handlers struct {
|
|||||||
// acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
|
// acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
|
||||||
// status and "look up now". Nil serves the defaults (off).
|
// status and "look up now". Nil serves the defaults (off).
|
||||||
acoustIDLookup *library.AcoustIDLookupWorker
|
acoustIDLookup *library.AcoustIDLookupWorker
|
||||||
|
// acquisition is the operator's indexers and download clients (M502).
|
||||||
|
acquisition *acquire.Store
|
||||||
|
// catalogue is the release catalogue read from MusicBrainz, and
|
||||||
|
// catalogueArt the images for what isn't held (M502 #5519).
|
||||||
|
catalogue *catalogue.Service
|
||||||
|
catalogueArt *catalogue.Art
|
||||||
|
// wanted is the wanted list: follows, release-type profiles and wants
|
||||||
|
// (M502 #5520). Its worker runs in main; the API reads and edits.
|
||||||
|
wanted *wanted.Service
|
||||||
|
// searcher searches for wants and grabs releases for them (#5521).
|
||||||
|
searcher *wanted.Searcher
|
||||||
|
// importer refuses a download at a person's word (#5522). The import
|
||||||
|
// loop itself runs in main; this one only acts on a request.
|
||||||
|
importer *importer.Importer
|
||||||
|
// lifecycle gives up on a download at a person's word (#5523); its loop
|
||||||
|
// runs in main.
|
||||||
|
lifecycle *lifecycle.Service
|
||||||
|
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
|
||||||
|
// time, which is what handler tests get.
|
||||||
|
catalogueSearches *searchCache
|
||||||
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
||||||
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
||||||
// request then gets the plain stream.
|
// request then gets the plain stream.
|
||||||
@@ -414,3 +493,24 @@ func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsServic
|
|||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artSources hands the art cache the cover-art settings, or nothing when
|
||||||
|
// there are none: a nil *SettingsService inside the interface would not
|
||||||
|
// compare equal to nil and would be called.
|
||||||
|
func artSources(s *coverart.SettingsService) catalogue.ArtSources {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// scannerRoots reads the library folders, or none when the API runs without
|
||||||
|
// a scanner (tests).
|
||||||
|
func scannerRoots(s *library.Scanner) func() []string {
|
||||||
|
return func() []string {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Roots()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
|
|||||||
// errNoPublicURL stops a reset email from going out before the operator has
|
// errNoPublicURL stops a reset email from going out before the operator has
|
||||||
// said where Minstrel lives. It surfaces in the log, not the response, which
|
// said where Minstrel lives. It surfaces in the log, not the response, which
|
||||||
// stays the same opaque 200 either way.
|
// stays the same opaque 200 either way.
|
||||||
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
|
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
|
||||||
|
|
||||||
// buildResetURL builds the emailed link from the operator-set public URL.
|
// buildResetURL builds the emailed link from the operator-set public URL.
|
||||||
// It deliberately ignores the request: the Host header is whatever the
|
// It deliberately ignores the request: the Host header is whatever the
|
||||||
|
|||||||
@@ -18,20 +18,21 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
|
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
|
||||||
@@ -64,16 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recsettings: %v", err)
|
t.Fatalf("recsettings: %v", err)
|
||||||
}
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
requestSvc := requests.NewService(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
cat := catalogue.New(pool, nil, logger)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
|
quarantineSvc := quarantine.NewService(pool, "")
|
||||||
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
|
||||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
|
||||||
// when they need a stubbed Lidarr.
|
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogueSearchLimit is how many results one search shows.
|
||||||
|
const catalogueSearchLimit = 25
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
type catalogueSearchResult struct {
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SecondaryText string `json:"secondary_text"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
|
AlbumMBID string `json:"album_mbid"`
|
||||||
|
InLibrary bool `json:"in_library"`
|
||||||
|
Requested bool `json:"requested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCatalogueSearch implements GET /api/catalogue/search?q=&kind=artist|album|track
|
||||||
|
// (M502 #5519), asking MusicBrainz directly. An unreachable MusicBrainz is a
|
||||||
|
// 503 the page shows as such (rule 164).
|
||||||
|
func (h *handlers) handleCatalogueSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
||||||
|
switch kind {
|
||||||
|
case "artist", "album", "track":
|
||||||
|
default:
|
||||||
|
writeErr(w, apierror.BadRequest("bad_kind", "kind must be artist, album, or track"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if q == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("missing_query", "q is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := h.catalogueSearches.get(r.Context(), kind, q, h.searchCatalogue)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
|
||||||
|
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
|
||||||
|
case r.Context().Err() != nil:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
h.logger.Error("api: catalogue search", "kind", kind, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbQ := dbq.New(h.pool)
|
||||||
|
for i := range out {
|
||||||
|
inLib, err := h.catalogueInLibrary(r.Context(), kind, out[i].MBID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.InternalMsg("library check failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requested, err := dbQ.HasNonTerminalRequestForMBID(r.Context(), out[i].MBID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.InternalMsg("request check failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out[i].InLibrary, out[i].Requested = inLib, requested
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) searchCatalogue(ctx context.Context, kind, q string) ([]catalogueSearchResult, error) {
|
||||||
|
mb := h.catalogue.MusicBrainz()
|
||||||
|
out := []catalogueSearchResult{}
|
||||||
|
switch kind {
|
||||||
|
case "artist":
|
||||||
|
artists, err := mb.SearchArtists(ctx, q, catalogueSearchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range artists {
|
||||||
|
out = append(out, catalogueSearchResult{
|
||||||
|
MBID: a.MBID, Name: a.Name, SecondaryText: artistLine(a),
|
||||||
|
ImageURL: artistArtURL(a.MBID, a.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "album":
|
||||||
|
groups, err := mb.SearchReleaseGroups(ctx, q, catalogueSearchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
out = append(out, catalogueSearchResult{
|
||||||
|
MBID: g.MBID, Name: g.Title, SecondaryText: joinNonEmpty(g.ArtistCredit, g.Year(), groupType(g)),
|
||||||
|
ImageURL: groupArtURL(g), ArtistMBID: first(g.ArtistMBIDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "track":
|
||||||
|
recs, err := mb.SearchRecordings(ctx, q, catalogueSearchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
res := catalogueSearchResult{
|
||||||
|
MBID: rec.MBID, Name: rec.Title,
|
||||||
|
SecondaryText: joinNonEmpty(rec.ReleaseGroup.Title, rec.ArtistCredit),
|
||||||
|
ArtistMBID: first(rec.ArtistMBIDs), AlbumMBID: rec.ReleaseGroup.MBID,
|
||||||
|
}
|
||||||
|
if rec.ReleaseGroup.MBID != "" {
|
||||||
|
res.ImageURL = groupArtURL(rec.ReleaseGroup)
|
||||||
|
}
|
||||||
|
out = append(out, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueSearchTTL is how long a search's MusicBrainz answer is reused.
|
||||||
|
// MusicBrainz allows a request a second, and Discover searches as the user
|
||||||
|
// types, so the same words are often asked again within moments.
|
||||||
|
const catalogueSearchTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// catalogueSearchCacheMax bounds the cache; past it, the cache starts over.
|
||||||
|
const catalogueSearchCacheMax = 500
|
||||||
|
|
||||||
|
// searchCache keeps recent MusicBrainz answers by kind and query. A nil
|
||||||
|
// cache asks every time. Whether a result is held or requested is not
|
||||||
|
// cached: the handler works that out on every request.
|
||||||
|
type searchCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]searchEntry
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchEntry struct {
|
||||||
|
at time.Time
|
||||||
|
results []catalogueSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSearchCache() *searchCache {
|
||||||
|
return &searchCache{entries: map[string]searchEntry{}, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *searchCache) get(ctx context.Context, kind, q string,
|
||||||
|
ask func(context.Context, string, string) ([]catalogueSearchResult, error),
|
||||||
|
) ([]catalogueSearchResult, error) {
|
||||||
|
if c == nil {
|
||||||
|
return ask(ctx, kind, q)
|
||||||
|
}
|
||||||
|
key := kind + "\x00" + strings.ToLower(q)
|
||||||
|
c.mu.Lock()
|
||||||
|
e, ok := c.entries[key]
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok && c.now().Sub(e.at) < catalogueSearchTTL {
|
||||||
|
return slices.Clone(e.results), nil
|
||||||
|
}
|
||||||
|
results, err := ask(ctx, kind, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if len(c.entries) >= catalogueSearchCacheMax {
|
||||||
|
c.entries = map[string]searchEntry{}
|
||||||
|
}
|
||||||
|
c.entries[key] = searchEntry{at: c.now(), results: slices.Clone(results)}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistLine is what tells two artists of one name apart: MusicBrainz's own
|
||||||
|
// disambiguation when it has one, else the kind of artist and the country.
|
||||||
|
func artistLine(a musicbrainz.Artist) string {
|
||||||
|
if a.Disambiguation != "" {
|
||||||
|
return a.Disambiguation
|
||||||
|
}
|
||||||
|
return joinNonEmpty(a.Type, a.Country)
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupType names an album's kind the way people do: "Album", "EP",
|
||||||
|
// "Album · Live", "Single · Remix".
|
||||||
|
func groupType(g musicbrainz.ReleaseGroup) string {
|
||||||
|
return joinNonEmpty(append([]string{g.PrimaryType}, g.SecondaryTypes...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinNonEmpty(parts ...string) string {
|
||||||
|
kept := parts[:0:0]
|
||||||
|
for _, p := range parts {
|
||||||
|
if p != "" {
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(s []string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// artistArtURL and groupArtURL point at Minstrel's own art endpoints. The
|
||||||
|
// names ride along for the sources that look art up by name.
|
||||||
|
func artistArtURL(mbid, name string) string {
|
||||||
|
if mbid == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/api/catalogue/art/artist/" + mbid + "?" + url.Values{"name": {name}}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupArtURL(g musicbrainz.ReleaseGroup) string {
|
||||||
|
return "/api/catalogue/art/release-group/" + g.MBID + "?" +
|
||||||
|
url.Values{"artist": {g.ArtistCredit}, "title": {g.Title}}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueInLibrary reports whether a result is already held: an artist by
|
||||||
|
// its MBID, an album by its release group (or a release id that happens to be
|
||||||
|
// the same), a track by its recording.
|
||||||
|
func (h *handlers) catalogueInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
|
||||||
|
var query string
|
||||||
|
switch kind {
|
||||||
|
case "artist":
|
||||||
|
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
|
||||||
|
case "album":
|
||||||
|
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE release_group_mbid = $1 OR mbid = $1)`
|
||||||
|
case "track":
|
||||||
|
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCatalogueArt implements GET /api/catalogue/art/{kind}/{mbid} for an
|
||||||
|
// artist or a release group Minstrel doesn't hold. The image comes from the
|
||||||
|
// operator's enabled cover-art sources and is kept on disk, so the browser
|
||||||
|
// never fetches a third-party host. A 404 shows the placeholder.
|
||||||
|
func (h *handlers) handleCatalogueArt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kind, mbid := chi.URLParam(r, "kind"), chi.URLParam(r, "mbid")
|
||||||
|
if !catalogue.ValidMBID(mbid) {
|
||||||
|
writeErr(w, apierror.NotFound("art"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
body []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch kind {
|
||||||
|
case "artist":
|
||||||
|
body, err = h.catalogueArt.Artist(r.Context(), mbid, r.URL.Query().Get("name"))
|
||||||
|
case "release-group":
|
||||||
|
body, err = h.catalogueArt.ReleaseGroup(r.Context(), mbid, r.URL.Query().Get("artist"), r.URL.Query().Get("title"))
|
||||||
|
default:
|
||||||
|
writeErr(w, apierror.NotFound("art"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if r.Context().Err() == nil {
|
||||||
|
writeErr(w, apierror.NotFound("art"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", http.DetectContentType(body))
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
searchArtistMBID = "a74b1b7f-71a5-4011-9441-d0b5e4122711"
|
||||||
|
searchGroupMBID = "b1392450-e666-3926-a536-22c65f834433"
|
||||||
|
searchRecMBID = "c1a0e1b2-0000-4000-8000-000000000001"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeMusicBrainz answers the three searches with one Radiohead result each,
|
||||||
|
// or 503 when down is set.
|
||||||
|
func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if down {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
credit := `[{"name": "Radiohead", "artist": {"id": "` + searchArtistMBID + `"}}]`
|
||||||
|
group := `{"id": "` + searchGroupMBID + `", "title": "OK Computer", "primary-type": "Album",
|
||||||
|
"first-release-date": "1997-05-21", "artist-credit": ` + credit + `}`
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/artist":
|
||||||
|
_, _ = w.Write([]byte(`{"artists": [{"id": "` + searchArtistMBID + `", "name": "Radiohead", "type": "Group", "country": "GB"}]}`))
|
||||||
|
case "/release-group":
|
||||||
|
_, _ = w.Write([]byte(`{"release-groups": [` + group + `]}`))
|
||||||
|
case "/recording":
|
||||||
|
_, _ = w.Write([]byte(`{"recordings": [{"id": "` + searchRecMBID + `", "title": "Karma Police",
|
||||||
|
"artist-credit": ` + credit + `,
|
||||||
|
"releases": [{"id": "r1", "status": "Official", "release-group": ` + group + `}]}]}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogueHandlers(t *testing.T, down bool) *handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func doCatalogueSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/catalogue/search?"+url.Values{"q": {q}, "kind": {kind}}.Encode(), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleCatalogueSearch(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResults(t *testing.T, w *httptest.ResponseRecorder) []catalogueSearchResult {
|
||||||
|
t.Helper()
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var out []catalogueSearchResult
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorCode(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var resp struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return resp.Error.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogueSearch_RefusesABadKindOrNoQuery(t *testing.T) {
|
||||||
|
h := catalogueHandlers(t, false)
|
||||||
|
if w := doCatalogueSearch(t, h, "x", "playlist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "bad_kind" {
|
||||||
|
t.Errorf("bad kind: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := doCatalogueSearch(t, h, " ", "artist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "missing_query" {
|
||||||
|
t.Errorf("no query: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogueSearch_EachKindAsksMusicBrainz(t *testing.T) {
|
||||||
|
h := catalogueHandlers(t, false)
|
||||||
|
|
||||||
|
artists := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist"))
|
||||||
|
if len(artists) != 1 || artists[0].MBID != searchArtistMBID || artists[0].SecondaryText != "Group · GB" {
|
||||||
|
t.Fatalf("artists = %+v", artists)
|
||||||
|
}
|
||||||
|
if artists[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Radiohead" {
|
||||||
|
t.Errorf("artist art = %q: it must be Minstrel's own endpoint", artists[0].ImageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album"))
|
||||||
|
if len(albums) != 1 || albums[0].MBID != searchGroupMBID || albums[0].ArtistMBID != searchArtistMBID ||
|
||||||
|
albums[0].SecondaryText != "Radiohead · 1997 · Album" {
|
||||||
|
t.Fatalf("albums = %+v", albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
|
||||||
|
if len(tracks) != 1 || tracks[0].AlbumMBID != searchGroupMBID || tracks[0].ArtistMBID != searchArtistMBID ||
|
||||||
|
tracks[0].SecondaryText != "OK Computer · Radiohead" {
|
||||||
|
t.Fatalf("tracks = %+v", tracks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) {
|
||||||
|
h := catalogueHandlers(t, false)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
|
artistMBID := searchArtistMBID
|
||||||
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: dbtest.TestUserPrefix + "Radiohead", SortName: "Radiohead", Mbid: &artistMBID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The album is held under a release id; its release group is what matches.
|
||||||
|
if _, err := h.pool.Exec(ctx,
|
||||||
|
`INSERT INTO albums (title, sort_title, artist_id, mbid, release_group_mbid) VALUES ('OK Computer', 'OK Computer', $1, 'some-release-id', $2)`,
|
||||||
|
artist.ID, searchGroupMBID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist")); !got[0].InLibrary {
|
||||||
|
t.Error("the artist is held")
|
||||||
|
}
|
||||||
|
if got := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album")); !got[0].InLibrary {
|
||||||
|
t.Error("the album is held, matched by release group")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := seedUser(t, h.pool, "requester", "pw", false)
|
||||||
|
track := searchRecMBID
|
||||||
|
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
|
||||||
|
UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID,
|
||||||
|
TrackMbid: &track, ArtistName: "Radiohead",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
|
||||||
|
if got[0].InLibrary || !got[0].Requested {
|
||||||
|
t.Errorf("track = %+v, want requested and not held", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogueSearch_UnreachableMusicBrainzIs503(t *testing.T) {
|
||||||
|
h := catalogueHandlers(t, true)
|
||||||
|
w := doCatalogueSearch(t, h, "radiohead", "artist")
|
||||||
|
if w.Code != http.StatusServiceUnavailable || errorCode(t, w) != "musicbrainz_unreachable" {
|
||||||
|
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// artSource is an artist-art source with one image.
|
||||||
|
type artSource struct{ apiTestAlbumProvider }
|
||||||
|
|
||||||
|
func (artSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) {
|
||||||
|
return []byte("\x89PNG\r\n\x1a\nrest"), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeArtSources struct{ artists []coverart.ArtistArtProvider }
|
||||||
|
|
||||||
|
func (s fakeArtSources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists }
|
||||||
|
func (fakeArtSources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return nil }
|
||||||
|
|
||||||
|
func TestCatalogueArt_ServesTheImageOrAPlaceholder404(t *testing.T) {
|
||||||
|
h := &handlers{catalogueArt: catalogue.NewArt(t.TempDir(), fakeArtSources{artists: []coverart.ArtistArtProvider{&artSource{}}})}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
|
||||||
|
|
||||||
|
get := func(path string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if w := get("/api/catalogue/art/artist/" + searchArtistMBID + "?name=Radiohead"); w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/png" {
|
||||||
|
t.Errorf("artist: %d %q", w.Code, w.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
// No album source is enabled: a 404, which the page shows as its placeholder.
|
||||||
|
if w := get("/api/catalogue/art/release-group/" + searchGroupMBID); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("release group with no source: %d", w.Code)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"/api/catalogue/art/artist/not-an-id", "/api/catalogue/art/label/" + searchArtistMBID} {
|
||||||
|
if w := get(bad); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("%s: %d", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestionArt_PointsAtMinstrelsOwnEndpoint(t *testing.T) {
|
||||||
|
views := []suggestionView{{MBID: searchArtistMBID, Name: "Sigur Rós"}, {Name: "No MBID"}}
|
||||||
|
resolveSuggestionArt(views)
|
||||||
|
if views[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Sigur+R%C3%B3s" {
|
||||||
|
t.Errorf("image_url = %q", views[0].ImageURL)
|
||||||
|
}
|
||||||
|
if views[1].ImageURL != "" {
|
||||||
|
t.Errorf("no MBID, no image: %q", views[1].ImageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCache_ReusesAnAnswerButNotTheFlags(t *testing.T) {
|
||||||
|
c := newSearchCache()
|
||||||
|
now := time.Now()
|
||||||
|
c.now = func() time.Time { return now }
|
||||||
|
asks := 0
|
||||||
|
ask := func(context.Context, string, string) ([]catalogueSearchResult, error) {
|
||||||
|
asks++
|
||||||
|
return []catalogueSearchResult{{MBID: "m"}}, nil
|
||||||
|
}
|
||||||
|
first, _ := c.get(context.Background(), "artist", "Radiohead", ask)
|
||||||
|
first[0].InLibrary = true // the handler sets flags on what it is given
|
||||||
|
again, _ := c.get(context.Background(), "artist", "radiohead", ask)
|
||||||
|
if asks != 1 {
|
||||||
|
t.Errorf("asks = %d: the same words in another case are the same search", asks)
|
||||||
|
}
|
||||||
|
if again[0].InLibrary {
|
||||||
|
t.Error("a flag set on one answer leaked into the cache")
|
||||||
|
}
|
||||||
|
if _, _ = c.get(context.Background(), "album", "radiohead", ask); asks != 2 {
|
||||||
|
t.Error("another kind is another search")
|
||||||
|
}
|
||||||
|
now = now.Add(catalogueSearchTTL + time.Second)
|
||||||
|
if _, _ = c.get(context.Background(), "artist", "radiohead", ask); asks != 3 {
|
||||||
|
t.Error("an old answer is asked again")
|
||||||
|
}
|
||||||
|
var none *searchCache
|
||||||
|
if _, _ = none.get(context.Background(), "artist", "x", ask); asks != 4 {
|
||||||
|
t.Error("a nil cache asks every time")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(body)
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
|
||||||
|
// mirrors the shape the RequireAdmin middleware uses and that the spec
|
||||||
|
// defines for /api/admin/* errors.
|
||||||
|
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publishQuarantineEvent broadcasts a quarantine action. User-side
|
// publishQuarantineEvent broadcasts a quarantine action. User-side
|
||||||
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
|
// flag/unflag are scoped to the user; admin-side resolve / delete-file
|
||||||
// delete-via-lidarr are broadcast (empty UserID) because a single admin
|
// are broadcast (empty UserID) because a single admin
|
||||||
// action can affect every user who'd flagged that track — every client
|
// action can affect every user who'd flagged that track — every client
|
||||||
// invalidates and the non-affected ones no-op when the query result
|
// invalidates and the non-affected ones no-op when the query result
|
||||||
// matches their cache.
|
// matches their cache.
|
||||||
@@ -103,12 +103,12 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
// publishRequestStatusChanged broadcasts a request status flip to
|
||||||
// the request's original requester so their /requests page reflects the
|
// the request's original requester so their /requests page reflects the
|
||||||
// new state without manual refresh. Admin actors (approve / reject) still
|
// new state without manual refresh. Admin actors (approve / reject) still
|
||||||
// route to the requester's user_id, not the admin's — the original user
|
// route to the requester's user_id, not the admin's — the original user
|
||||||
// is who needs to see the update.
|
// is who needs to see the update.
|
||||||
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
|
func (h *handlers) publishRequestStatusChanged(row dbq.Request) {
|
||||||
if h.eventbus == nil {
|
if h.eventbus == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
||||||
|
|
||||||
// removeFailure builds the error a delete service returns when the file would
|
// removeFailure builds the error a delete service returns when the file would
|
||||||
// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
|
// not go, wrapped the way quarantine.DeleteFile and tracks.RemoveTrack
|
||||||
// wrap it — the mapping has to see through that.
|
// wrap it — the mapping has to see through that.
|
||||||
func removeFailure(errno syscall.Errno) error {
|
func removeFailure(errno syscall.Errno) error {
|
||||||
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
Albums: refs,
|
Albums: refs,
|
||||||
Genres: nonNilStrings(genres),
|
Genres: nonNilStrings(genres),
|
||||||
}
|
}
|
||||||
|
if artist.Mbid != nil {
|
||||||
|
detail.MBID = *artist.Mbid
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
@@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
|
|||||||
FileSize: 1, FileFormat: "mp3",
|
FileSize: 1, FileFormat: "mp3",
|
||||||
})
|
})
|
||||||
if _, err := pool.Exec(context.Background(),
|
if _, err := pool.Exec(context.Background(),
|
||||||
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
||||||
user.ID, track.ID,
|
user.ID, track.ID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("quarantine insert: %v", err)
|
t.Fatalf("quarantine insert: %v", err)
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lidarrSearchResult is the JSON shape returned by GET /api/lidarr/search.
|
|
||||||
// Field names follow the lowercase snake_case convention used by all /api/*
|
|
||||||
// responses. artist_mbid and album_mbid are the parent identifiers surfaced
|
|
||||||
// by LookupAlbum / LookupTrack; they are empty strings on artist results.
|
|
||||||
type lidarrSearchResult struct {
|
|
||||||
MBID string `json:"mbid"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SecondaryText string `json:"secondary_text"`
|
|
||||||
ImageURL string `json:"image_url"`
|
|
||||||
ArtistMBID string `json:"artist_mbid"`
|
|
||||||
AlbumMBID string `json:"album_mbid"`
|
|
||||||
InLibrary bool `json:"in_library"`
|
|
||||||
Requested bool `json:"requested"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLidarrSearch implements GET /api/lidarr/search?q=&kind=artist|album|track.
|
|
||||||
//
|
|
||||||
// Steps:
|
|
||||||
// 1. Validate kind and q params.
|
|
||||||
// 2. Load lidarr config; 503 when disabled.
|
|
||||||
// 3. Call the matching Lidarr lookup.
|
|
||||||
// 4. For each result, compute in_library (EXISTS by MBID) and requested
|
|
||||||
// (HasNonTerminalRequestForMBID).
|
|
||||||
// 5. Return normalized JSON array.
|
|
||||||
func (h *handlers) handleLidarrSearch(w http.ResponseWriter, r *http.Request) {
|
|
||||||
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
|
||||||
switch kind {
|
|
||||||
case "artist", "album", "track":
|
|
||||||
// valid
|
|
||||||
default:
|
|
||||||
writeErr(w, apierror.BadRequest("bad_kind", "kind must be artist, album, or track"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
||||||
if q == "" {
|
|
||||||
writeErr(w, apierror.BadRequest("missing_query", "q is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := h.lidarrCfg.Get(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("api: lidarr search: load config", "err", err)
|
|
||||||
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "failed to load lidarr config", Cause: err})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !cfg.Enabled {
|
|
||||||
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_disabled", Message: "Lidarr integration is not enabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
|
||||||
|
|
||||||
var results []lidarr.LookupResult
|
|
||||||
switch kind {
|
|
||||||
case "artist":
|
|
||||||
results, err = client.LookupArtist(r.Context(), q)
|
|
||||||
case "album":
|
|
||||||
results, err = client.LookupAlbum(r.Context(), q)
|
|
||||||
case "track":
|
|
||||||
results, err = client.LookupTrack(r.Context(), q)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, lidarr.ErrUnreachable):
|
|
||||||
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_unreachable", Message: "Lidarr is unreachable", Cause: err})
|
|
||||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
|
||||||
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_auth_failed", Message: "Lidarr authentication failed", Cause: err})
|
|
||||||
default:
|
|
||||||
h.logger.Error("api: lidarr search: lookup failed", "kind", kind, "err", err)
|
|
||||||
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "Lidarr lookup failed", Cause: err})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dbQ := dbq.New(h.pool)
|
|
||||||
out := make([]lidarrSearchResult, 0, len(results))
|
|
||||||
for _, res := range results {
|
|
||||||
inLib, libErr := h.lidarrInLibrary(r.Context(), kind, res.MBID)
|
|
||||||
if libErr != nil {
|
|
||||||
h.logger.Error("api: lidarr search: in_library check failed", "err", libErr)
|
|
||||||
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "library check failed", Cause: libErr})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requested, reqErr := dbQ.HasNonTerminalRequestForMBID(r.Context(), res.MBID)
|
|
||||||
if reqErr != nil {
|
|
||||||
h.logger.Error("api: lidarr search: requested check failed", "err", reqErr)
|
|
||||||
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "request check failed", Cause: reqErr})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out = append(out, lidarrSearchResult{
|
|
||||||
MBID: res.MBID,
|
|
||||||
Name: res.Name,
|
|
||||||
SecondaryText: res.Secondary,
|
|
||||||
ImageURL: res.ImageURL,
|
|
||||||
ArtistMBID: res.ArtistMBID,
|
|
||||||
AlbumMBID: res.AlbumMBID,
|
|
||||||
InLibrary: inLib,
|
|
||||||
Requested: requested,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// lidarrInLibrary checks whether the given MBID exists in the corresponding
|
|
||||||
// local library table based on kind. Runs a direct EXISTS query since
|
|
||||||
// these per-kind MBID lookups are not in the sqlc-generated query set.
|
|
||||||
func (h *handlers) lidarrInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
|
|
||||||
var query string
|
|
||||||
switch kind {
|
|
||||||
case "artist":
|
|
||||||
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
|
|
||||||
case "album":
|
|
||||||
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE mbid = $1)`
|
|
||||||
case "track":
|
|
||||||
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
|
|
||||||
default:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
var exists bool
|
|
||||||
if err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return exists, nil
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
// resetLidarrState clears lidarr_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 lidarr_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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lidarrArtistStubBody returns a minimal Lidarr artist lookup JSON payload.
|
|
||||||
func lidarrArtistStubBody(mbid, name string) string {
|
|
||||||
return fmt.Sprintf(`[{"foreignArtistId":%q,"artistName":%q,"genres":["Rock"],"albumCount":5,"images":[]}]`, mbid, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedArtistWithMBID inserts an artist row with a specific MBID for in_library testing.
|
|
||||||
func seedArtistWithMBID(t *testing.T, h *handlers, name, mbid string) dbq.Artist {
|
|
||||||
t.Helper()
|
|
||||||
a, err := dbq.New(h.pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
||||||
Name: name,
|
|
||||||
SortName: name,
|
|
||||||
Mbid: &mbid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seedArtistWithMBID: %v", err)
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedRequestForMBID inserts a pending lidarr_requests row for the given MBID.
|
|
||||||
func seedRequestForMBID(t *testing.T, h *handlers, userID pgtype.UUID, kind dbq.LidarrRequestKind, artistMBID, mbid string) {
|
|
||||||
t.Helper()
|
|
||||||
var albumMBID, trackMBID *string
|
|
||||||
switch kind {
|
|
||||||
case dbq.LidarrRequestKindAlbum:
|
|
||||||
albumMBID = &mbid
|
|
||||||
case dbq.LidarrRequestKindTrack:
|
|
||||||
trackMBID = &mbid
|
|
||||||
}
|
|
||||||
_, err := dbq.New(h.pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
|
||||||
UserID: userID,
|
|
||||||
Kind: kind,
|
|
||||||
LidarrArtistMbid: artistMBID,
|
|
||||||
LidarrAlbumMbid: albumMBID,
|
|
||||||
LidarrTrackMbid: trackMBID,
|
|
||||||
ArtistName: "Test Artist",
|
|
||||||
AlbumTitle: nil,
|
|
||||||
TrackTitle: nil,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seedRequestForMBID: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// doLidarrSearch fires a GET /api/lidarr/search request directly at the handler.
|
|
||||||
// It simulates the RequireUser middleware by injecting a dummy user context.
|
|
||||||
func doLidarrSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", q, kind)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
||||||
// Inject a minimal user context the same way other handler tests do it.
|
|
||||||
// The lidarr search handler doesn't use the user from context, so any
|
|
||||||
// non-nil user value keeps the handler from panicking.
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.handleLidarrSearch(w, req)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_ValidationErrors covers bad kind and missing query
|
|
||||||
// in a table-driven test.
|
|
||||||
func TestHandleLidarrSearch_ValidationErrors(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
q string
|
|
||||||
kind string
|
|
||||||
wantStatus int
|
|
||||||
wantCode string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "bad kind playlist",
|
|
||||||
q: "Beatles",
|
|
||||||
kind: "playlist",
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
wantCode: "bad_kind",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing query",
|
|
||||||
q: "",
|
|
||||||
kind: "artist",
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
wantCode: "missing_query",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", tc.q, tc.kind)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.handleLidarrSearch(w, req)
|
|
||||||
|
|
||||||
if w.Code != tc.wantStatus {
|
|
||||||
t.Fatalf("status = %d, want %d; body = %s", w.Code, tc.wantStatus, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Error struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp.Error.Code != tc.wantCode {
|
|
||||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_DisabledReturns503 verifies that a disabled Lidarr
|
|
||||||
// config causes the handler to return 503 lidarr_disabled.
|
|
||||||
func TestHandleLidarrSearch_DisabledReturns503(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
// Config is already disabled after resetLidarrState; ensure it's explicit.
|
|
||||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", false)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.handleLidarrSearch(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Error struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp.Error.Code != "lidarr_disabled" {
|
|
||||||
t.Errorf("error.code = %q, want lidarr_disabled", resp.Error.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_HappyPath_RequestableResult checks a result that is
|
|
||||||
// neither in the library nor already requested.
|
|
||||||
func TestHandleLidarrSearch_HappyPath_RequestableResult(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
const mbid = "artist-mbid-abc123"
|
|
||||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "The Beatles"))
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
|
|
||||||
w := doLidarrSearch(t, h, "Beatles", "artist")
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var results []lidarrSearchResult
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
||||||
}
|
|
||||||
r := results[0]
|
|
||||||
if r.MBID != mbid {
|
|
||||||
t.Errorf("mbid = %q, want %q", r.MBID, mbid)
|
|
||||||
}
|
|
||||||
if r.InLibrary {
|
|
||||||
t.Errorf("in_library = true, want false")
|
|
||||||
}
|
|
||||||
if r.Requested {
|
|
||||||
t.Errorf("requested = true, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_HappyPath_InLibraryResult checks that a result whose
|
|
||||||
// MBID matches an artist in the local DB has in_library=true.
|
|
||||||
func TestHandleLidarrSearch_HappyPath_InLibraryResult(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
const mbid = "artist-mbid-inlib"
|
|
||||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Library Artist"))
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
|
|
||||||
// Seed the artist with the matching MBID so in_library becomes true.
|
|
||||||
seedArtistWithMBID(t, h, dbtest.TestUserPrefix+"LibraryArtist", mbid)
|
|
||||||
|
|
||||||
w := doLidarrSearch(t, h, "Library+Artist", "artist")
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var results []lidarrSearchResult
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
||||||
}
|
|
||||||
if !results[0].InLibrary {
|
|
||||||
t.Errorf("in_library = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_HappyPath_AlreadyRequested verifies that a result
|
|
||||||
// whose MBID matches a pending lidarr_requests row has requested=true.
|
|
||||||
func TestHandleLidarrSearch_HappyPath_AlreadyRequested(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
const mbid = "artist-mbid-requested"
|
|
||||||
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Requested Artist"))
|
|
||||||
saveLidarrConfig(t, h, stub.URL, true)
|
|
||||||
|
|
||||||
// Seed a user and a pending request for the artist MBID.
|
|
||||||
user := seedUser(t, h.pool, "requester", "pw", false)
|
|
||||||
seedRequestForMBID(t, h, user.ID, dbq.LidarrRequestKindArtist, mbid, mbid)
|
|
||||||
|
|
||||||
w := doLidarrSearch(t, h, "Requested+Artist", "artist")
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var results []lidarrSearchResult
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
|
||||||
}
|
|
||||||
if !results[0].Requested {
|
|
||||||
t.Errorf("requested = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLidarrSearch_LidarrUnreachableReturns503 verifies that a
|
|
||||||
// connection-refused Lidarr base URL returns 503 lidarr_unreachable.
|
|
||||||
func TestHandleLidarrSearch_LidarrUnreachableReturns503(t *testing.T) {
|
|
||||||
h, _ := testHandlers(t)
|
|
||||||
resetLidarrState(t, h)
|
|
||||||
|
|
||||||
// Port 1 is reserved and will refuse connections immediately.
|
|
||||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
|
|
||||||
// Use a short-deadline context so the test doesn't hang waiting for TCP.
|
|
||||||
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
req = req.WithContext(ctx)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.handleLidarrSearch(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
var resp struct {
|
|
||||||
Error struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if resp.Error.Code != "lidarr_unreachable" {
|
|
||||||
t.Errorf("error.code = %q, want lidarr_unreachable", resp.Error.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,7 @@ func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, start
|
|||||||
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
|
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := pool.Exec(context.Background(),
|
_, err := pool.Exec(context.Background(),
|
||||||
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
|
||||||
userID, trackID,
|
userID, trackID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helpers for the handlers that produce notifications (M489). Every call is
|
// Helpers for the handlers that produce notifications (M489). Every call is
|
||||||
@@ -22,17 +23,17 @@ func userLabel(u dbq.User) string {
|
|||||||
return u.Username
|
return u.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
|
func requestPayload(row dbq.Request) notifications.Payload {
|
||||||
return notifications.Payload{
|
return notifications.Payload{
|
||||||
RequestID: uuidToString(row.ID),
|
RequestID: uuidToString(row.ID),
|
||||||
RequestKind: string(row.Kind),
|
RequestKind: string(row.Kind),
|
||||||
Name: lidarrrequests.DisplayName(row),
|
Name: requests.DisplayName(row),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyRequestDecided tells the requester an admin approved or declined
|
// notifyRequestDecided tells the requester an admin approved or declined
|
||||||
// their request. An admin deciding their own request needs no notice.
|
// their request. An admin deciding their own request needs no notice.
|
||||||
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
|
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.Request, reason string) {
|
||||||
if row.UserID == admin.ID {
|
if row.UserID == admin.ID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,21 +67,19 @@ func (h *handlers) trackLabel(ctx context.Context, trackID pgtype.UUID) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil && a.Name != "" {
|
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil {
|
||||||
return a.Name + " – " + t.Title
|
return library.TrackLabel(a.Name, t.Title)
|
||||||
}
|
}
|
||||||
return t.Title
|
return t.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyPlaybackErrors tells admins how many playback errors await review.
|
// notifyPlaybackErrors tells admins a track failed to play, naming it and who
|
||||||
// The notice coalesces, so while it is unread a new report only updates the
|
// it failed for (M542). The notice coalesces: while it is unread each report
|
||||||
// count.
|
// adds one to its count and its name to the front of the list.
|
||||||
func (h *handlers) notifyPlaybackErrors(ctx context.Context) {
|
func (h *handlers) notifyPlaybackErrors(ctx context.Context, trackID pgtype.UUID, user dbq.User) {
|
||||||
n, err := dbq.New(h.pool).CountUnresolvedPlaybackErrors(ctx)
|
p := notifications.Payload{Count: 1}
|
||||||
if err != nil {
|
if name := h.trackLabel(ctx, trackID); name != "" {
|
||||||
h.logger.Warn("api: playback_error: count unresolved", "err", err)
|
p.Items = []string{name + " (" + userLabel(user) + ")"}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}),
|
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}), p.Map())
|
||||||
notifications.Payload{Count: n}.Map())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,27 +53,9 @@ func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newApprovingLidarrStub answers every Lidarr call an approval makes.
|
|
||||||
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
switch {
|
|
||||||
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
|
||||||
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
|
||||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
|
||||||
default:
|
|
||||||
_, _ = w.Write([]byte(`{"id":1}`))
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(stub.Close)
|
|
||||||
return stub
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "np-alice", "pw", false)
|
alice := seedUser(t, pool, "np-alice", "pw", false)
|
||||||
admin := seedUser(t, pool, "np-admin", "pw", true)
|
admin := seedUser(t, pool, "np-admin", "pw", true)
|
||||||
@@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "np-auto", "pw", false)
|
alice := seedUser(t, h.pool, "np-auto", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
|
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
|
||||||
@@ -123,9 +104,8 @@ func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
|
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
|
||||||
h, _ := testHandlersWithClientFn(t)
|
h, _ := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
|
|
||||||
|
|
||||||
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
|
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
|
||||||
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
|
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
|
||||||
@@ -231,8 +211,9 @@ func TestNotify_PlaybackErrorsCoalesceIntoOneCount(t *testing.T) {
|
|||||||
|
|
||||||
got := inboxOf(t, pool, admin)
|
got := inboxOf(t, pool, admin)
|
||||||
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
|
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
|
||||||
if got[0].Payload.Count != 2 {
|
if got[0].Payload.Count != 2 || len(got[0].Payload.Items) != 2 ||
|
||||||
t.Errorf("count = %d, want 2 unresolved", got[0].Payload.Count)
|
!strings.HasSuffix(got[0].Payload.Items[0], "np-play-alice)") {
|
||||||
|
t.Errorf("payload = %+v, want two reports, each naming the track and alice", got[0].Payload)
|
||||||
}
|
}
|
||||||
wantInbox(t, "reporter", inboxOf(t, pool, alice))
|
wantInbox(t, "reporter", inboxOf(t, pool, alice))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Requ
|
|||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.notifyPlaybackErrors(r.Context())
|
h.notifyPlaybackErrors(r.Context(), trackID, user)
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// quarantineView is the JSON shape returned by the user-facing endpoints
|
// quarantineView is the JSON shape returned by the user-facing endpoints
|
||||||
@@ -22,7 +22,7 @@ type quarantineView struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
|
func quarantineViewFrom(row dbq.Quarantine) quarantineView {
|
||||||
return quarantineView{
|
return quarantineView{
|
||||||
UserID: uuidToString(row.UserID),
|
UserID: uuidToString(row.UserID),
|
||||||
TrackID: uuidToString(row.TrackID),
|
TrackID: uuidToString(row.TrackID),
|
||||||
@@ -55,12 +55,12 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
|
row, err := h.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrBadReason):
|
case errors.Is(err, quarantine.ErrBadReason):
|
||||||
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
|
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
|
||||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
case errors.Is(err, quarantine.ErrTrackNotFound):
|
||||||
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
|
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
|
||||||
default:
|
default:
|
||||||
h.logger.Error("api: flag", "err", err)
|
h.logger.Error("api: flag", "err", err)
|
||||||
@@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
||||||
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
|
if errors.Is(err, quarantine.ErrQuarantineNotFound) {
|
||||||
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
|
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
|
rows, err := h.quarantine.ListMine(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list mine", "err", err)
|
h.logger.Error("api: list mine", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("list failed", err))
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
||||||
@@ -134,10 +134,10 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
|
|||||||
out := make([]quarantineMineView, 0, len(rows))
|
out := make([]quarantineMineView, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out = append(out, quarantineMineView{
|
out = append(out, quarantineMineView{
|
||||||
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
|
TrackID: uuidToString(row.Quarantine.TrackID),
|
||||||
Reason: string(row.LidarrQuarantine.Reason),
|
Reason: string(row.Quarantine.Reason),
|
||||||
Notes: row.LidarrQuarantine.Notes,
|
Notes: row.Quarantine.Notes,
|
||||||
CreatedAt: row.LidarrQuarantine.CreatedAt,
|
CreatedAt: row.Quarantine.CreatedAt,
|
||||||
TrackTitle: row.TrackTitle,
|
TrackTitle: row.TrackTitle,
|
||||||
TrackDurationMs: row.TrackDurationMs,
|
TrackDurationMs: row.TrackDurationMs,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
|
|||||||
+75
-86
@@ -9,34 +9,32 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestView is the JSON shape returned by all /api/requests handlers.
|
// requestView is the JSON shape returned by all /api/requests handlers.
|
||||||
// Field names follow the lowercase snake_case convention for /api/* responses.
|
// Field names follow the lowercase snake_case convention for /api/* responses.
|
||||||
type requestView struct {
|
type requestView struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
AlbumMBID *string `json:"album_mbid,omitempty"`
|
||||||
LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"`
|
TrackMBID *string `json:"track_mbid,omitempty"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumTitle *string `json:"album_title,omitempty"`
|
AlbumTitle *string `json:"album_title,omitempty"`
|
||||||
TrackTitle *string `json:"track_title,omitempty"`
|
TrackTitle *string `json:"track_title,omitempty"`
|
||||||
QualityProfileID *int32 `json:"quality_profile_id,omitempty"`
|
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
|
||||||
RootFolderPath *string `json:"root_folder_path,omitempty"`
|
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
|
||||||
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
|
Notes *string `json:"notes,omitempty"`
|
||||||
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
|
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
|
||||||
Notes *string `json:"notes,omitempty"`
|
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
|
||||||
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
|
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
|
||||||
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
|
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
|
||||||
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
|
RequestedAt pgtype.Timestamptz `json:"requested_at"`
|
||||||
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
RequestedAt pgtype.Timestamptz `json:"requested_at"`
|
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
|
||||||
// Progress counters, computed at response time from the matched
|
// Progress counters, computed at response time from the matched
|
||||||
// entity's children. 0 when nothing has been ingested yet (or when
|
// entity's children. 0 when nothing has been ingested yet (or when
|
||||||
// the request has no matched entity yet). For artist requests both
|
// the request has no matched entity yet). For artist requests both
|
||||||
@@ -45,29 +43,27 @@ type requestView struct {
|
|||||||
ImportedTrackCount int `json:"imported_track_count"`
|
ImportedTrackCount int `json:"imported_track_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestViewFrom(row dbq.LidarrRequest) requestView {
|
func requestViewFrom(row dbq.Request) requestView {
|
||||||
return requestView{
|
return requestView{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
UserID: row.UserID,
|
UserID: row.UserID,
|
||||||
Status: string(row.Status),
|
Status: string(row.Status),
|
||||||
Kind: string(row.Kind),
|
Kind: string(row.Kind),
|
||||||
LidarrArtistMBID: row.LidarrArtistMbid,
|
ArtistMBID: row.ArtistMbid,
|
||||||
LidarrAlbumMBID: row.LidarrAlbumMbid,
|
AlbumMBID: row.AlbumMbid,
|
||||||
LidarrTrackMBID: row.LidarrTrackMbid,
|
TrackMBID: row.TrackMbid,
|
||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
TrackTitle: row.TrackTitle,
|
TrackTitle: row.TrackTitle,
|
||||||
QualityProfileID: row.QualityProfileID,
|
DecidedAt: row.DecidedAt,
|
||||||
RootFolderPath: row.RootFolderPath,
|
DecidedBy: row.DecidedBy,
|
||||||
DecidedAt: row.DecidedAt,
|
Notes: row.Notes,
|
||||||
DecidedBy: row.DecidedBy,
|
CompletedAt: row.CompletedAt,
|
||||||
Notes: row.Notes,
|
MatchedTrackID: row.MatchedTrackID,
|
||||||
CompletedAt: row.CompletedAt,
|
MatchedAlbumID: row.MatchedAlbumID,
|
||||||
MatchedTrackID: row.MatchedTrackID,
|
MatchedArtistID: row.MatchedArtistID,
|
||||||
MatchedAlbumID: row.MatchedAlbumID,
|
RequestedAt: row.RequestedAt,
|
||||||
MatchedArtistID: row.MatchedArtistID,
|
UpdatedAt: row.UpdatedAt,
|
||||||
RequestedAt: row.RequestedAt,
|
|
||||||
UpdatedAt: row.UpdatedAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +77,9 @@ func requestViewFrom(row dbq.LidarrRequest) requestView {
|
|||||||
// N+1 against the request list: acceptable at admin scale (dozens of
|
// N+1 against the request list: acceptable at admin scale (dozens of
|
||||||
// in-flight requests at most). Errors are swallowed — progress is a
|
// in-flight requests at most). Errors are swallowed — progress is a
|
||||||
// reporting nicety, not a correctness gate.
|
// reporting nicety, not a correctness gate.
|
||||||
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) {
|
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.Request, view *requestView) {
|
||||||
switch row.Kind {
|
switch row.Kind {
|
||||||
case dbq.LidarrRequestKindArtist:
|
case dbq.RequestKindArtist:
|
||||||
if !row.MatchedArtistID.Valid {
|
if !row.MatchedArtistID.Valid {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,7 +89,7 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
|
|||||||
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
|
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
|
||||||
view.ImportedTrackCount = int(n)
|
view.ImportedTrackCount = int(n)
|
||||||
}
|
}
|
||||||
case dbq.LidarrRequestKindAlbum:
|
case dbq.RequestKindAlbum:
|
||||||
if !row.MatchedAlbumID.Valid {
|
if !row.MatchedAlbumID.Valid {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,22 +97,23 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
|
|||||||
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
|
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
|
||||||
view.ImportedTrackCount = int(n)
|
view.ImportedTrackCount = int(n)
|
||||||
}
|
}
|
||||||
case dbq.LidarrRequestKindTrack:
|
case dbq.RequestKindTrack:
|
||||||
if row.MatchedTrackID.Valid {
|
if row.MatchedTrackID.Valid {
|
||||||
view.ImportedTrackCount = 1
|
view.ImportedTrackCount = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRequestBody is the decoded JSON for POST /api/requests.
|
// createRequestBody is the decoded JSON for POST /api/requests. album_mbid
|
||||||
|
// is the album's MusicBrainz release group, as catalogue search returns it.
|
||||||
type createRequestBody struct {
|
type createRequestBody struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
|
ArtistMBID string `json:"artist_mbid"`
|
||||||
LidarrAlbumMBID string `json:"lidarr_album_mbid"`
|
AlbumMBID string `json:"album_mbid"`
|
||||||
LidarrTrackMBID string `json:"lidarr_track_mbid"`
|
TrackMBID string `json:"track_mbid"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumTitle string `json:"album_title"`
|
AlbumTitle string `json:"album_title"`
|
||||||
TrackTitle string `json:"track_title"`
|
TrackTitle string `json:"track_title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCreateRequest implements POST /api/requests.
|
// handleCreateRequest implements POST /api/requests.
|
||||||
@@ -131,17 +128,17 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
|
row, created, err := h.requests.CreateTracked(r.Context(), user.ID, requests.CreateParams{
|
||||||
Kind: body.Kind,
|
Kind: body.Kind,
|
||||||
LidarrArtistMBID: body.LidarrArtistMBID,
|
ArtistMBID: body.ArtistMBID,
|
||||||
LidarrAlbumMBID: body.LidarrAlbumMBID,
|
AlbumMBID: body.AlbumMBID,
|
||||||
LidarrTrackMBID: body.LidarrTrackMBID,
|
TrackMBID: body.TrackMBID,
|
||||||
ArtistName: body.ArtistName,
|
ArtistName: body.ArtistName,
|
||||||
AlbumTitle: body.AlbumTitle,
|
AlbumTitle: body.AlbumTitle,
|
||||||
TrackTitle: body.TrackTitle,
|
TrackTitle: body.TrackTitle,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, lidarrrequests.ErrInvalidKindFields) {
|
if errors.Is(err, requests.ErrInvalidKindFields) {
|
||||||
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
|
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,22 +147,14 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-approve flow (#355 / #376 U2.5): when the user has the
|
// Auto-approve flow (#355 / #376 U2.5): when an admin has set the user's
|
||||||
// auto_approve_requests flag set by an admin, transition the
|
// auto_approve_requests flag, the new request goes straight onto the
|
||||||
// just-created pending row through Approve so it dispatches to
|
// wanted list as if approved. A failure leaves it pending for an admin,
|
||||||
// Lidarr immediately. Failures here (Lidarr disabled, defaults
|
// the same as a manual approval hitting the same error. The approver is
|
||||||
// missing, network error) leave the row pending — same fallback
|
// the user themselves, acting under the privilege the admin granted; the
|
||||||
// as a manual admin approve that hits the same error path. The
|
// grant is in audit_log (ActionAutoApproveToggle).
|
||||||
// user gets a 201 with whatever status Approve produced; admin
|
|
||||||
// can still resolve the row in /admin/requests if it stayed
|
|
||||||
// pending.
|
|
||||||
//
|
|
||||||
// The "actor" id passed to Approve is the user themselves — the
|
|
||||||
// auto-approval is the user acting under the privilege the admin
|
|
||||||
// granted them via the toggle. The trail of "admin set the flag"
|
|
||||||
// already lives in audit_log (ActionAutoApproveToggle from U2).
|
|
||||||
if user.AutoApproveRequests {
|
if user.AutoApproveRequests {
|
||||||
approved, aerr := h.lidarrRequests.Approve(r.Context(), row.ID, user.ID, lidarrrequests.ApproveOverrides{})
|
approved, aerr := h.requests.Approve(r.Context(), row.ID, user.ID, pgtype.UUID{})
|
||||||
if aerr == nil {
|
if aerr == nil {
|
||||||
row = approved
|
row = approved
|
||||||
} else {
|
} else {
|
||||||
@@ -180,7 +169,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
// (M489). One that deduped into a request already in flight was
|
// (M489). One that deduped into a request already in flight was
|
||||||
// announced when it was first made, and the requester, if they are an
|
// announced when it was first made, and the requester, if they are an
|
||||||
// admin themselves, needs no notice of their own request.
|
// admin themselves, needs no notice of their own request.
|
||||||
if created && row.Status == dbq.LidarrRequestStatusPending {
|
if created && row.Status == dbq.RequestStatusPending {
|
||||||
p := requestPayload(row)
|
p := requestPayload(row)
|
||||||
p.Actor = userLabel(user)
|
p.Actor = userLabel(user)
|
||||||
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
|
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
|
||||||
@@ -197,7 +186,7 @@ func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100)
|
rows, err := h.requests.ListForUser(r.Context(), user.ID, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list requests", "err", err)
|
h.logger.Error("api: list requests", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("list failed", err))
|
writeErr(w, apierror.InternalMsg("list failed", err))
|
||||||
@@ -222,7 +211,7 @@ func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetLidarrRequestByID, "request")
|
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetRequestByID, "request")
|
||||||
if apiErr != nil {
|
if apiErr != nil {
|
||||||
writeErr(w, apiErr)
|
writeErr(w, apiErr)
|
||||||
return
|
return
|
||||||
@@ -252,12 +241,12 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID)
|
row, err := h.requests.Cancel(r.Context(), id, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
case errors.Is(err, requests.ErrNotPending):
|
||||||
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
|
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
|
||||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
case errors.Is(err, requests.ErrNotFound):
|
||||||
writeErr(w, apierror.NotFound("request"))
|
writeErr(w, apierror.NotFound("request"))
|
||||||
default:
|
default:
|
||||||
h.logger.Error("api: cancel request", "err", err)
|
h.logger.Error("api: cancel request", "err", err)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.Respo
|
|||||||
// via the handler and returns the parsed response.
|
// via the handler and returns the parsed response.
|
||||||
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body := fmt.Sprintf(`{"kind":"artist","lidarr_artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
|
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
|
||||||
w := doCreateRequest(h, user, body)
|
w := doCreateRequest(h, user, body)
|
||||||
if w.Code != http.StatusCreated {
|
if w.Code != http.StatusCreated {
|
||||||
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
|
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
|
||||||
@@ -85,15 +85,12 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
|
|||||||
return rv
|
return rv
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending verifies
|
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
|
||||||
// that a user with auto_approve_requests=true still receives a 201 Created
|
// that a user with auto_approve_requests=true gets their request approved on
|
||||||
// when Lidarr is disabled in the test environment — the auto-approve attempt
|
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
|
||||||
// fails inside Service.Approve with ErrLidarrDisabled, which the handler
|
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
|
||||||
// catches and logs, leaving the row in pending state. This is the U2.5
|
|
||||||
// fallback contract: a misconfigured Lidarr must not break request creation.
|
|
||||||
func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.T) {
|
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
if _, err := pool.Exec(context.Background(),
|
if _, err := pool.Exec(context.Background(),
|
||||||
@@ -105,13 +102,9 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload user: %v", err)
|
t.Fatalf("reload user: %v", err)
|
||||||
}
|
}
|
||||||
if !refreshed.AutoApproveRequests {
|
|
||||||
t.Fatalf("auto_approve flag did not persist — bailing")
|
|
||||||
}
|
|
||||||
|
|
||||||
body := `{"kind":"artist","lidarr_artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
|
body := `{"kind":"artist","artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
|
||||||
w := doCreateRequest(h, refreshed, body)
|
w := doCreateRequest(h, refreshed, body)
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
if w.Code != http.StatusCreated {
|
||||||
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
|
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
@@ -119,8 +112,11 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
|
|||||||
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
if rv.Status != "pending" {
|
if rv.Status != "approved" {
|
||||||
t.Errorf("status = %q, want pending (auto-approve falls back when Lidarr disabled)", rv.Status)
|
t.Errorf("status = %q, want approved", rv.Status)
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(pool).GetFollow(context.Background(), "auto-mbid-1"); err != nil {
|
||||||
|
t.Errorf("the artist should be followed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +124,10 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
|
|||||||
// returns 201 with kind=artist and status=pending.
|
// returns 201 with kind=artist and status=pending.
|
||||||
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
body := `{"kind":"artist","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||||
w := doCreateRequest(h, alice, body)
|
w := doCreateRequest(h, alice, body)
|
||||||
|
|
||||||
if w.Code != http.StatusCreated {
|
if w.Code != http.StatusCreated {
|
||||||
@@ -147,8 +143,8 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
|||||||
if rv.Status != "pending" {
|
if rv.Status != "pending" {
|
||||||
t.Errorf("status = %q, want pending", rv.Status)
|
t.Errorf("status = %q, want pending", rv.Status)
|
||||||
}
|
}
|
||||||
if rv.LidarrArtistMBID != "artist-mbid-1" {
|
if rv.ArtistMBID != "artist-mbid-1" {
|
||||||
t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID)
|
t.Errorf("artist_mbid = %q", rv.ArtistMBID)
|
||||||
}
|
}
|
||||||
if rv.ArtistName != "The Beatles" {
|
if rv.ArtistName != "The Beatles" {
|
||||||
t.Errorf("artist_name = %q", rv.ArtistName)
|
t.Errorf("artist_name = %q", rv.ArtistName)
|
||||||
@@ -159,14 +155,14 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
|
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
|
||||||
// kind=album without lidarr_album_mbid returns 400 with mbid_required.
|
// kind=album without album_mbid returns 400 with mbid_required.
|
||||||
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
// Missing lidarr_album_mbid and album_title.
|
// Missing album_mbid and album_title.
|
||||||
body := `{"kind":"album","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
body := `{"kind":"album","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||||
w := doCreateRequest(h, alice, body)
|
w := doCreateRequest(h, alice, body)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
@@ -189,11 +185,11 @@ func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
|||||||
// with missing track_mbid/track_title returns 400 with mbid_required.
|
// with missing track_mbid/track_title returns 400 with mbid_required.
|
||||||
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
// Provides album fields but misses track_mbid and track_title.
|
// Provides album fields but misses track_mbid and track_title.
|
||||||
body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
|
body := `{"kind":"track","artist_mbid":"artist-mbid-1","artist_name":"Beatles","album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
|
||||||
w := doCreateRequest(h, alice, body)
|
w := doCreateRequest(h, alice, body)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusBadRequest {
|
||||||
@@ -216,7 +212,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
|||||||
// requests, not bob's.
|
// requests, not bob's.
|
||||||
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
|
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
@@ -246,7 +242,7 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
|
|||||||
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
|
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
|
||||||
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
|
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
|
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
|
||||||
@@ -268,7 +264,7 @@ func TestHandleGetRequest_OwnReturns200(t *testing.T) {
|
|||||||
// cannot see another user's request.
|
// cannot see another user's request.
|
||||||
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
charlie := seedUser(t, pool, "charlie", "pw", false)
|
charlie := seedUser(t, pool, "charlie", "pw", false)
|
||||||
@@ -297,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
|
|||||||
// user's request.
|
// user's request.
|
||||||
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
bob := seedUser(t, pool, "bob", "pw", false)
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
admin := seedUser(t, pool, "admin", "pw", true)
|
admin := seedUser(t, pool, "admin", "pw", true)
|
||||||
@@ -322,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
|
|||||||
// request returns 200 with status=rejected.
|
// request returns 200 with status=rejected.
|
||||||
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
|
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
|
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
|
||||||
@@ -344,7 +340,7 @@ func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
|
|||||||
// rejected request returns 409 with request_not_pending.
|
// rejected request returns 409 with request_not_pending.
|
||||||
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
|
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice", "pw", false)
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
|
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
|
||||||
@@ -380,7 +376,7 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) {
|
|||||||
// another user's request returns 409 (not_pending from the ownership miss).
|
// another user's request returns 409 (not_pending from the ownership miss).
|
||||||
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
|
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
|
||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
resetLidarrState(t, h)
|
resetRequests(t, h)
|
||||||
|
|
||||||
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
|
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
|
||||||
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
|
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetRequests clears requests rows between tests.
|
||||||
|
func resetRequests(t *testing.T, h *handlers) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := h.pool.Exec(context.Background(), "DELETE FROM requests"); err != nil {
|
||||||
|
t.Fatalf("resetRequests: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user