Compare commits

..

22 Commits

Author SHA1 Message Date
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen dabca3ad24 fix(android): stop single SOAP failure from dropping Sonos to local
android / Build + lint + test (push) Successful in 3m44s
When the phone was locked, a transport command (lock-screen/Bluetooth/
watch media button, or queue-resync play) issued during a WiFi power-save
stall would hit the 2s connect timeout and throw. handleSoapFailure then
cancelled the poll loop and fired onDrop on that single failure -- showing
"Disconnected from <Sonos>" and reverting to local playback, even though
the renderer was perfectly reachable. This bypassed the poll loop's
deliberate 30-consecutive-failure tolerance (DROP_THRESHOLD, bumped from 3
precisely for screen-off WiFi sleep / Doze).

Make the 1 Hz poll loop the sole drop arbiter:
- Transport SOAP commands retry transient IO failures (retryTransientIo:
  3 attempts, 400ms backoff) so a brief WiFi stall lands the command once
  WiFi wakes instead of abandoning it. A SoapFaultException (renderer
  answered, rejected the action) is not retried -- the device is alive.
- handleTransportFailure no longer cancels the poll loop or fires onDrop;
  it logs and nudges an immediate poll so the UI reconciles to Sonos's
  actual state. If the renderer is truly gone, the poll loop trips the
  drop on its own via DROP_THRESHOLD.

