diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt index e870150a..557f5443 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt @@ -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 + @GET("api/artists/{id}/similar") + suspend fun getSimilarArtists( + @Path("id") id: String, + @Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT, + ): List + + @GET("api/artists/{id}/top-tracks") + suspend fun getArtistTopTracks( + @Path("id") id: String, + @Query("limit") limit: Int = TOP_TRACKS_LIMIT, + ): List + @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 + + private companion object { + const val SIMILAR_ARTISTS_LIMIT = 12 + const val TOP_TRACKS_LIMIT = 5 + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt index de6f2c48..5dc4bc69 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor( suspend fun fetchArtistTracks(id: String): List = 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 = + 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 = + api.getArtistTopTracks(id).map { it.toDomain() } + /** * Pulls the album detail (AlbumRef + tracks), persists both, and * returns the in-memory snapshot. Lets the AlbumDetail screen diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 335cbf4f..e048eec0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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, + 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, + 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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 9355c670..8bce5b37 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -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 = emptyList(), + val topTracks: List = 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 sectionOrEmpty(block: () -> List): List = + 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", + ) + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index d448c48b..14737b7e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -172,6 +172,7 @@ class MinstrelForwardingPlayer( if (active == null) { super.play() } else { + remoteState.setPlayIntent(true) scope.launch { runCatching { active.avTransport.play() } .onSuccess { @@ -193,6 +194,7 @@ class MinstrelForwardingPlayer( if (active == null) { super.pause() } else { + remoteState.setPlayIntent(false) scope.launch { runCatching { active.avTransport.pause() } .onSuccess { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt index 0ffde318..bbaab6db 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemotePlayerState.kt @@ -27,6 +27,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 +70,8 @@ class RemotePlayerState @Inject constructor() { positionMs = 0L } + fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended } + fun applyError(t: Throwable) { isPlaying = false lastError = t @@ -80,6 +89,7 @@ class RemotePlayerState @Inject constructor() { positionMs = 0L durationMs = 0L isPlaying = false + lastPlayIntent = false currentTrackUri = "" lastError = null consecutivePollFailures = 0 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/IdleRevertAction.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/IdleRevertAction.kt new file mode 100644 index 00000000..07d5f341 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/IdleRevertAction.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt index 9d0dddec..bef9838b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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? = 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: @@ -163,6 +170,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) } /** @@ -374,7 +429,10 @@ class OutputPickerController @Inject constructor( */ 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 +555,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 +701,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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt index 1989f904..cbf22ddc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/UpnpDiscoveryController.kt @@ -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>(emptyList()) val routes: StateFlow> = 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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 7759a78e..9f2ee4b9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -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 diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt index bb0c0224..b597187a 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemotePlayerStateTest.kt @@ -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 } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/output/IdleRevertDecisionTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/output/IdleRevertDecisionTest.kt new file mode 100644 index 00000000..8aa91a05 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/output/IdleRevertDecisionTest.kt @@ -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), + ) + } +} diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 285f00ff..4657f232 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -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 diff --git a/go.mod b/go.mod index 873b5d37..28a4ff66 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 3ebffbc5..cae66c76 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/admin_scan_schedule.go b/internal/api/admin_scan_schedule.go deleted file mode 100644 index 539b6d60..00000000 --- a/internal/api/admin_scan_schedule.go +++ /dev/null @@ -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 -} diff --git a/internal/api/admin_scan_schedule_test.go b/internal/api/admin_scan_schedule_test.go deleted file mode 100644 index bb648d1e..00000000 --- a/internal/api/admin_scan_schedule_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/api/api.go b/internal/api/api.go index e95a26a1..ff425bb9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/library.go b/internal/api/library.go index 1bfc7523..cc919a81 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -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 diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 5ac1b450..09d36cf7 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.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", diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 1856b21a..3556ce8e 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index e78ea599..f508a031 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -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 diff --git a/internal/db/dbq/scan_schedule.sql.go b/internal/db/dbq/scan_schedule.sql.go deleted file mode 100644 index e46f9c3f..00000000 --- a/internal/db/dbq/scan_schedule.sql.go +++ /dev/null @@ -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 -} diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 5fff165f..153bcb26 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -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()) diff --git a/internal/db/migrations/0033_drop_scan_schedule.down.sql b/internal/db/migrations/0033_drop_scan_schedule.down.sql new file mode 100644 index 00000000..59b72012 --- /dev/null +++ b/internal/db/migrations/0033_drop_scan_schedule.down.sql @@ -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); diff --git a/internal/db/migrations/0033_drop_scan_schedule.up.sql b/internal/db/migrations/0033_drop_scan_schedule.up.sql new file mode 100644 index 00000000..ffaecb1e --- /dev/null +++ b/internal/db/migrations/0033_drop_scan_schedule.up.sql @@ -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; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 68299d48..7611daac 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -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 diff --git a/internal/db/queries/scan_schedule.sql b/internal/db/queries/scan_schedule.sql deleted file mode 100644 index c6521233..00000000 --- a/internal/db/queries/scan_schedule.sql +++ /dev/null @@ -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; diff --git a/internal/db/queries/similarity.sql b/internal/db/queries/similarity.sql index c9688c00..94989f94 100644 --- a/internal/db/queries/similarity.sql +++ b/internal/db/queries/similarity.sql @@ -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); diff --git a/internal/library/scanner.go b/internal/library/scanner.go index c6b7c6e7..49689651 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -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) { diff --git a/internal/library/scanner_test.go b/internal/library/scanner_test.go index 80f8df54..f894fa34 100644 --- a/internal/library/scanner_test.go +++ b/internal/library/scanner_test.go @@ -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 diff --git a/internal/library/scheduler.go b/internal/library/scheduler.go deleted file mode 100644 index 378543c7..00000000 --- a/internal/library/scheduler.go +++ /dev/null @@ -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) - } -} diff --git a/internal/library/scheduler_test.go b/internal/library/scheduler_test.go deleted file mode 100644 index 0e76d68a..00000000 --- a/internal/library/scheduler_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/library/watcher.go b/internal/library/watcher.go new file mode 100644 index 00000000..94a66900 --- /dev/null +++ b/internal/library/watcher.go @@ -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 diff --git a/internal/library/watcher_test.go b/internal/library/watcher_test.go new file mode 100644 index 00000000..0d53a74e --- /dev/null +++ b/internal/library/watcher_test.go @@ -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) + } + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index b402d2af..248340c1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 12545d21..22e352a3 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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() diff --git a/web/src/lib/api/admin.scan-schedule.test.ts b/web/src/lib/api/admin.scan-schedule.test.ts deleted file mode 100644 index 86001b5c..00000000 --- a/web/src/lib/api/admin.scan-schedule.test.ts +++ /dev/null @@ -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).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).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).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); - }); -}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 96e621dc..9533a9e9 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 { - return api.get('/api/admin/scan/schedule'); -} - -export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise { - return api.patch('/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 = { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 37dafb5c..71f444f4 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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(`/api/artists/${id}/similar`), + enabled: id.length > 0, + }); +} + +export function createArtistTopTracksQuery(id: string) { + return createQuery({ + queryKey: qk.artistTopTracks(id), + queryFn: () => api.get(`/api/artists/${id}/top-tracks`), + enabled: id.length > 0, + }); +} + export function createSearchQuery(q: string) { return createQuery({ queryKey: qk.search(q), diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 54718b28..0ca34f8d 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -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(null); @@ -517,77 +430,6 @@ {/if} - -
-
-

Scan schedule

-

- Run a library scan automatically on a recurring cadence. Manual triggers - and boot scans are unaffected. -

-
- - {#if schedule} -
- - - - {#if scheduleLocal.mode === 'interval'} - -
- - hours -
- {/if} - - {#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'} - - - {/if} - - {#if scheduleLocal.mode === 'weekly'} - - - {/if} -
- -
- - {#if schedule.next_scheduled_at} - - Next scan: {formatRelative(schedule.next_scheduled_at)} - - {:else if scheduleLocal.mode === 'off'} - Schedule is off. - {/if} -
- {:else} -

Loading…

- {/if} -
-

Cover art

diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index a0272596..35f4dfdf 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -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); diff --git a/web/src/routes/artists/[id]/+page.svelte b/web/src/routes/artists/[id]/+page.svelte index c79fe59e..3f829273 100644 --- a/web/src/routes/artists/[id]/+page.svelte +++ b/web/src/routes/artists/[id]/+page.svelte @@ -1,9 +1,16 @@