Extract retryTransientIo as an internal top-level fn + unit test covering
first-success, retry-then-succeed, exhaust-and-rethrow, and no-retry-on-
SoapFault. Refresh now-stale drop-heuristic comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:54:14 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 8f29cc7414 feat: poll in-flight requests on native + refresh after submit (#369)
test-web / test (push) Successful in 33s
android / Build + lint + test (push) Successful in 3m37s
Native RequestsViewModel gains poll-while-approved (silent reloads, paused
when nothing in-flight) for parity with the web auto-poll, and the SSE
collector now reloads silently instead of flashing the loading spinner.
Web discover submit invalidates qk.myRequests() so a new request appears
on /requests immediately.
2026-06-06 23:18:44 -04:00
bvandeusen c8b21aa76e fix(android): grid items() resolution on artist detail (drop bad alias)
android / Build + lint + test (push) Successful in 3m35s
2026-06-06 22:56:58 -04:00
bvandeusen 483804fc9e fix(android): extract ArtistSuccessBody to satisfy detekt LongMethod
android / Build + lint + test (push) Failing after 2m56s
2026-06-06 22:48:45 -04:00
bvandeusen 12a8cfccb5 feat(android): similar-artists strip + top-tracks on artist detail
test-go / test (push) Successful in 32s
test-web / test (push) Successful in 40s
android / Build + lint + test (push) Failing after 1m16s
test-go / integration (push) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:39:54 -04:00
bvandeusen e358a92cf4 feat(web): similar-artists strip + top-tracks panel on artist detail 2026-06-06 22:34:46 -04:00
bvandeusen 63b25e65ad feat(server): similar-artists + per-user artist top-tracks endpoints
GET /api/artists/{id}/similar — in-library artists ranked by similarity
score (deduped across sources), ArtistRef list with cover + album count.
GET /api/artists/{id}/top-tracks — current user's most-played tracks for
the artist (skips excluded, quarantine filtered).
2026-06-06 22:30:41 -04:00
bvandeusen a766b7193f test(server): drop removed scheduler arg from New() callers
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m27s
2026-06-06 22:00:21 -04:00
bvandeusen e95138d412 style(server): gofmt watcher_test map alignment
test-go / test (push) Failing after 10s
test-web / test (push) Successful in 33s
test-go / integration (push) Failing after 4m27s
2026-06-06 21:54:14 -04:00
bvandeusen 7426f6c718 feat(web): remove scan-schedule admin UI (scheduler retired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:20 -04:00
bvandeusen e994aae613 feat(server): retire scan scheduler for watcher + safety-net walk
Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main;
remove the configurable scan scheduler (scheduler.go, scan_schedule table via
migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api
plumbing). Manual scan + scan status are unchanged.
2026-06-06 21:50:08 -04:00
bvandeusen c39a9ca18f feat(server): targeted ScanFiles + fsnotify library watcher
Add Scanner.ScanFiles (watcher-driven targeted scan returning changed album
IDs) and a recursive fsnotify Watcher that debounces filesystem events and
enriches just the affected albums inline. Pure classifyEvent/drainPending
seams unit-tested; ScanFiles covered in the scanner integration test.
2026-06-06 21:43:27 -04:00
bvandeusen 06e155abb6 fix(android): rename IdleRevertDecision.kt to satisfy detekt MatchingDeclarationName
android / Build + lint + test (push) Successful in 4m11s
2026-06-06 17:36:07 -04:00
bvandeusen 6b11208e5a perf(android): fail-fast connect timeout for UPnP transport SOAP
android / Build + lint + test (push) Failing after 1m8s
2026-06-06 17:30:28 -04:00
bvandeusen 41da012494 fix(android): failed UPnP play reverts to phone and resumes locally 2026-06-06 17:29:58 -04:00
bvandeusen 8a3104443c feat(android): record play-intent on remote play/pause 2026-06-06 17:29:36 -04:00
bvandeusen 5f5ab69da6 feat(android): track UPnP play-intent surviving SOAP errors 2026-06-06 17:29:19 -04:00
bvandeusen 5386ae870f feat(android): revert UPnP output to phone after 5-min idle 2026-06-06 17:28:32 -04:00
bvandeusen d7b011e52f feat(android): pure idle-revert decision for UPnP output 2026-06-06 17:27:52 -04:00
48 changed files with 1577 additions and 1162 deletions
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
import com.fabledsword.minstrel.models.wire.ArtistWire
import com.fabledsword.minstrel.models.wire.TrackWire
import retrofit2.http.GET
import retrofit2.http.Path
@@ -35,9 +36,26 @@ interface LibraryApi {
@GET("api/artists/{id}/tracks")
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
@GET("api/artists/{id}/similar")
suspend fun getSimilarArtists(
@Path("id") id: String,
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
): List<ArtistWire>
@GET("api/artists/{id}/top-tracks")
suspend fun getArtistTopTracks(
@Path("id") id: String,
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
): List<TrackWire>
@GET("api/albums/{id}")
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
private companion object {
const val SIMILAR_ARTISTS_LIMIT = 12
const val TOP_TRACKS_LIMIT = 5
}
}
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
api.getArtistTracks(id).map { it.toDomain() }
/**
* Pulls related artists for the ArtistDetail "Similar artists"
* strip. Network-only — non-critical UI, callers swallow failures
* and render nothing rather than blocking the screen.
*/
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
api.getSimilarArtists(id).map { it.toDomain() }
/**
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
* panel. Network-only; tapping a row plays this exact list as a
* queue, so no persistence is needed.
*/
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
api.getArtistTopTracks(id).map { it.toDomain() }
/**
* Pulls the album detail (AlbumRef + tracks), persists both, and
* returns the in-memory snapshot. Lets the AlbumDetail screen
@@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
package com.fabledsword.minstrel.library.ui
import androidx.compose.animation.Crossfade
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items as lazyItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.albumCoverPath
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.TrackRow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistDetailScreen(
navController: NavHostController,
viewModel: ArtistDetailViewModel = hiltViewModel(),
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
title = "Couldn't load artist",
body = s.message,
)
is ArtistDetailUiState.Success -> {
val artistLiked by viewModel.artistLiked
.collectAsStateWithLifecycle()
ArtistBody(
detail = s.detail,
artistLiked = artistLiked,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
is ArtistDetailUiState.Success ->
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
}
}
}
}
}
@Composable
private fun ArtistSuccessBody(
state: ArtistDetailUiState.Success,
viewModel: ArtistDetailViewModel,
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
ArtistBody(
state = state,
artistLiked = artistLiked,
playingTrackId = playerState.currentTrack?.id,
onPlay = viewModel::playArtist,
onToggleLike = viewModel::toggleLikeArtist,
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
onTopTrackClick = viewModel::playTopTracks,
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
)
}
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
is ArtistDetailUiState.Success -> state.detail.artist.name
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
@Composable
private fun ArtistBody(
detail: ArtistDetailRef,
state: ArtistDetailUiState.Success,
artistLiked: Boolean,
playingTrackId: String?,
onPlay: () -> Unit,
onToggleLike: () -> Unit,
onAlbumClick: (String) -> Unit,
onTopTrackClick: (Int) -> Unit,
onSimilarArtistClick: (String) -> Unit,
) {
val detail = state.detail
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 176.dp),
modifier = Modifier.fillMaxSize(),
@@ -149,6 +174,23 @@ private fun ArtistBody(
onToggleLike = onToggleLike,
)
}
if (state.topTracks.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
TopTracksPanel(
tracks = state.topTracks,
playingTrackId = playingTrackId,
onTrackClick = onTopTrackClick,
)
}
}
if (state.similarArtists.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SimilarArtistsStrip(
artists = state.similarArtists,
onArtistClick = onSimilarArtistClick,
)
}
}
if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
EmptyAlbumsHint()
@@ -161,6 +203,67 @@ private fun ArtistBody(
}
}
@Composable
private fun TopTracksPanel(
tracks: List<TrackRef>,
playingTrackId: String?,
onTrackClick: (Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Top tracks",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
tracks.forEachIndexed { index, track ->
TopTrackRow(
track = track,
nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) },
)
}
}
}
@Composable
private fun TopTrackRow(
track: TrackRef,
nowPlaying: Boolean,
onClick: () -> Unit,
) {
TrackRow(
title = track.title,
artist = track.artistName,
trackId = track.id,
onClick = onClick,
nowPlaying = nowPlaying,
trailing = {
Text(
text = formatDuration(track.durationSec, zero = "--:--"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
},
)
}
@Composable
private fun SimilarArtistsStrip(
artists: List<ArtistRef>,
onArtistClick: (String) -> Unit,
) {
HorizontalScrollRow(title = "Similar artists") {
lazyItems(items = artists, key = { it.id }) { artist ->
ArtistCard(
artist = artist,
onClick = { onArtistClick(artist.id) },
)
}
}
}
@Composable
private fun ArtistHeader(
artist: ArtistRef,
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.player.PlayerController
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface ArtistDetailUiState {
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
data class Success(
val detail: ArtistDetailRef,
val similarArtists: List<ArtistRef> = emptyList(),
val topTracks: List<TrackRef> = emptyList(),
) : ArtistDetailUiState
data class Error(val message: String) : ArtistDetailUiState
}
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
try {
val detail = repository.refreshArtistDetail(artistId)
internal.value = ArtistDetailUiState.Success(detail)
loadSecondarySections()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
}
}
/**
* Pulls the non-critical "Similar artists" + "Top tracks" sections
* and folds them into the live Success state. Each failure is
* swallowed (logged only) — these sections render absent rather than
* blocking, so the core artist view never depends on them.
*/
private suspend fun loadSecondarySections() {
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
val current = internal.value
if (current is ArtistDetailUiState.Success) {
internal.value = current.copy(similarArtists = similar, topTracks = top)
}
}
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
try {
block()
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
emptyList()
}
/**
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
}
}
}
/**
* Plays the loaded "Top tracks" list starting at [index] — the top
* tracks are themselves the playable queue (mirrors the web client's
* sectionTracks = top-tracks behaviour). No network round-trip; the
* list is already in the Success state.
*/
fun playTopTracks(index: Int) {
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
if (tracks.isEmpty()) return
player.setQueue(
tracks = tracks,
initialIndex = index.coerceIn(0, tracks.lastIndex),
source = "artist-top:$artistId",
)
}
}
@@ -12,12 +12,15 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.isActive
@@ -39,9 +42,15 @@ import timber.log.Timber
* wrapped ExoPlayer stays paused at the position it had when the
* route was selected.
*
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
* factory wraps that callback into a SharedFlow consumed by the
* NowPlaying surface as a snackbar.
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
@@ -172,13 +181,14 @@ class MinstrelForwardingPlayer(
if (active == null) {
super.play()
} else {
remoteState.setPlayIntent(true)
scope.launch {
runCatching { active.avTransport.play() }
runCatching { retryTransport { active.avTransport.play() } }
.onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) }
.onFailure { handleTransportFailure(active, it) }
}
}
}
@@ -193,13 +203,14 @@ class MinstrelForwardingPlayer(
if (active == null) {
super.pause()
} else {
remoteState.setPlayIntent(false)
scope.launch {
runCatching { active.avTransport.pause() }
runCatching { retryTransport { active.avTransport.pause() } }
.onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) }
.onFailure { handleTransportFailure(active, it) }
}
}
}
@@ -222,8 +233,8 @@ class MinstrelForwardingPlayer(
trackNumber = remoteState.trackNumber,
)
scope.launch {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { handleSoapFailure(active, it) }
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
.onFailure { handleTransportFailure(active, it) }
}
}
}
@@ -253,11 +264,13 @@ class MinstrelForwardingPlayer(
)
scope.launch {
runCatching {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}.onFailure { handleSoapFailure(active, it) }
}.onFailure { handleTransportFailure(active, it) }
}
}
@@ -280,8 +293,8 @@ class MinstrelForwardingPlayer(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { active.avTransport.next() }
.onFailure { handleSoapFailure(active, it) }
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
}
}
@@ -303,8 +316,8 @@ class MinstrelForwardingPlayer(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { active.avTransport.previous() }
.onFailure { handleSoapFailure(active, it) }
runCatching { retryTransport { active.avTransport.previous() } }
.onFailure { handleTransportFailure(active, it) }
}
}
@@ -385,11 +398,34 @@ class MinstrelForwardingPlayer(
super.release()
}
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
pollJob?.cancel()
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
remoteState.applyError(t)
handler.post { onDrop(active.routeName) }
/**
* Run a transport SOAP block, retrying transport-level (IO) failures a
* few times with backoff. A locked phone's WiFi power-save can stall the
* first socket I/O for a second or two; a retry lets the command land once
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
* renderer answered and rejected the action) is NOT retried -- the device
* is alive and retrying won't change its verdict.
*/
private suspend fun <T> retryTransport(block: suspend () -> T): T =
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
/**
* A transport SOAP command failed even after retries. Deliberately does
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
* WiFi power-save can fail one command while the renderer is perfectly
* reachable; dropping on a single command falsely kicked playback back to
* the phone. We log, leave the poll loop running, and nudge an immediate
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
* truly gone, the poll loop trips the drop on its own.
*/
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
if (t is SoapFaultException) {
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
} else {
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
}
pollTrigger.trySend(Unit)
}
private fun onActiveChanged(active: ActiveUpnp?) {
@@ -513,5 +549,39 @@ class MinstrelForwardingPlayer(
// when Sonos's reported Track matches the wrapped player; this only
// kicks in if SOAP fails or Sonos drops the ack entirely.
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
// Transport commands retry transient IO failures so a single WiFi
// power-save stall (locked phone) doesn't abandon the command. 3
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
// worst case at ~7s; a still-failing command then defers to the poll.
const val TRANSPORT_RETRY_ATTEMPTS = 3
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
}
}
/**
* Retry [block] on transient transport-level ([IOException]) failures, with
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
* an [IOException] -- notably [SoapFaultException], where the renderer
* answered and rejected the action -- propagates immediately: the device is
* alive, so retrying won't change its verdict. Extracted from
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
* abandon a single transport command (the bug that falsely reverted Sonos
* playback to local audio).
*/
internal suspend fun <T> retryTransientIo(
attempts: Int,
backoffMs: Long,
block: suspend () -> T,
): T {
var attempt = 0
while (true) {
try {
return block()
} catch (io: IOException) {
attempt += 1
if (attempt >= attempts) throw io
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
delay(backoffMs)
}
}
}
@@ -11,11 +11,13 @@ import javax.inject.Singleton
* Updates flow in from:
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
* - Poll failures -> recordPollFailure()
*
* The poll-failure counter implements the rolling-3 drop heuristic: 3
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
* consecutive poll failures = remote considered dropped (returns true
* from recordPollFailure for the caller to surface). Success resets it.
* from recordPollFailure for the caller to surface). Any success resets it.
* Failed transport commands no longer drop the route -- they retry and
* otherwise defer to this counter (see MinstrelForwardingPlayer).
*/
@Singleton
class RemotePlayerState @Inject constructor() {
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
@Volatile var lastError: Throwable? = null; private set
@Volatile var trackNumber: Int = 0; private set
// The operator's last explicit transport intent (play=true, pause=false),
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
// local playback based on THIS, not isPlaying -- so a play() that fails
// its SOAP (stale/dead renderer) still resumes on the phone instead of
// swallowing the tap. Deliberately NOT cleared by applyError.
@Volatile var lastPlayIntent: Boolean = false; private set
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
// give up waiting). When > 0, a user transport action (next/prev/seekTo
// idx) is in flight: ForwardingPlayer has already moved the wrapped
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
positionMs = 0L
}
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
fun applyError(t: Throwable) {
isPlaying = false
lastError = t
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
positionMs = 0L
durationMs = 0L
isPlaying = false
lastPlayIntent = false
currentTrackUri = ""
lastError = null
consecutivePollFailures = 0
@@ -0,0 +1,21 @@
package com.fabledsword.minstrel.player.output
/** Action the idle-revert collector should take on a player-state change. */
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
/**
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
* idle timer Job is currently running. We ARM only on the transition INTO
* "engaged + not playing" (so repeated paused emissions don't reset the
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
* otherwise IGNORE.
*/
internal fun idleRevertAction(
upnpEngaged: Boolean,
isPlaying: Boolean,
armed: Boolean,
): IdleRevertAction = when {
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
armed -> IdleRevertAction.CANCEL
else -> IdleRevertAction.IGNORE
}
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -147,6 +148,12 @@ class OutputPickerController @Inject constructor(
// can run the longest-common-prefix / common-suffix diff.
private var lastSyncedQueueIds: List<String>? = null
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
// non-playing. Cancelled on resume, route change, or explicit disconnect.
// See idleRevertAction for the arm/cancel decision.
private var idleRevertJob: Job? = null
init {
// Two-arg addCallback registers with no discovery flag —
// androidx.mediarouter 1.7.0's default passive behavior:
@@ -155,7 +162,8 @@ class OutputPickerController @Inject constructor(
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
mediaRouter.addCallback(selector, callback)
// When MinstrelForwardingPlayer reports the active UPnP route has
// dropped (3+ consecutive poll failures or a transport SOAP exception),
// dropped (the poll loop's consecutive-failure threshold tripping --
// the sole drop arbiter; a failed transport command no longer drops),
// clear the UPnP selection state and fall back to local ExoPlayer at
// the last-known remote position. This mirrors selectSystem's disconnect
// path but skips the Stop SOAP since the device is already unreachable.
@@ -163,6 +171,54 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() }
}
scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeIdleRevertWhileUpnp() }
}
/**
* Arm/cancel the idle-revert timer as playback state changes while a
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
* selectUpnp/resync window (active momentarily null, target set) doesn't
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
*/
private suspend fun observeIdleRevertWhileUpnp() {
playerController.uiState.collect { state ->
val engaged = activeUpnpHolder.active.value != null ||
activeUpnpHolder.target.value != null
val armed = idleRevertJob?.isActive == true
when (idleRevertAction(engaged, state.isPlaying, armed)) {
IdleRevertAction.ARM ->
idleRevertJob = scope.launch {
delay(IDLE_REVERT_MS)
revertToPhoneOnIdle()
}
IdleRevertAction.CANCEL -> {
idleRevertJob?.cancel()
idleRevertJob = null
}
IdleRevertAction.IGNORE -> Unit
}
}
}
/**
* Timer fired: the operator paused on a UPnP route and never resumed.
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
* clear the UPnP overlay so routesState falls back to the phone, and
* seek local ExoPlayer to the last remote position. No play(): the
* operator was paused, so we revert routing silently. Guarded by the
* selectUpnpMutex so it can't race selectUpnp/resync.
*/
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
if (remoteState.isPlaying) return@withLock // resumed at the boundary
val active = activeUpnpHolder.active.value
val capturedPositionMs = remoteState.positionMs
runCatching { active?.avTransport?.stop() }
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
idleRevertJob = null
playerController.seekTo(capturedPositionMs)
}
/**
@@ -365,16 +421,19 @@ class OutputPickerController @Inject constructor(
}
/**
* Called when the active UPnP route drops unexpectedly (poll-failure
* threshold or SOAP exception). Captures the last remote position +
* play state, clears UPnP selection, and resumes local ExoPlayer at
* Called when the active UPnP route drops unexpectedly (the poll loop's
* consecutive-failure threshold tripping). Captures the last remote
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
* the same point. Skips the Stop SOAP (device already unreachable).
* The snackbar is handled independently by the NowPlaying surface
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
*/
private fun handleRemoteDrop() {
val capturedPositionMs = remoteState.positionMs
val wasPlayingRemote = remoteState.isPlaying
// Resume on the operator's last play-intent, not the observed remote
// state: after a pause, isPlaying is false, but a failed play() that
// triggered this drop means the operator DID ask to play -- honor it.
val wasPlayingRemote = remoteState.lastPlayIntent
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
@@ -497,6 +556,9 @@ class OutputPickerController @Inject constructor(
rendering = rendering,
),
)
// Selecting a route loads the queue and Plays on the renderer, so
// the standing intent is "play" -- a later drop should resume local.
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
activeUpnpHolder.set(null)
@@ -640,5 +702,10 @@ class OutputPickerController @Inject constructor(
private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing.
const val IDLE_REVERT_MS = 5 * 60 * 1000L
}
}
@@ -17,6 +17,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -45,6 +46,15 @@ class UpnpDiscoveryController @Inject constructor(
) {
private val ssdp = SsdpDiscovery(context)
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
// this client. A dead renderer should fail fast so OutputPickerController's
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
// dispatcher stay shared with the app client.
private val controlHttp: OkHttpClient = okHttp.newBuilder()
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
@@ -91,7 +101,7 @@ class UpnpDiscoveryController @Inject constructor(
fun transportFor(routeId: String): AVTransportClient? {
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
return AVTransportClient(
loggingSoapClient(okHttp, route.name),
loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl,
)
}
@@ -225,6 +235,11 @@ class UpnpDiscoveryController @Inject constructor(
// devices append. Anchored to end-of-string so it never eats
// a legitimate parenthetical inside a name.
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
// Aggressive connect timeout for transport SOAP so a dead renderer
// trips the drop-recovery quickly. Conservative enough not to false-drop
// a slow-but-alive LAN; read timeout still inherits the shared client.
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
}
}
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.requests.data.CancelOutcome
import com.fabledsword.minstrel.requests.data.RequestsRepository
import com.fabledsword.minstrel.shared.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -20,6 +22,10 @@ import javax.inject.Inject
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
// Cadence for in-flight request polling — matches the web client (#369):
// fast enough to feel live, slow enough not to hammer the server.
private const val POLL_INTERVAL_MS = 12_000L
@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
init {
refresh()
// SSE push: reconciler completions arrive here; reload silently so the
// row updates in place rather than flashing the loading spinner.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
.collect { silentReload() }
}
viewModelScope.launch { pollWhileInFlight() }
}
fun refresh(): Job = viewModelScope.launch {
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
}
}
/**
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
* when nothing is in-flight — complements the SSE push so progress stays
* live even if the event stream drops.
*/
private suspend fun pollWhileInFlight() {
while (true) {
if (hasInFlightRequest()) {
silentReload()
}
delay(POLL_INTERVAL_MS)
}
}
private fun hasInFlightRequest(): Boolean {
val state = internal.value
return state is UiState.Success &&
state.data.any { it.status == RequestStatus.APPROVED }
}
/**
* Reload the list in place without flipping to Loading — used by the poll
* loop and the SSE collector so visible rows update without a spinner
* flash. A transient failure keeps the current view; the next reload wins.
*/
private suspend fun silentReload() {
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Keep the current view; the next poll / SSE / pull reconciles.
}
}
/**
* Optimistically removes [id] from the success list so the row
* disappears immediately; on server-side success the row is
@@ -69,6 +69,32 @@ class RemotePlayerStateTest {
assertFalse(state.recordPollFailure())
}
@Test
fun `play intent survives a SOAP error so a failed play can resume locally`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.applyError(RuntimeException("connect timeout"))
// isPlaying is cleared by the error, but the operator's intent persists.
assertFalse(state.isPlaying)
assertTrue(state.lastPlayIntent)
}
@Test
fun `pausing clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.setPlayIntent(false)
assertFalse(state.lastPlayIntent)
}
@Test
fun `reset clears play intent`() {
val state = RemotePlayerState()
state.setPlayIntent(true)
state.reset()
assertFalse(state.lastPlayIntent)
}
private companion object {
const val DROP_THRESHOLD = 30
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Locks in the transport-command retry semantics behind the locked-phone
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
* SOAP fault from a responding renderer must NOT be retried, and an
* exhausted retry must rethrow rather than loop forever. Without this, a
* single failed command falsely reverted Sonos playback to the phone.
*/
class RetryTransientIoTest {
@Test
fun `returns immediately on first success`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
"ok"
}
assertEquals("ok", result)
assertEquals(1, calls)
}
@Test
fun `retries transient IO failure then succeeds`() = runTest {
var calls = 0
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
if (calls < 2) throw IOException("connect timeout")
"ok"
}
assertEquals("ok", result)
assertEquals(2, calls)
}
@Test
fun `rethrows IO failure after exhausting attempts`() = runTest {
var calls = 0
assertFailsWith<IOException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw IOException("still asleep")
}
}
assertEquals(3, calls)
}
@Test
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
var calls = 0
assertFailsWith<SoapFaultException> {
retryTransientIo(attempts = 3, backoffMs = 0L) {
calls += 1
throw SoapFaultException("718", "Invalid InstanceID")
}
}
assertEquals(1, calls)
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.player.output
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class IdleRevertDecisionTest {
@Test
fun `engaged and paused and not armed arms the timer`() {
assertEquals(
IdleRevertAction.ARM,
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
)
}
@Test
fun `engaged and paused and already armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
)
}
@Test
fun `resuming playback cancels an armed timer`() {
assertEquals(
IdleRevertAction.CANCEL,
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
)
}
@Test
fun `playing with no timer armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
)
}
@Test
fun `disengaging UPnP cancels an armed timer`() {
assertEquals(
IdleRevertAction.CANCEL,
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
)
}
@Test
fun `disengaged with no timer armed is ignored`() {
assertEquals(
IdleRevertAction.IGNORE,
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
)
}
}
+48 -4
View File
@@ -5,12 +5,15 @@ import (
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
@@ -53,6 +56,35 @@ func main() {
}
}
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
// full delta walk, catching anything the watcher missed (inotify watch-limit
// exhaustion, dropped events). Fixed — there is no operator configuration.
const safetyNetScanInterval = 12 * time.Hour
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
// is cancelled, deferring to TryStartScan's in-flight guard so it never
// collides with a manual, startup, or watcher-driven scan.
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
) {
ticker := time.NewTicker(safetyNetScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
if err != nil {
logger.Error("safety-net scan: try start failed", "err", err)
} else if !started && existing != nil {
logger.Info("safety-net scan skipped — prior still in flight",
"in_flight_id", existing.ID)
}
}
}
}
func run() error {
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
flag.Parse()
@@ -237,12 +269,24 @@ func run() error {
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
}
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
scanner, coverEnricher, scanCfg)
scheduler.Start(ctx)
// Filesystem watcher: near-instant pickup of new/changed files via
// fsnotify, scanning just the affected paths + enriching their albums
// inline. Replaces the removed configurable scan scheduler.
watcher := library.NewWatcher(scanner, coverEnricher,
logger.With("component", "watcher"), cfg.Library.ScanPaths)
go func() {
if werr := watcher.Run(ctx); werr != nil {
logger.Warn("library watcher exited", "err", werr)
}
}()
// Safety-net delta walk backstops the watcher (inotify limits / dropped
// events) at a fixed low frequency. No operator config.
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
logger.With("component", "scan_safetynet"), scanCfg)
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.StreamSecret = cfg.StreamSecret
+2
View File
@@ -4,6 +4,7 @@ go 1.25.0
require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.2.5
github.com/go-co-op/gocron/v2 v2.21.2
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -25,5 +26,6 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+2
View File
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
-155
View File
@@ -1,155 +0,0 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"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/library"
)
type scanScheduleResp struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
NextScheduledAt *string `json:"next_scheduled_at"`
}
type scanScheduleReq struct {
Mode string `json:"mode"`
IntervalHours *int `json:"interval_hours"`
TimeOfDay *string `json:"time_of_day"`
WeeklyDay *int `json:"weekly_day"`
}
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
row, err := q.GetScanSchedule(r.Context())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
h.logger.Error("admin: scan_schedule singleton row missing")
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
return
}
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
}
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
var req scanScheduleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if err := validateScheduleReq(&req); err != nil {
writeErr(w, apierror.BadRequest("validation", err.Error()))
return
}
q := dbq.New(h.pool)
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
Mode: req.Mode,
IntervalHours: pgInt32Ptr(req.IntervalHours),
TimeOfDay: req.TimeOfDay,
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
}); err != nil {
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
return
}
h.scheduler.Refresh()
row, err := q.GetScanSchedule(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
return
}
cfg := library.ScheduleConfig{Mode: row.Mode}
if row.IntervalHours != nil {
cfg.IntervalHours = int(*row.IntervalHours)
}
if row.TimeOfDay != nil {
cfg.TimeOfDay = *row.TimeOfDay
}
if row.WeeklyDay != nil {
cfg.WeeklyDay = int(*row.WeeklyDay)
}
next := cfg.NextFire(time.Now())
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
}
func validateScheduleReq(req *scanScheduleReq) error {
switch req.Mode {
case "off":
req.IntervalHours = nil
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "interval":
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
}
req.TimeOfDay = nil
req.WeeklyDay = nil
return nil
case "daily":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=daily")
}
req.IntervalHours = nil
req.WeeklyDay = nil
return nil
case "weekly":
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
return fmt.Errorf("time_of_day required when mode=weekly")
}
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
}
req.IntervalHours = nil
return nil
default:
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
}
}
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
resp := scanScheduleResp{Mode: row.Mode}
if row.IntervalHours != nil {
v := int(*row.IntervalHours)
resp.IntervalHours = &v
}
if row.TimeOfDay != nil {
v := *row.TimeOfDay
resp.TimeOfDay = &v
}
if row.WeeklyDay != nil {
v := int(*row.WeeklyDay)
resp.WeeklyDay = &v
}
if !nextFire.IsZero() {
s := nextFire.UTC().Format(time.RFC3339)
resp.NextScheduledAt = &s
}
return resp
}
func pgInt32Ptr(src *int) *int32 {
if src == nil {
return nil
}
v := int32(*src)
return &v
}
-181
View File
@@ -1,181 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
func newAdminScheduleRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
})
return r
}
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedget", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "off" {
t.Errorf("Mode = %q, want off", resp.Mode)
}
if resp.NextScheduledAt != nil {
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
}
}
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedpatch", "pw", true)
body := `{"mode":"daily","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "daily" {
t.Errorf("Mode = %q, want daily", resp.Mode)
}
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
}
if resp.NextScheduledAt == nil {
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
} else {
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
if err != nil {
t.Errorf("NextScheduledAt parse: %v", err)
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
// Handler computes next-fire from time.Now() in the host zone, so
// the local-time hour is what's stable across CI runners regardless
// of TZ; the wire timestamp is UTC but parses back to the same
// instant either way.
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
}
}
}
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedweekly", "pw", true)
body := `{"mode":"weekly","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedoff", "pw", true)
if _, err := pool.Exec(context.Background(),
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"mode":"off","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var timeOfDay *string
if err := pool.QueryRow(context.Background(),
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
).Scan(&timeOfDay); err != nil {
t.Fatalf("verify: %v", err)
}
if timeOfDay != nil {
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
}
}
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
user := seedUser(t, pool, "schednonadmin", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
}
+3 -5
View File
@@ -28,7 +28,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
eventbus: bus,
@@ -87,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
authed.Get("/albums/{id}", h.handleGetAlbum)
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/shuffle", h.handleLibraryShuffle)
@@ -167,8 +168,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
@@ -222,7 +221,6 @@ type handlers struct {
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
+79
View File
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
const (
similarArtistsDefaultLimit = 12
artistTopTracksDefaultLimit = 5
)
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
// in-library artists similar to {id} (ranked by similarity score) as a flat
// ArtistRef list with cover + album count. Empty when the similarity ingest
// has no matches yet; 404 when the artist doesn't exist.
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for similar", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
})
if err != nil {
h.logger.Error("api: list similar artists", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]ArtistRef, 0, len(rows))
for _, row := range rows {
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
}
writeJSON(w, http.StatusOK, out)
}
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
// the current user's most-played tracks for {id} (skips excluded, quarantine
// filtered). Empty when the user hasn't played this artist; 404 when the
// artist doesn't exist.
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("artist"))
return
}
h.logger.Error("api: get artist for top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
})
if err != nil {
h.logger.Error("api: list artist top tracks", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
out := make([]TrackRef, 0, len(rows))
for _, row := range rows {
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
}
writeJSON(w, http.StatusOK, out)
}
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
// the online source for the client's always-present "Shuffle all"
// (#427 S4). N random tracks across the whole library, per-user
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{
"/api/artists",
-9
View File
@@ -439,15 +439,6 @@ type ScanRun struct {
ArtistArtEnrich []byte
}
type ScanSchedule struct {
ID bool
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
UpdatedAt pgtype.Timestamptz
}
type ScrobbleQueue struct {
ID pgtype.UUID
UserID pgtype.UUID
+77
View File
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
return items, nil
}
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
JOIN tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
LIMIT $3
`
type ListMostPlayedTracksForArtistParams struct {
ArtistID pgtype.UUID
UserID pgtype.UUID
ResultLimit int32
}
type ListMostPlayedTracksForArtistRow struct {
Track Track
AlbumTitle string
ArtistName string
}
// Top tracks for one artist by this user's completed-play count (skips
// excluded, quarantine filtered). Same projection as
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListMostPlayedTracksForArtistRow
for rows.Next() {
var i ListMostPlayedTracksForArtistRow
if err := rows.Scan(
&i.Track.ID,
&i.Track.Title,
&i.Track.AlbumID,
&i.Track.ArtistID,
&i.Track.TrackNumber,
&i.Track.DiscNumber,
&i.Track.DurationMs,
&i.Track.FilePath,
&i.Track.FileSize,
&i.Track.FileFormat,
&i.Track.Bitrate,
&i.Track.Mbid,
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
-71
View File
@@ -1,71 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: scan_schedule.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getScanSchedule = `-- name: GetScanSchedule :one
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
FROM scan_schedule WHERE id = true
`
type GetScanScheduleRow struct {
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
UpdatedAt pgtype.Timestamptz
}
// M7 recurring-scans: returns the singleton schedule row. Used by the
// scheduler goroutine on boot and config-refresh, and by the admin GET
// handler.
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
row := q.db.QueryRow(ctx, getScanSchedule)
var i GetScanScheduleRow
err := row.Scan(
&i.Mode,
&i.IntervalHours,
&i.TimeOfDay,
&i.WeeklyDay,
&i.UpdatedAt,
)
return i, err
}
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
UPDATE scan_schedule
SET mode = $1,
interval_hours = $2,
time_of_day = $3,
weekly_day = $4,
updated_at = now()
WHERE id = true
`
type UpdateScanScheduleParams struct {
Mode string
IntervalHours *int32
TimeOfDay *string
WeeklyDay *int32
}
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
// The application normalizes mode='off' to NULL the per-mode fields
// before calling this.
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
_, err := q.db.Exec(ctx, updateScanSchedule,
arg.Mode,
arg.IntervalHours,
arg.TimeOfDay,
arg.WeeklyDay,
)
return err
}
+71
View File
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
return items, nil
}
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :many
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count
FROM (
SELECT artist_b_id, max(score) AS sim_score
FROM artist_similarity
WHERE artist_a_id = $1
GROUP BY artist_b_id
) s
JOIN artists ON artists.id = s.artist_b_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
ORDER BY created_at DESC LIMIT 1
) cov ON true
LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = artists.id
) cnt ON true
ORDER BY s.sim_score DESC, artists.sort_name
LIMIT $2
`
type ListSimilarArtistsForArtistParams struct {
SeedArtistID pgtype.UUID
ResultLimit int32
}
type ListSimilarArtistsForArtistRow struct {
Artist Artist
CoverAlbumID pgtype.UUID
AlbumCount int64
}
// In-library artists similar to a seed artist, ranked by best similarity
// score across sources (deduped per candidate). cover_album_id + album_count
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSimilarArtistsForArtistRow
for rows.Next() {
var i ListSimilarArtistsForArtistRow
if err := rows.Scan(
&i.Artist.ID,
&i.Artist.Name,
&i.Artist.SortName,
&i.Artist.Mbid,
&i.Artist.CreatedAt,
&i.Artist.UpdatedAt,
&i.Artist.ArtistThumbPath,
&i.Artist.ArtistFanartPath,
&i.Artist.ArtistArtSource,
&i.Artist.ArtistArtSourcesVersion,
&i.CoverAlbumID,
&i.AlbumCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
@@ -0,0 +1,21 @@
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
-- reversible. The application no longer reads or writes this table.
CREATE TABLE scan_schedule (
id boolean PRIMARY KEY DEFAULT true,
mode text NOT NULL DEFAULT 'off',
interval_hours int,
time_of_day text,
weekly_day int,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scan_schedule_singleton CHECK (id = true),
CONSTRAINT scan_schedule_mode_check
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
CONSTRAINT scan_schedule_interval_check
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
CONSTRAINT scan_schedule_daily_check
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
CONSTRAINT scan_schedule_weekly_check
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
);
INSERT INTO scan_schedule (id) VALUES (true);
@@ -0,0 +1,4 @@
-- Retire the configurable scan scheduler. New-file pickup is now handled by
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
-- so the operator-configurable schedule (and its admin UI) is gone.
DROP TABLE IF EXISTS scan_schedule;
+26
View File
@@ -231,6 +231,32 @@ WHERE NOT EXISTS (
ORDER BY p.cnt DESC, t.id
LIMIT $2;
-- name: ListMostPlayedTracksForArtist :many
-- Top tracks for one artist by this user's completed-play count (skips
-- excluded, quarantine filtered). Same projection as
-- ListMostPlayedTracksForUser plus an artist_id filter, so the handler
-- reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
GROUP BY track_id
)
SELECT sqlc.embed(t),
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
JOIN tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = sqlc.arg(artist_id)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
LIMIT sqlc.arg(result_limit);
-- name: ListLastPlayedArtistsForUser :many
-- M6a: artists ranked by max(play_events.started_at) for the user, with
-- a derived cover_album_id via a representative-album lateral join (most
-18
View File
@@ -1,18 +0,0 @@
-- name: GetScanSchedule :one
-- M7 recurring-scans: returns the singleton schedule row. Used by the
-- scheduler goroutine on boot and config-refresh, and by the admin GET
-- handler.
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
FROM scan_schedule WHERE id = true;
-- name: UpdateScanSchedule :exec
-- Admin PATCH replaces the whole row; CHECK constraints enforce validity.
-- The application normalizes mode='off' to NULL the per-mode fields
-- before calling this.
UPDATE scan_schedule
SET mode = $1,
interval_hours = $2,
time_of_day = $3,
weekly_day = $4,
updated_at = now()
WHERE id = true;
+26
View File
@@ -60,3 +60,29 @@ ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
candidate_name = EXCLUDED.candidate_name,
score = EXCLUDED.score,
fetched_at = now();
-- name: ListSimilarArtistsForArtist :many
-- In-library artists similar to a seed artist, ranked by best similarity
-- score across sources (deduped per candidate). cover_album_id + album_count
-- mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
SELECT sqlc.embed(artists),
cov.id AS cover_album_id,
cnt.album_count::bigint AS album_count
FROM (
SELECT artist_b_id, max(score) AS sim_score
FROM artist_similarity
WHERE artist_a_id = sqlc.arg(seed_artist_id)
GROUP BY artist_b_id
) s
JOIN artists ON artists.id = s.artist_b_id
LEFT JOIN LATERAL (
SELECT id FROM albums
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
ORDER BY created_at DESC LIMIT 1
) cov ON true
LEFT JOIN LATERAL (
SELECT count(*) AS album_count
FROM albums WHERE artist_id = artists.id
) cnt ON true
ORDER BY s.sim_score DESC, artists.sort_name
LIMIT sqlc.arg(result_limit);
+56 -11
View File
@@ -87,7 +87,7 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
return nil
}
if err := s.scanFile(ctx, q, path, &stats); err != nil {
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
s.logger.Warn("library scan file error", "path", path, "err", err)
stats.Errored++
}
@@ -114,19 +114,24 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
return stats, nil
}
func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
// scanFile upserts a single audio file. Returns the album ID the track
// belongs to and whether the file was added/updated (false = skipped as
// unchanged), so watcher-driven callers can enrich just the changed albums.
func (s *Scanner) scanFile(
ctx context.Context, q *dbq.Queries, path string, stats *Stats,
) (pgtype.UUID, bool, error) {
stats.Scanned++
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("stat: %w", err)
}
mtime := info.ModTime()
existing, err := q.GetTrackByPath(ctx, path)
knownTrack := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lookup: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
}
// Incremental skip: only when the file hasn't changed AND we already have
// a real duration. The second clause lets older scans that recorded
@@ -135,18 +140,18 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
// scans short-circuit as before.
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
stats.Skipped++
return nil
return pgtype.UUID{}, false, nil
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("open: %w", err)
}
defer func() { _ = f.Close() }()
meta, err := tag.ReadFrom(f)
if err != nil {
return fmt.Errorf("tag read: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
}
albumMBID, artistMBID := extractMBIDs(meta)
recordingMBID := extractRecordingMBID(meta)
@@ -166,11 +171,11 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
artist, err := s.resolveArtist(ctx, q, artistName, artistMBID)
if err != nil {
return fmt.Errorf("artist: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("artist: %w", err)
}
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID)
if err != nil {
return fmt.Errorf("album: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("album: %w", err)
}
trackNum, _ := meta.Track()
@@ -213,7 +218,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
track, err := q.UpsertTrack(ctx, params)
if err != nil {
return fmt.Errorf("upsert track: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("upsert track: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack,
syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil {
@@ -227,7 +232,47 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
} else {
stats.Added++
}
return nil
return album.ID, true, nil
}
// ScanFiles processes a specific set of audio file paths (watcher-driven),
// applying the same upsert + delta-skip logic as a full Scan. Non-audio or
// unreadable paths are skipped (logged), never fatal. Returns the distinct
// album IDs whose tracks were added or updated, so the caller can enrich
// just those albums inline rather than waiting for a batch pass.
func (s *Scanner) ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error) {
q := dbq.New(s.pool)
seen := make(map[[16]byte]struct{})
changed := make([]pgtype.UUID, 0)
var stats Stats
for _, path := range paths {
if ctx.Err() != nil {
return changed, ctx.Err()
}
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
continue
}
albumID, didChange, err := s.scanFile(ctx, q, path, &stats)
if err != nil {
s.logger.Warn("library watch: scan file error", "path", path, "err", err)
continue
}
if didChange && albumID.Valid {
if _, ok := seen[albumID.Bytes]; !ok {
seen[albumID.Bytes] = struct{}{}
changed = append(changed, albumID)
}
}
}
if stats.Added > 0 || stats.Updated > 0 {
s.logger.Info("library watch: scan batch",
"added", stats.Added,
"updated", stats.Updated,
"skipped", stats.Skipped,
"errored", stats.Errored,
)
}
return changed, nil
}
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) {
+39
View File
@@ -121,6 +121,45 @@ func TestScanner_Integration(t *testing.T) {
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
}
// ScanFiles (watcher-driven targeted scan): a newly-added file is picked
// up by path without walking the whole tree, and its album ID is returned
// so the watcher can enrich just that album. A non-audio path in the batch
// is silently ignored.
newFile := filepath.Join(root, "artistX/albumA/03.mp3")
writeTestMP3(t, newFile, map[string]string{
"TIT2": "Track Three", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "3", "TYER": "2020",
})
notAudio := filepath.Join(root, "artistX/albumA/cover.txt")
if err := os.WriteFile(notAudio, []byte("nope"), 0o644); err != nil {
t.Fatalf("write non-audio: %v", err)
}
changed, err := scanner.ScanFiles(ctx, []string{newFile, notAudio})
if err != nil {
t.Fatalf("ScanFiles: %v", err)
}
if len(changed) != 1 || !changed[0].Valid {
t.Fatalf("ScanFiles changed albums = %+v, want exactly 1 valid", changed)
}
// The targeted scan upserts only the named file; the rest of the library
// is untouched (no full walk).
if _, err := q.GetTrackByPath(ctx, newFile); err != nil {
t.Fatalf("GetTrackByPath after ScanFiles: %v", err)
}
// Re-running ScanFiles on the now-unchanged file (duration probed) skips it,
// so no album is reported as changed.
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
t.Fatalf("seed durations (2): %v", err)
}
changed2, err := scanner.ScanFiles(ctx, []string{newFile})
if err != nil {
t.Fatalf("ScanFiles (rescan): %v", err)
}
if len(changed2) != 0 {
t.Errorf("ScanFiles on unchanged file changed = %+v, want none", changed2)
}
}
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
-238
View File
@@ -1,238 +0,0 @@
package library
import (
"context"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// ScheduleConfig represents the operator's recurring scan schedule.
// Loaded from the scan_schedule singleton row on boot and on Refresh.
type ScheduleConfig struct {
Mode string // "off" | "interval" | "daily" | "weekly"
IntervalHours int
TimeOfDay string // "HH:MM" (24h)
WeeklyDay int // 1..7 (Mon..Sun, ISO 8601)
}
// NextFire returns the next time the scan should fire given a reference
// "now". Returns zero time when mode == "off" or config is invalid.
//
// DST semantics: "daily 02:30" on a spring-forward day will fire at the
// computed time using the local zone's rules (Go time.Date silently
// normalizes a non-existent local time). Single-operator scale; documented
// as known and acceptable.
func (c ScheduleConfig) NextFire(now time.Time) time.Time {
switch c.Mode {
case "off":
return time.Time{}
case "interval":
if c.IntervalHours <= 0 {
return time.Time{}
}
return now.Add(time.Duration(c.IntervalHours) * time.Hour)
case "daily":
h, m, ok := parseHHMM(c.TimeOfDay)
if !ok {
return time.Time{}
}
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next
case "weekly":
h, m, ok := parseHHMM(c.TimeOfDay)
if !ok || c.WeeklyDay < 1 || c.WeeklyDay > 7 {
return time.Time{}
}
// Map ISO weekday (1=Mon..7=Sun) to Go's time.Weekday (0=Sun..6=Sat).
targetGoDow := c.WeeklyDay % 7
nowGoDow := int(now.Weekday())
daysAhead := (targetGoDow - nowGoDow + 7) % 7
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
next = next.AddDate(0, 0, daysAhead)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
return next
}
return time.Time{}
}
func parseHHMM(s string) (h, m int, ok bool) {
if len(s) != 5 || s[2] != ':' {
return 0, 0, false
}
for i, c := range s {
if i == 2 {
continue
}
if c < '0' || c > '9' {
return 0, 0, false
}
}
h = int(s[0]-'0')*10 + int(s[1]-'0')
m = int(s[3]-'0')*10 + int(s[4]-'0')
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, false
}
return h, m, true
}
// Scheduler runs in a goroutine and triggers RunScan on the operator's
// configured cadence via library.TryStartScan (which handles in-flight
// conflicts and stale-row reaping).
type Scheduler struct {
pool *pgxpool.Pool
logger *slog.Logger
scanner *Scanner
enricher *coverart.Enricher
scanCfg RunScanConfig
mu sync.RWMutex
cfg ScheduleConfig
nextFire time.Time
refresh chan struct{}
stop chan struct{}
}
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger,
scanner *Scanner, enricher *coverart.Enricher,
scanCfg RunScanConfig,
) *Scheduler {
return &Scheduler{
pool: pool, logger: logger,
scanner: scanner, enricher: enricher, scanCfg: scanCfg,
refresh: make(chan struct{}, 1),
stop: make(chan struct{}),
}
}
// Start launches the scheduler's loop goroutine. The loop runs until
// the passed ctx is cancelled or Stop is called.
func (s *Scheduler) Start(ctx context.Context) {
go s.loop(ctx)
}
// Stop signals the loop to exit. Not idempotent — call only once
// (typically at server shutdown).
func (s *Scheduler) Stop() { close(s.stop) }
// Refresh signals the loop to reload config and recompute next-fire.
// Non-blocking: additional Refresh calls coalesce on the buffered channel.
func (s *Scheduler) Refresh() {
select {
case s.refresh <- struct{}{}:
default:
}
}
// NextFire returns the cached next-fire time. Zero when mode=off.
func (s *Scheduler) NextFire() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nextFire
}
func (s *Scheduler) setState(cfg ScheduleConfig, next time.Time) {
s.mu.Lock()
s.cfg = cfg
s.nextFire = next
s.mu.Unlock()
}
func (s *Scheduler) loadConfig(ctx context.Context) (ScheduleConfig, error) {
q := dbq.New(s.pool)
row, err := q.GetScanSchedule(ctx)
if err != nil {
return ScheduleConfig{}, err
}
cfg := ScheduleConfig{Mode: row.Mode}
if row.IntervalHours != nil {
cfg.IntervalHours = int(*row.IntervalHours)
}
if row.TimeOfDay != nil {
cfg.TimeOfDay = *row.TimeOfDay
}
if row.WeeklyDay != nil {
cfg.WeeklyDay = int(*row.WeeklyDay)
}
return cfg, nil
}
func (s *Scheduler) loop(ctx context.Context) {
for {
cfg, err := s.loadConfig(ctx)
if err != nil {
s.logger.Error("scheduler: load config failed", "err", err)
retry := time.NewTimer(1 * time.Minute)
select {
case <-retry.C:
case <-s.refresh:
retry.Stop()
case <-s.stop:
retry.Stop()
return
case <-ctx.Done():
retry.Stop()
return
}
continue
}
next := cfg.NextFire(time.Now())
s.setState(cfg, next)
if next.IsZero() {
s.logger.Info("scheduler: mode=off, waiting for config change")
select {
case <-s.refresh:
continue
case <-s.stop:
return
case <-ctx.Done():
return
}
}
wait := time.Until(next)
s.logger.Info("scheduler: next scan scheduled", "at", next, "in", wait)
timer := time.NewTimer(wait)
select {
case <-timer.C:
s.tryFire(ctx)
case <-s.refresh:
timer.Stop()
case <-s.stop:
timer.Stop()
return
case <-ctx.Done():
timer.Stop()
return
}
}
}
func (s *Scheduler) tryFire(ctx context.Context) {
started, existing, err := TryStartScan(
ctx, s.pool, s.scanner, s.enricher,
s.logger.With("source", "scheduler"), s.scanCfg,
)
if err != nil {
s.logger.Error("scheduler: try start failed", "err", err)
return
}
if !started && existing != nil {
s.logger.Info("scheduler: scan skipped — prior still in flight",
"in_flight_id", existing.ID,
"in_flight_started_at", existing.StartedAt.Time)
}
}
-129
View File
@@ -1,129 +0,0 @@
package library
import (
"testing"
"time"
)
func TestNextFire_Off(t *testing.T) {
c := ScheduleConfig{Mode: "off"}
if got := c.NextFire(time.Now()); !got.IsZero() {
t.Errorf("NextFire = %v, want zero time", got)
}
}
func TestNextFire_Interval(t *testing.T) {
c := ScheduleConfig{Mode: "interval", IntervalHours: 6}
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
want := time.Date(2026, 5, 6, 20, 0, 0, 0, time.UTC)
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_Daily_Tomorrow(t *testing.T) {
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
want := time.Date(2026, 5, 7, 3, 0, 0, 0, time.UTC)
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_Daily_Today(t *testing.T) {
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
now := time.Date(2026, 5, 6, 2, 0, 0, 0, time.UTC)
want := time.Date(2026, 5, 6, 3, 0, 0, 0, time.UTC)
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_Weekly_UpcomingDay(t *testing.T) {
// Tuesday 2pm, schedule = Sunday (ISO 7) 03:00 → upcoming Sunday at 03:00.
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 7, TimeOfDay: "03:00"}
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) // 2026-05-05 = Tuesday
want := time.Date(2026, 5, 10, 3, 0, 0, 0, time.UTC) // 2026-05-10 = Sunday
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_Weekly_TodayBeforeTime(t *testing.T) {
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} // ISO 2 = Tuesday
now := time.Date(2026, 5, 5, 2, 0, 0, 0, time.UTC)
want := time.Date(2026, 5, 5, 3, 0, 0, 0, time.UTC)
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_Weekly_TodayAfterTime(t *testing.T) {
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"}
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC)
want := time.Date(2026, 5, 12, 3, 0, 0, 0, time.UTC)
if got := c.NextFire(now); !got.Equal(want) {
t.Errorf("NextFire = %v, want %v", got, want)
}
}
func TestNextFire_InvalidConfigs(t *testing.T) {
cases := []ScheduleConfig{
{Mode: "interval", IntervalHours: 0},
{Mode: "interval", IntervalHours: -1},
{Mode: "daily", TimeOfDay: "bad"},
{Mode: "daily", TimeOfDay: "25:00"},
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 0},
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 8},
{Mode: "unknown"},
}
now := time.Now()
for _, c := range cases {
if got := c.NextFire(now); !got.IsZero() {
t.Errorf("NextFire(%+v) = %v, want zero", c, got)
}
}
}
func TestParseHHMM(t *testing.T) {
cases := []struct {
in string
h, m int
wantOK bool
}{
{"03:00", 3, 0, true},
{"23:59", 23, 59, true},
{"00:00", 0, 0, true},
{"24:00", 0, 0, false},
{"23:60", 0, 0, false},
{"3:00", 0, 0, false},
{"03-00", 0, 0, false},
{"ab:cd", 0, 0, false},
}
for _, c := range cases {
h, m, ok := parseHHMM(c.in)
if ok != c.wantOK || (ok && (h != c.h || m != c.m)) {
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)",
c.in, h, m, ok, c.h, c.m, c.wantOK)
}
}
}
func TestScheduler_Refresh_NonBlocking(t *testing.T) {
s := &Scheduler{
refresh: make(chan struct{}, 1),
stop: make(chan struct{}),
}
s.Refresh()
done := make(chan struct{})
go func() {
s.Refresh()
s.Refresh()
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Errorf("Refresh blocked when channel was full")
}
}
+249
View File
@@ -0,0 +1,249 @@
package library
import (
"context"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
// watcherDebounce is the quiet period after the last filesystem event before
// the watcher processes the pending batch. Copying an album emits a burst of
// write events; waiting for quiet coalesces the whole album into one scan.
const watcherDebounce = 3 * time.Second
// watchAction is the pure decision for a single filesystem event.
type watchAction int
const (
watchIgnore watchAction = iota // not a relevant change
watchEnqueueFile // an audio file was created/written
watchScanDir // a directory appeared; scan it recursively
)
// classifyEvent decides what to do with a filesystem event, separated from I/O
// so it is unit-testable. Only Create/Write matter (removals are not pruned
// here -- see the watcher's design non-goals). A newly-created directory is
// scanned recursively because fsnotify does not emit events for files that
// already existed inside a directory that was moved/copied in wholesale.
func classifyEvent(op fsnotify.Op, isDir, isAudio bool) watchAction {
if op&(fsnotify.Create|fsnotify.Write) == 0 {
return watchIgnore
}
if isDir {
if op&fsnotify.Create != 0 {
return watchScanDir
}
return watchIgnore
}
if isAudio {
return watchEnqueueFile
}
return watchIgnore
}
// Watcher delivers near-instant library updates by watching the scan roots
// with fsnotify and scanning just the changed files (plus inline cover-art
// enrichment for the affected albums), instead of waiting for a full scan.
// It is recursive: a watch is added per directory, and new directories get a
// watch as they appear. inotify watch-limit exhaustion on huge libraries is
// logged, not fatal -- the periodic safety-net scan covers anything missed.
type Watcher struct {
scanner *Scanner
enricher *coverart.Enricher
logger *slog.Logger
roots []string
debounce time.Duration
}
func NewWatcher(
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
) *Watcher {
return &Watcher{
scanner: scanner,
enricher: enricher,
logger: logger,
roots: roots,
debounce: watcherDebounce,
}
}
// Run blocks until ctx is cancelled. Returns a non-nil error only when the
// fsnotify watcher cannot be created at all; per-path watch failures and
// fsnotify errors are logged and tolerated.
func (w *Watcher) Run(ctx context.Context) error {
if len(w.roots) == 0 {
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
return nil
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
w.logger.Error("library watcher: create failed", "err", err)
return err
}
defer func() { _ = fsw.Close() }()
for _, root := range w.roots {
w.addRecursive(fsw, root)
}
w.logger.Info("library watcher: started", "roots", len(w.roots))
batches := make(chan []string, batchQueueDepth)
go w.processLoop(ctx, batches)
w.eventLoop(ctx, fsw, batches)
return nil
}
// eventLoop reads fsnotify events, coalesces them via a debounce timer, and
// hands drained batches to the process loop. Returns on ctx cancellation.
func (w *Watcher) eventLoop(ctx context.Context, fsw *fsnotify.Watcher, batches chan<- []string) {
pending := make(map[string]struct{})
timer := time.NewTimer(w.debounce)
timer.Stop()
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
timer.Stop()
w.logger.Info("library watcher: stopped")
return
case event, ok := <-fsw.Events:
if !ok {
return
}
if w.handleEvent(fsw, event, pending) {
timer.Reset(w.debounce)
timerC = timer.C
}
case err, ok := <-fsw.Errors:
if ok {
w.logger.Warn("library watcher: fsnotify error", "err", err)
}
case <-timerC:
timerC = nil
paths := drainPending(pending)
if len(paths) == 0 {
continue
}
select {
case batches <- paths:
case <-ctx.Done():
return
}
}
}
}
// handleEvent applies one event to the pending set and reports whether the
// debounce timer should be (re)armed.
func (w *Watcher) handleEvent(
fsw *fsnotify.Watcher, event fsnotify.Event, pending map[string]struct{},
) bool {
info, err := os.Stat(event.Name)
isDir := err == nil && info.IsDir()
isAudio := audioExtensions[strings.ToLower(filepath.Ext(event.Name))]
switch classifyEvent(event.Op, isDir, isAudio) {
case watchScanDir:
w.addRecursive(fsw, event.Name)
return w.enqueueDirAudio(event.Name, pending)
case watchEnqueueFile:
pending[event.Name] = struct{}{}
return true
case watchIgnore:
return false
default:
return false
}
}
// processLoop serializes batch processing so a slow remote enrichment never
// blocks the fsnotify event loop.
func (w *Watcher) processLoop(ctx context.Context, batches <-chan []string) {
for {
select {
case <-ctx.Done():
return
case paths, ok := <-batches:
if !ok {
return
}
w.process(ctx, paths)
}
}
}
// process scans the changed files then enriches just the affected albums.
func (w *Watcher) process(ctx context.Context, paths []string) {
changed, err := w.scanner.ScanFiles(ctx, paths)
if err != nil {
w.logger.Warn("library watcher: scan batch failed", "err", err)
}
if w.enricher != nil {
for _, albumID := range changed {
if ctx.Err() != nil {
return
}
if eerr := w.enricher.EnrichAlbum(ctx, albumID); eerr != nil {
w.logger.Warn("library watcher: enrich album failed", "err", eerr)
}
}
}
if len(changed) > 0 {
w.logger.Info("library watcher: applied", "files", len(paths), "albums", len(changed))
}
}
// addRecursive adds an fsnotify watch for root and every subdirectory under it.
// Per-directory failures (inotify limits, permissions) are logged and skipped.
func (w *Watcher) addRecursive(fsw *fsnotify.Watcher, root string) {
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil //nolint:nilerr // tolerate unreadable subtrees
}
if aerr := fsw.Add(path); aerr != nil {
w.logger.Warn("library watcher: add watch failed", "path", path, "err", aerr)
}
return nil
})
}
// enqueueDirAudio enqueues every audio file under dir (used when a directory
// appears wholesale, since fsnotify emits no events for its existing children).
func (w *Watcher) enqueueDirAudio(dir string, pending map[string]struct{}) bool {
added := false
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil //nolint:nilerr // tolerate unreadable subtrees
}
if audioExtensions[strings.ToLower(filepath.Ext(path))] {
pending[path] = struct{}{}
added = true
}
return nil
})
return added
}
// drainPending returns the pending paths and clears the set.
func drainPending(pending map[string]struct{}) []string {
if len(pending) == 0 {
return nil
}
paths := make([]string, 0, len(pending))
for p := range pending {
paths = append(paths, p)
}
clear(pending)
return paths
}
// batchQueueDepth bounds how many debounced batches can be in flight before the
// event loop would block; generous since each batch is processed quickly.
const batchQueueDepth = 16
+59
View File
@@ -0,0 +1,59 @@
package library
import (
"sort"
"testing"
"github.com/fsnotify/fsnotify"
)
func TestClassifyEvent(t *testing.T) {
tests := []struct {
name string
op fsnotify.Op
isDir bool
isAudio bool
want watchAction
}{
{"create audio file enqueues", fsnotify.Create, false, true, watchEnqueueFile},
{"write audio file enqueues", fsnotify.Write, false, true, watchEnqueueFile},
{"create non-audio file ignored", fsnotify.Create, false, false, watchIgnore},
{"create directory scans it", fsnotify.Create, true, false, watchScanDir},
{"write on directory ignored", fsnotify.Write, true, false, watchIgnore},
{"remove audio file ignored (no pruning)", fsnotify.Remove, false, true, watchIgnore},
{"rename audio file ignored", fsnotify.Rename, false, true, watchIgnore},
{"chmod audio file ignored", fsnotify.Chmod, false, true, watchIgnore},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyEvent(tt.op, tt.isDir, tt.isAudio); got != tt.want {
t.Fatalf("classifyEvent(%v, dir=%v, audio=%v) = %v, want %v",
tt.op, tt.isDir, tt.isAudio, got, tt.want)
}
})
}
}
func TestDrainPending(t *testing.T) {
t.Run("empty set returns nil", func(t *testing.T) {
if got := drainPending(map[string]struct{}{}); got != nil {
t.Fatalf("drainPending(empty) = %v, want nil", got)
}
})
t.Run("returns paths and clears the set", func(t *testing.T) {
pending := map[string]struct{}{
"/music/a.mp3": {},
"/music/b.flac": {},
}
got := drainPending(pending)
sort.Strings(got)
want := []string{"/music/a.mp3", "/music/b.flac"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("drainPending = %v, want %v", got, want)
}
if len(pending) != 0 {
t.Fatalf("pending not cleared: %v", pending)
}
})
}
+4 -5
View File
@@ -78,9 +78,8 @@ type Server struct {
CoverSettings *coverart.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
Scheduler *library.Scheduler
// Bus is the live-event bus shared with background workers (the
// lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go.
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
// When nil, Router() constructs a local fallback (test contexts).
Bus *eventbus.Bus
// PlaylistScheduler fires per-user daily system-playlist builds at
@@ -97,8 +96,8 @@ type Server struct {
StreamSecret []byte
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
}
func (s *Server) Router() http.Handler {
@@ -144,7 +143,7 @@ func (s *Server) Router() http.Handler {
if bus == nil {
bus = eventbus.New()
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+6 -6
View File
@@ -24,7 +24,7 @@ import (
)
func TestHealthz(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) {
}
func TestRouter_ServesSPAAtRoot(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
}
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
}
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
}
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
}
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
+61
View File
@@ -0,0 +1,61 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits"
],
"baseBranches": ["dev"],
"timezone": "America/New_York",
"schedule": ["every weekend"],
"prHourlyLimit": 2,
"prConcurrentLimit": 8,
"ignorePaths": [
"**/node_modules/**",
"**/vendor/**",
"flutter_client/**"
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 5am on the first day of the month"]
},
"packageRules": [
{
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "Hold all major bumps for manual approval via the dependency dashboard",
"matchUpdateTypes": ["major"],
"automerge": false,
"dependencyDashboardApproval": true,
"addLabels": ["deps", "deps:major"]
},
{
"description": "Group Go module updates into one PR",
"matchManagers": ["gomod"],
"groupName": "go modules"
},
{
"description": "Group CI workflow action bumps",
"matchManagers": ["github-actions"],
"groupName": "ci actions"
},
{
"description": "Group Docker base-image bumps (Dockerfile + compose)",
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "docker images"
},
{
"description": "Group the Android Gradle/Kotlin toolchain",
"matchManagers": ["gradle", "gradle-wrapper"],
"groupName": "android gradle"
},
{
"description": "Group web npm non-major bumps",
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "web npm (non-major)"
}
]
}
@@ -1,58 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin scan-schedule API', () => {
beforeEach(() => vi.clearAllMocks());
it('getScanSchedule GETs the correct path', async () => {
const sample: ScanSchedule = {
mode: 'daily',
interval_hours: null,
time_of_day: '03:00',
weekly_day: null,
next_scheduled_at: '2026-05-07T03:00:00Z'
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getScanSchedule();
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
expect(got).toEqual(sample);
});
it('updateScanSchedule PATCHes with the right body', async () => {
const sample: ScanSchedule = {
mode: 'interval',
interval_hours: 6,
time_of_day: null,
weekly_day: null,
next_scheduled_at: '2026-05-06T20:00:00Z'
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
mode: 'interval',
interval_hours: 6
});
expect(got.mode).toBe('interval');
expect(got.interval_hours).toBe(6);
});
it('updateScanSchedule with mode=off sends just mode', async () => {
const sample: ScanSchedule = {
mode: 'off',
interval_hours: null,
time_of_day: null,
weekly_day: null,
next_scheduled_at: null
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await updateScanSchedule({ mode: 'off' });
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
expect(got.next_scheduled_at).toBe(null);
});
});
-37
View File
@@ -383,43 +383,6 @@ export function createCoverProvidersQuery() {
});
}
// Scan schedule -----------------------------------------------------------
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
export type ScanSchedule = {
mode: ScanScheduleMode;
interval_hours: number | null;
time_of_day: string | null; // 'HH:MM'
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
};
export type ScanScheduleUpdate = {
mode: ScanScheduleMode;
interval_hours?: number | null;
time_of_day?: string | null;
weekly_day?: number | null;
};
export async function getScanSchedule(): Promise<ScanSchedule> {
return api.get<ScanSchedule>('/api/admin/scan/schedule');
}
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
}
// Settings rarely change in operator time. 60s staleTime; refetches
// on window focus + after any PATCH invalidation — no polling.
export function createScanScheduleQuery() {
return createQuery({
queryKey: qk.scanSchedule(),
queryFn: getScanSchedule,
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
+18 -1
View File
@@ -46,7 +46,6 @@ export const qk = {
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const,
scanSchedule: () => ['scanSchedule'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
@@ -57,6 +56,8 @@ export const qk = {
home: () => ['home'] as const,
albumsAlpha: () => ['albumsAlpha'] as const,
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
similarArtists: (artistId: string) => ['similarArtists', artistId] as const,
artistTopTracks: (artistId: string) => ['artistTopTracks', artistId] as const,
playlists: (kind?: 'user' | 'system' | 'all') =>
['playlists', { kind: kind ?? 'user' }] as const,
playlist: (id: string) => ['playlist', id] as const,
@@ -89,6 +90,22 @@ export function createAlbumQuery(id: string) {
});
}
export function createSimilarArtistsQuery(id: string) {
return createQuery({
queryKey: qk.similarArtists(id),
queryFn: () => api.get<ArtistRef[]>(`/api/artists/${id}/similar`),
enabled: id.length > 0,
});
}
export function createArtistTopTracksQuery(id: string) {
return createQuery({
queryKey: qk.artistTopTracks(id),
queryFn: () => api.get<TrackRef[]>(`/api/artists/${id}/top-tracks`),
enabled: id.length > 0,
});
}
export function createSearchQuery(q: string) {
return createQuery({
queryKey: qk.search(q),
+1 -159
View File
@@ -10,8 +10,6 @@
createAdminQuarantineQuery,
createScanStatusQuery,
createCoverageQuery,
createScanScheduleQuery,
updateScanSchedule,
approveRequest,
rejectRequest,
resolveQuarantine,
@@ -19,9 +17,7 @@
deleteQuarantineViaLidarr,
refetchMissingCovers,
triggerScan,
researchMissingArt,
type ScanSchedule,
type ScanScheduleMode
researchMissingArt
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errCode } from '$lib/api/errors';
@@ -211,89 +207,6 @@
return new Date(iso).toLocaleString();
}
// ---- Scan schedule ----
const scheduleStore = $derived(createScanScheduleQuery());
const scheduleQ = $derived($scheduleStore);
const schedule = $derived(scheduleQ.data);
let scheduleLocal = $state<{
mode: ScanScheduleMode;
interval_hours: number;
time_of_day: string;
weekly_day: number;
}>({
mode: 'off',
interval_hours: 6,
time_of_day: '03:00',
weekly_day: 7,
});
let scheduleSaving = $state(false);
$effect(() => {
if (schedule) {
scheduleLocal = {
mode: schedule.mode,
interval_hours: schedule.interval_hours ?? 6,
time_of_day: schedule.time_of_day ?? '03:00',
weekly_day: schedule.weekly_day ?? 7,
};
}
});
function scheduleHasChanges(): boolean {
if (!schedule) return false;
if (scheduleLocal.mode !== schedule.mode) return true;
switch (scheduleLocal.mode) {
case 'interval':
return scheduleLocal.interval_hours !== (schedule.interval_hours ?? -1);
case 'daily':
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '');
case 'weekly':
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '')
|| scheduleLocal.weekly_day !== (schedule.weekly_day ?? -1);
default:
return false;
}
}
async function onSaveSchedule() {
scheduleSaving = true;
try {
const patch: { mode: ScanScheduleMode; interval_hours?: number; time_of_day?: string; weekly_day?: number } = {
mode: scheduleLocal.mode,
};
if (scheduleLocal.mode === 'interval') patch.interval_hours = scheduleLocal.interval_hours;
if (scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly') patch.time_of_day = scheduleLocal.time_of_day;
if (scheduleLocal.mode === 'weekly') patch.weekly_day = scheduleLocal.weekly_day;
await updateScanSchedule(patch);
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
} catch (e) {
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
} finally {
scheduleSaving = false;
}
}
function formatRelative(iso: string): string {
const target = new Date(iso);
const now = new Date();
const diffMs = target.getTime() - now.getTime();
const absHours = Math.abs(diffMs) / 3_600_000;
let rel: string;
if (absHours < 1) {
const mins = Math.round(Math.abs(diffMs) / 60_000);
rel = diffMs >= 0 ? `in ${mins} min` : `${mins} min ago`;
} else if (absHours < 48) {
const h = Math.round(absHours);
rel = diffMs >= 0 ? `in ${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ago`;
} else {
const days = Math.round(absHours / 24);
rel = diffMs >= 0 ? `in ${days} days` : `${days} days ago`;
}
const local = target.toLocaleString();
return `${rel} (${local})`;
}
// ---- Cover art bulk refetch ----
let bulkBusy = $state(false);
let bulkResult = $state<string | null>(null);
@@ -517,77 +430,6 @@
{/if}
</section>
<!-- Scan schedule -->
<section class="rounded border border-border bg-surface p-4 space-y-3">
<div>
<h2 class="font-display text-lg font-medium">Scan schedule</h2>
<p class="mt-1 text-sm text-text-secondary">
Run a library scan automatically on a recurring cadence. Manual triggers
and boot scans are unaffected.
</p>
</div>
{#if schedule}
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="schedule-mode">Mode</label>
<select id="schedule-mode" bind:value={scheduleLocal.mode}
class="text-sm rounded border border-border bg-background px-2 py-1">
<option value="off">Off</option>
<option value="interval">Every N hours</option>
<option value="daily">Daily at time</option>
<option value="weekly">Weekly at day + time</option>
</select>
{#if scheduleLocal.mode === 'interval'}
<label class="text-sm text-text-secondary" for="schedule-interval">Interval</label>
<div class="flex items-center gap-2">
<input id="schedule-interval" type="number" min="1" max="168"
bind:value={scheduleLocal.interval_hours}
class="w-20 text-sm rounded border border-border bg-background px-2 py-1" />
<span class="text-sm text-text-muted">hours</span>
</div>
{/if}
{#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'}
<label class="text-sm text-text-secondary" for="schedule-time">Time</label>
<input id="schedule-time" type="time" bind:value={scheduleLocal.time_of_day}
class="text-sm rounded border border-border bg-background px-2 py-1" />
{/if}
{#if scheduleLocal.mode === 'weekly'}
<label class="text-sm text-text-secondary" for="schedule-day">Day</label>
<select id="schedule-day" bind:value={scheduleLocal.weekly_day}
class="text-sm rounded border border-border bg-background px-2 py-1">
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
<option value={7}>Sunday</option>
</select>
{/if}
</div>
<div class="flex items-center gap-3">
<button type="button" disabled={!scheduleHasChanges() || scheduleSaving}
onclick={onSaveSchedule}
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50">
{scheduleSaving ? 'Saving…' : 'Save changes'}
</button>
{#if schedule.next_scheduled_at}
<span class="text-xs text-text-muted">
Next scan: {formatRelative(schedule.next_scheduled_at)}
</span>
{:else if scheduleLocal.mode === 'off'}
<span class="text-xs text-text-muted">Schedule is off.</span>
{/if}
</div>
{:else}
<p class="text-sm text-text-muted">Loading…</p>
{/if}
</section>
<!-- Cover art bulk refetch -->
<section class="rounded border border-border bg-surface p-4">
<h2 class="font-display text-lg font-medium">Cover art</h2>
+1 -23
View File
@@ -42,21 +42,7 @@ vi.mock('$lib/api/admin', async () => {
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
triggerScan: vi.fn().mockResolvedValue({}),
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
createScanScheduleQuery: vi.fn(() =>
readable({
data: {
mode: 'off',
interval_hours: null,
time_of_day: null,
weekly_day: null,
next_scheduled_at: null,
},
isPending: false,
isError: false
})
),
updateScanSchedule: vi.fn().mockResolvedValue({})
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 })
};
});
@@ -250,14 +236,6 @@ describe('admin Overview page', () => {
);
});
test('renders the scan-schedule section with mode=off default', () => {
setupOverview();
render(OverviewPage);
expect(screen.getByText('Scan schedule')).toBeInTheDocument();
const select = screen.getByLabelText('Mode') as HTMLSelectElement;
expect(select.value).toBe('off');
});
test('renders Re-search missing art button and invokes on click', async () => {
setupOverview();
render(OverviewPage);
+41 -2
View File
@@ -1,9 +1,16 @@
<script lang="ts">
import { Play } from 'lucide-svelte';
import { page } from '$app/state';
import { createArtistQuery } from '$lib/api/queries';
import {
createArtistQuery,
createSimilarArtistsQuery,
createArtistTopTracksQuery
} from '$lib/api/queries';
import { pageTitle } from '$lib/branding';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
@@ -11,13 +18,20 @@
import { api } from '$lib/api/client';
import { playQueue } from '$lib/player/store.svelte';
import type { ApiError } from '$lib/api/client';
import type { TrackRef } from '$lib/api/types';
import type { ArtistRef, TrackRef } from '$lib/api/types';
const id = $derived(page.params.id ?? '');
const queryStore = $derived(createArtistQuery(id));
const query = $derived($queryStore);
const showSkeleton = useDelayed(() => query.isPending);
// Secondary sections, lazy-loaded alongside the detail. Each hides itself
// when empty (no similarity matches yet / user hasn't played this artist).
const similarStore = $derived(createSimilarArtistsQuery(id));
const similarQuery = $derived($similarStore);
const topTracksStore = $derived(createArtistTopTracksQuery(id));
const topTracksQuery = $derived($topTracksStore);
const notFound = $derived(
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
);
@@ -97,5 +111,30 @@
{/each}
</div>
{/if}
{#if (topTracksQuery.data?.length ?? 0) > 0}
{@const top = topTracksQuery.data ?? []}
<section class="space-y-3">
<HorizontalScrollRow rows={[top]} title="Top tracks" ariaLabel={`Top tracks for ${detail.name}`}>
{#snippet item(track: TrackRef, globalIdx: number)}
<CompactTrackCard {track} sectionTracks={top} index={globalIdx} />
{/snippet}
</HorizontalScrollRow>
</section>
{/if}
{#if (similarQuery.data?.length ?? 0) > 0}
<section class="space-y-3">
<HorizontalScrollRow
rows={[similarQuery.data ?? []]}
title="Similar artists"
ariaLabel="Similar artists"
>
{#snippet item(artist: ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
</section>
{/if}
{/if}
</div>
+38 -3
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { flushSync } from 'svelte';
import { mockQuery } from '../../../test-utils/query';
@@ -16,13 +16,19 @@ vi.mock('$app/state', () => ({
vi.mock('$lib/api/queries', () => ({
qk: { artist: (id: string) => ['artist', id] },
createArtistQuery: vi.fn()
createArtistQuery: vi.fn(),
createSimilarArtistsQuery: vi.fn(),
createArtistTopTracksQuery: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
import ArtistPage from './+page.svelte';
import { createArtistQuery } from '$lib/api/queries';
import {
createArtistQuery,
createSimilarArtistsQuery,
createArtistTopTracksQuery
} from '$lib/api/queries';
function album(id: string, title: string, year?: number): AlbumRef {
return {
@@ -40,6 +46,13 @@ afterEach(() => {
state.pageParams = { id: 'abc' };
});
beforeEach(() => {
// Secondary sections default to empty so the existing album-grid assertions
// are unaffected; individual tests can override to exercise the new strips.
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
});
describe('artist detail page', () => {
test('renders artist name, subtitle, and one AlbumCard per album', () => {
const detail: ArtistDetail = {
@@ -54,6 +67,28 @@ describe('artist detail page', () => {
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
});
test('renders top-tracks panel and similar-artists strip when present', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)]
};
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
data: [{
id: 't1', title: 'Hit Song', album_id: 'a1', album_title: 'First',
artist_id: 'abc', artist_name: 'Alice', duration_sec: 200, stream_url: '/s/t1'
}]
}));
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
data: [{ id: 'sim1', name: 'Bob', sort_name: 'Bob', album_count: 2, cover_url: '' }]
}));
render(ArtistPage);
expect(screen.getByText(/top tracks/i)).toBeInTheDocument();
expect(screen.getByText('Hit Song')).toBeInTheDocument();
expect(screen.getByText(/similar artists/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/sim1');
});
test('back link points to Library', () => {
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] }
+6
View File
@@ -2,6 +2,8 @@
import { pageTitle } from '$lib/branding';
import { createLidarrSearchQuery } from '$lib/api/lidarr';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { useQueryClient } from '@tanstack/svelte-query';
import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
@@ -12,6 +14,8 @@
LidarrSearchResult
} from '$lib/api/types';
const queryClient = useQueryClient();
// Local input is the source of truth for the discover query — Shell's global
// SearchInput targets /search, not /discover. We debounce by 250ms before
// pushing into `debouncedQ`, which is what the query factory observes.
@@ -87,6 +91,8 @@
const next = new Set(optimisticRequested);
next.add(r.mbid);
optimisticRequested = next;
// Surface the new request on /requests right away (+ start its poll) (#369).
queryClient.invalidateQueries({ queryKey: qk.myRequests() });
} catch {
// Swallow for v1: error toasts land in a later UX pass. The card stays
// in 'requestable' so the user can retry.
+30
View File
@@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({
vi.mock('$lib/api/client', () => apiClientMock());
// The page calls useQueryClient() to invalidate qk.myRequests() after a
// submit (#369); stub it so rendering doesn't require a real QueryClient and
// we can assert the invalidation fires.
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }));
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/svelte-query')>();
return { ...actual, useQueryClient: () => queryClientMock };
});
import DiscoverPage from './+page.svelte';
import { createLidarrSearchQuery } from '$lib/api/lidarr';
import { createSuggestionsQuery } from '$lib/api/suggestions';
@@ -187,6 +196,27 @@ describe('Discover page', () => {
});
});
test('submitting a request invalidates myRequests so /requests updates (#369)', async () => {
vi.useFakeTimers();
const r = result({ mbid: 'art-mbid', artist_mbid: 'art-mbid', name: 'Boards of Canada' });
mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] }));
render(DiscoverPage);
const input = screen.getByLabelText(/search lidarr/i);
await fireEvent.input(input, { target: { value: 'boards' } });
await vi.advanceTimersByTimeAsync(250);
vi.useRealTimers();
const requestBtn = await screen.findByRole('button', {
name: /request boards of canada/i
});
await fireEvent.click(requestBtn);
await waitFor(() => {
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['myRequests']
});
});
});
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
vi.useFakeTimers();
const r = result({