v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
release / Build + push container image (push) Successful in 14s
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
release / Build + push container image (push) Successful in 14s
This commit was merged in pull request #87.
This commit is contained in:
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
|||||||
@GET("api/artists/{id}/tracks")
|
@GET("api/artists/{id}/tracks")
|
||||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||||
|
|
||||||
|
@GET("api/artists/{id}/similar")
|
||||||
|
suspend fun getSimilarArtists(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||||
|
): List<ArtistWire>
|
||||||
|
|
||||||
|
@GET("api/artists/{id}/top-tracks")
|
||||||
|
suspend fun getArtistTopTracks(
|
||||||
|
@Path("id") id: String,
|
||||||
|
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||||
|
): List<TrackWire>
|
||||||
|
|
||||||
@GET("api/albums/{id}")
|
@GET("api/albums/{id}")
|
||||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||||
|
|
||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||||
|
const val TOP_TRACKS_LIMIT = 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
|||||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||||
api.getArtistTracks(id).map { it.toDomain() }
|
api.getArtistTracks(id).map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||||
|
* strip. Network-only — non-critical UI, callers swallow failures
|
||||||
|
* and render nothing rather than blocking the screen.
|
||||||
|
*/
|
||||||
|
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||||
|
api.getSimilarArtists(id).map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||||
|
* panel. Network-only; tapping a row plays this exact list as a
|
||||||
|
* queue, so no persistence is needed.
|
||||||
|
*/
|
||||||
|
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||||
|
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||||
|
|||||||
+116
-13
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||||
|
|
||||||
package com.fabledsword.minstrel.library.ui
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
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.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items as lazyItems
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.Play
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
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.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.models.albumCoverPath
|
import com.fabledsword.minstrel.models.albumCoverPath
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
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.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||||
|
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
|
|||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
body = s.message,
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success -> {
|
is ArtistDetailUiState.Success ->
|
||||||
val artistLiked by viewModel.artistLiked
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
ArtistBody(
|
|
||||||
detail = s.detail,
|
|
||||||
artistLiked = artistLiked,
|
|
||||||
onPlay = viewModel::playArtist,
|
|
||||||
onToggleLike = viewModel::toggleLikeArtist,
|
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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) {
|
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||||
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistBody(
|
private fun ArtistBody(
|
||||||
detail: ArtistDetailRef,
|
state: ArtistDetailUiState.Success,
|
||||||
artistLiked: Boolean,
|
artistLiked: Boolean,
|
||||||
|
playingTrackId: String?,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
|
onTopTrackClick: (Int) -> Unit,
|
||||||
|
onSimilarArtistClick: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val detail = state.detail
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -149,6 +174,23 @@ private fun ArtistBody(
|
|||||||
onToggleLike = onToggleLike,
|
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()) {
|
if (detail.albums.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
EmptyAlbumsHint()
|
EmptyAlbumsHint()
|
||||||
@@ -161,6 +203,67 @@ private fun ArtistBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopTracksPanel(
|
||||||
|
tracks: List<TrackRef>,
|
||||||
|
playingTrackId: String?,
|
||||||
|
onTrackClick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Top tracks",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
tracks.forEachIndexed { index, track ->
|
||||||
|
TopTrackRow(
|
||||||
|
track = track,
|
||||||
|
nowPlaying = track.id == playingTrackId,
|
||||||
|
onClick = { onTrackClick(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopTrackRow(
|
||||||
|
track: TrackRef,
|
||||||
|
nowPlaying: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
TrackRow(
|
||||||
|
title = track.title,
|
||||||
|
artist = track.artistName,
|
||||||
|
trackId = track.id,
|
||||||
|
onClick = onClick,
|
||||||
|
nowPlaying = nowPlaying,
|
||||||
|
trailing = {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SimilarArtistsStrip(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
HorizontalScrollRow(title = "Similar artists") {
|
||||||
|
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||||
|
ArtistCard(
|
||||||
|
artist = artist,
|
||||||
|
onClick = { onArtistClick(artist.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistHeader(
|
private fun ArtistHeader(
|
||||||
artist: ArtistRef,
|
artist: ArtistRef,
|
||||||
|
|||||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
|||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
sealed interface ArtistDetailUiState {
|
sealed interface ArtistDetailUiState {
|
||||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
data class Success(
|
||||||
|
val detail: ArtistDetailRef,
|
||||||
|
val similarArtists: List<ArtistRef> = emptyList(),
|
||||||
|
val topTracks: List<TrackRef> = emptyList(),
|
||||||
|
) : ArtistDetailUiState
|
||||||
data class Error(val message: String) : ArtistDetailUiState
|
data class Error(val message: String) : ArtistDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = repository.refreshArtistDetail(artistId)
|
val detail = repository.refreshArtistDetail(artistId)
|
||||||
internal.value = ArtistDetailUiState.Success(detail)
|
internal.value = ArtistDetailUiState.Success(detail)
|
||||||
|
loadSecondarySections()
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||||
|
* and folds them into the live Success state. Each failure is
|
||||||
|
* swallowed (logged only) — these sections render absent rather than
|
||||||
|
* blocking, so the core artist view never depends on them.
|
||||||
|
*/
|
||||||
|
private suspend fun loadSecondarySections() {
|
||||||
|
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||||
|
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||||
|
val current = internal.value
|
||||||
|
if (current is ArtistDetailUiState.Success) {
|
||||||
|
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull every track across the artist's albums, shuffle, and start
|
* Pull every track across the artist's albums, shuffle, and start
|
||||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
* 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.play()
|
super.play()
|
||||||
} else {
|
} else {
|
||||||
|
remoteState.setPlayIntent(true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.play() }
|
runCatching { active.avTransport.play() }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
@@ -193,6 +194,7 @@ class MinstrelForwardingPlayer(
|
|||||||
if (active == null) {
|
if (active == null) {
|
||||||
super.pause()
|
super.pause()
|
||||||
} else {
|
} else {
|
||||||
|
remoteState.setPlayIntent(false)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.pause() }
|
runCatching { active.avTransport.pause() }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
@Volatile var lastError: Throwable? = null; private set
|
@Volatile var lastError: Throwable? = null; private set
|
||||||
@Volatile var trackNumber: Int = 0; 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
|
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||||
@@ -63,6 +70,8 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||||
|
|
||||||
fun applyError(t: Throwable) {
|
fun applyError(t: Throwable) {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
lastError = t
|
lastError = t
|
||||||
@@ -80,6 +89,7 @@ class RemotePlayerState @Inject constructor() {
|
|||||||
positionMs = 0L
|
positionMs = 0L
|
||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
lastPlayIntent = false
|
||||||
currentTrackUri = ""
|
currentTrackUri = ""
|
||||||
lastError = null
|
lastError = null
|
||||||
consecutivePollFailures = 0
|
consecutivePollFailures = 0
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
/** Action the idle-revert collector should take on a player-state change. */
|
||||||
|
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||||
|
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||||
|
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||||
|
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||||
|
* otherwise IGNORE.
|
||||||
|
*/
|
||||||
|
internal fun idleRevertAction(
|
||||||
|
upnpEngaged: Boolean,
|
||||||
|
isPlaying: Boolean,
|
||||||
|
armed: Boolean,
|
||||||
|
): IdleRevertAction = when {
|
||||||
|
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||||
|
armed -> IdleRevertAction.CANCEL
|
||||||
|
else -> IdleRevertAction.IGNORE
|
||||||
|
}
|
||||||
+67
-1
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -147,6 +148,12 @@ class OutputPickerController @Inject constructor(
|
|||||||
// can run the longest-common-prefix / common-suffix diff.
|
// can run the longest-common-prefix / common-suffix diff.
|
||||||
private var lastSyncedQueueIds: List<String>? = null
|
private var lastSyncedQueueIds: List<String>? = null
|
||||||
|
|
||||||
|
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||||
|
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||||
|
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||||
|
// See idleRevertAction for the arm/cancel decision.
|
||||||
|
private var idleRevertJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Two-arg addCallback registers with no discovery flag —
|
// Two-arg addCallback registers with no discovery flag —
|
||||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||||
@@ -163,6 +170,54 @@ class OutputPickerController @Inject constructor(
|
|||||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
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() {
|
private fun handleRemoteDrop() {
|
||||||
val capturedPositionMs = remoteState.positionMs
|
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.set(null)
|
||||||
activeUpnpHolder.setTarget(null)
|
activeUpnpHolder.setTarget(null)
|
||||||
selectedUpnpRouteIdInternal.value = null
|
selectedUpnpRouteIdInternal.value = null
|
||||||
@@ -497,6 +555,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
rendering = rendering,
|
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 ->
|
}.onFailure { e ->
|
||||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||||
activeUpnpHolder.set(null)
|
activeUpnpHolder.set(null)
|
||||||
@@ -640,5 +701,10 @@ class OutputPickerController @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -17,6 +17,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -45,6 +46,15 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
private val ssdp = SsdpDiscovery(context)
|
private val ssdp = SsdpDiscovery(context)
|
||||||
|
|
||||||
|
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||||
|
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||||
|
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||||
|
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||||
|
// dispatcher stay shared with the app client.
|
||||||
|
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||||
|
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -91,7 +101,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
fun transportFor(routeId: String): AVTransportClient? {
|
fun transportFor(routeId: String): AVTransportClient? {
|
||||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||||
return AVTransportClient(
|
return AVTransportClient(
|
||||||
loggingSoapClient(okHttp, route.name),
|
loggingSoapClient(controlHttp, route.name),
|
||||||
route.avTransportControlUrl,
|
route.avTransportControlUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -225,6 +235,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// devices append. Anchored to end-of-string so it never eats
|
// devices append. Anchored to end-of-string so it never eats
|
||||||
// a legitimate parenthetical inside a name.
|
// a legitimate parenthetical inside a name.
|
||||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
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.CancelOutcome
|
||||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
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
|
@HiltViewModel
|
||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// SSE push: reconciler completions arrive here; reload silently so the
|
||||||
|
// row updates in place rather than flashing the loading spinner.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
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
|
* Optimistically removes [id] from the success list so the row
|
||||||
* disappears immediately; on server-side success the row is
|
* disappears immediately; on server-side success the row is
|
||||||
|
|||||||
@@ -69,6 +69,32 @@ class RemotePlayerStateTest {
|
|||||||
assertFalse(state.recordPollFailure())
|
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 {
|
private companion object {
|
||||||
const val DROP_THRESHOLD = 30
|
const val DROP_THRESHOLD = 30
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class IdleRevertDecisionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `engaged and paused and not armed arms the timer`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.ARM,
|
||||||
|
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `engaged and paused and already armed is ignored`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.IGNORE,
|
||||||
|
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resuming playback cancels an armed timer`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.CANCEL,
|
||||||
|
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playing with no timer armed is ignored`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.IGNORE,
|
||||||
|
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disengaging UPnP cancels an armed timer`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.CANCEL,
|
||||||
|
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disengaged with no timer armed is ignored`() {
|
||||||
|
assertEquals(
|
||||||
|
IdleRevertAction.IGNORE,
|
||||||
|
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-4
@@ -5,12 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"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 {
|
func run() error {
|
||||||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -237,12 +269,24 @@ func run() error {
|
|||||||
ArtistEnrichCap: -1,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
}
|
}
|
||||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||||
scanner, coverEnricher, scanCfg)
|
// fsnotify, scanning just the affected paths + enriching their albums
|
||||||
scheduler.Start(ctx)
|
// 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{
|
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
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.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
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-chi/chi/v5 v5.2.5
|
||||||
github.com/go-co-op/gocron/v2 v2.21.2
|
github.com/go-co-op/gocron/v2 v2.21.2
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
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/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
golang.org/x/sync v0.20.0 // 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
|
golang.org/x/text v0.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
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=
|
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newAdminScheduleRouter(h *handlers) chi.Router {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Route("/api/admin", func(admin chi.Router) {
|
|
||||||
admin.Use(auth.RequireAdmin())
|
|
||||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
|
||||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
|
||||||
})
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
|
||||||
h.scheduler = s
|
|
||||||
admin := seedUser(t, pool, "schedget", "pw", true)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
|
||||||
req = withUser(req, admin)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var resp scanScheduleResp
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Mode != "off" {
|
|
||||||
t.Errorf("Mode = %q, want off", resp.Mode)
|
|
||||||
}
|
|
||||||
if resp.NextScheduledAt != nil {
|
|
||||||
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
|
||||||
h.scheduler = s
|
|
||||||
admin := seedUser(t, pool, "schedpatch", "pw", true)
|
|
||||||
|
|
||||||
body := `{"mode":"daily","time_of_day":"03:00"}`
|
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
|
||||||
bytes.NewReader([]byte(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req = withUser(req, admin)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var resp scanScheduleResp
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Mode != "daily" {
|
|
||||||
t.Errorf("Mode = %q, want daily", resp.Mode)
|
|
||||||
}
|
|
||||||
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
|
|
||||||
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
|
|
||||||
}
|
|
||||||
if resp.NextScheduledAt == nil {
|
|
||||||
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
|
|
||||||
} else {
|
|
||||||
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("NextScheduledAt parse: %v", err)
|
|
||||||
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
|
|
||||||
// Handler computes next-fire from time.Now() in the host zone, so
|
|
||||||
// the local-time hour is what's stable across CI runners regardless
|
|
||||||
// of TZ; the wire timestamp is UTC but parses back to the same
|
|
||||||
// instant either way.
|
|
||||||
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
|
||||||
h.scheduler = s
|
|
||||||
admin := seedUser(t, pool, "schedweekly", "pw", true)
|
|
||||||
|
|
||||||
body := `{"mode":"weekly","time_of_day":"03:00"}`
|
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
|
||||||
bytes.NewReader([]byte(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req = withUser(req, admin)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
|
||||||
h.scheduler = s
|
|
||||||
admin := seedUser(t, pool, "schedoff", "pw", true)
|
|
||||||
|
|
||||||
if _, err := pool.Exec(context.Background(),
|
|
||||||
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := `{"mode":"off","time_of_day":"03:00"}`
|
|
||||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
|
||||||
bytes.NewReader([]byte(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req = withUser(req, admin)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeOfDay *string
|
|
||||||
if err := pool.QueryRow(context.Background(),
|
|
||||||
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
|
|
||||||
).Scan(&timeOfDay); err != nil {
|
|
||||||
t.Fatalf("verify: %v", err)
|
|
||||||
}
|
|
||||||
if timeOfDay != nil {
|
|
||||||
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
|
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
h, pool := testHandlers(t)
|
|
||||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
|
||||||
h.scheduler = s
|
|
||||||
user := seedUser(t, pool, "schednonadmin", "pw", false)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
|
||||||
req = withUser(req, user)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-5
@@ -28,7 +28,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
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,
|
coverSettings: coverSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
scheduler: scheduler,
|
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
mailer: sender,
|
mailer: sender,
|
||||||
eventbus: bus,
|
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", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
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}", h.handleGetAlbum)
|
||||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
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.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
|
||||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
|
||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
|
|
||||||
@@ -222,7 +221,6 @@ type handlers struct {
|
|||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
scheduler *library.Scheduler
|
|
||||||
dataDir string
|
dataDir string
|
||||||
mailer mailer.Sender
|
mailer mailer.Sender
|
||||||
eventbus *eventbus.Bus
|
eventbus *eventbus.Bus
|
||||||
|
|||||||
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, out)
|
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 —
|
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||||
// the online source for the client's always-present "Shuffle all"
|
// the online source for the client's always-present "Shuffle all"
|
||||||
// (#427 S4). N random tracks across the whole library, per-user
|
// (#427 S4). N random tracks across the whole library, per-user
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -439,15 +439,6 @@ type ScanRun struct {
|
|||||||
ArtistArtEnrich []byte
|
ArtistArtEnrich []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScanSchedule struct {
|
|
||||||
ID bool
|
|
||||||
Mode string
|
|
||||||
IntervalHours *int32
|
|
||||||
TimeOfDay *string
|
|
||||||
WeeklyDay *int32
|
|
||||||
UpdatedAt pgtype.Timestamptz
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScrobbleQueue struct {
|
type ScrobbleQueue struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
|
|||||||
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
|||||||
return items, nil
|
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
|
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||||
WITH plays AS (
|
WITH plays AS (
|
||||||
SELECT track_id, count(*) AS cnt
|
SELECT track_id, count(*) AS cnt
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
|
|||||||
return items, nil
|
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
|
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
|
||||||
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||||
VALUES ($1, $2, $3, 'listenbrainz', now())
|
VALUES ($1, $2, $3, 'listenbrainz', now())
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
|
||||||
|
-- reversible. The application no longer reads or writes this table.
|
||||||
|
CREATE TABLE scan_schedule (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
mode text NOT NULL DEFAULT 'off',
|
||||||
|
interval_hours int,
|
||||||
|
time_of_day text,
|
||||||
|
weekly_day int,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT scan_schedule_singleton CHECK (id = true),
|
||||||
|
CONSTRAINT scan_schedule_mode_check
|
||||||
|
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
|
||||||
|
CONSTRAINT scan_schedule_interval_check
|
||||||
|
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
|
||||||
|
CONSTRAINT scan_schedule_daily_check
|
||||||
|
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
|
||||||
|
CONSTRAINT scan_schedule_weekly_check
|
||||||
|
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO scan_schedule (id) VALUES (true);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Retire the configurable scan scheduler. New-file pickup is now handled by
|
||||||
|
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
|
||||||
|
-- so the operator-configurable schedule (and its admin UI) is gone.
|
||||||
|
DROP TABLE IF EXISTS scan_schedule;
|
||||||
@@ -231,6 +231,32 @@ WHERE NOT EXISTS (
|
|||||||
ORDER BY p.cnt DESC, t.id
|
ORDER BY p.cnt DESC, t.id
|
||||||
LIMIT $2;
|
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
|
-- name: ListLastPlayedArtistsForUser :many
|
||||||
-- M6a: artists ranked by max(play_events.started_at) for the user, with
|
-- 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
|
-- a derived cover_album_id via a representative-album lateral join (most
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -60,3 +60,29 @@ ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
|
|||||||
candidate_name = EXCLUDED.candidate_name,
|
candidate_name = EXCLUDED.candidate_name,
|
||||||
score = EXCLUDED.score,
|
score = EXCLUDED.score,
|
||||||
fetched_at = now();
|
fetched_at = now();
|
||||||
|
|
||||||
|
-- name: ListSimilarArtistsForArtist :many
|
||||||
|
-- In-library artists similar to a seed artist, ranked by best similarity
|
||||||
|
-- score across sources (deduped per candidate). cover_album_id + album_count
|
||||||
|
-- mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||||
|
SELECT sqlc.embed(artists),
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_b_id, max(score) AS sim_score
|
||||||
|
FROM artist_similarity
|
||||||
|
WHERE artist_a_id = sqlc.arg(seed_artist_id)
|
||||||
|
GROUP BY artist_b_id
|
||||||
|
) s
|
||||||
|
JOIN artists ON artists.id = s.artist_b_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id FROM albums
|
||||||
|
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) cov ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*) AS album_count
|
||||||
|
FROM albums WHERE artist_id = artists.id
|
||||||
|
) cnt ON true
|
||||||
|
ORDER BY s.sim_score DESC, artists.sort_name
|
||||||
|
LIMIT sqlc.arg(result_limit);
|
||||||
|
|||||||
+56
-11
@@ -87,7 +87,7 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||||
return nil
|
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)
|
s.logger.Warn("library scan file error", "path", path, "err", err)
|
||||||
stats.Errored++
|
stats.Errored++
|
||||||
}
|
}
|
||||||
@@ -114,19 +114,24 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
|||||||
return stats, nil
|
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++
|
stats.Scanned++
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stat: %w", err)
|
return pgtype.UUID{}, false, fmt.Errorf("stat: %w", err)
|
||||||
}
|
}
|
||||||
mtime := info.ModTime()
|
mtime := info.ModTime()
|
||||||
|
|
||||||
existing, err := q.GetTrackByPath(ctx, path)
|
existing, err := q.GetTrackByPath(ctx, path)
|
||||||
knownTrack := err == nil
|
knownTrack := err == nil
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
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
|
// Incremental skip: only when the file hasn't changed AND we already have
|
||||||
// a real duration. The second clause lets older scans that recorded
|
// 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.
|
// scans short-circuit as before.
|
||||||
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
|
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
|
||||||
stats.Skipped++
|
stats.Skipped++
|
||||||
return nil
|
return pgtype.UUID{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open: %w", err)
|
return pgtype.UUID{}, false, fmt.Errorf("open: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
meta, err := tag.ReadFrom(f)
|
meta, err := tag.ReadFrom(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("tag read: %w", err)
|
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
|
||||||
}
|
}
|
||||||
albumMBID, artistMBID := extractMBIDs(meta)
|
albumMBID, artistMBID := extractMBIDs(meta)
|
||||||
recordingMBID := extractRecordingMBID(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)
|
artist, err := s.resolveArtist(ctx, q, artistName, artistMBID)
|
||||||
if err != nil {
|
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)
|
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("album: %w", err)
|
return pgtype.UUID{}, false, fmt.Errorf("album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
trackNum, _ := meta.Track()
|
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)
|
track, err := q.UpsertTrack(ctx, params)
|
||||||
if err != nil {
|
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,
|
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack,
|
||||||
syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil {
|
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 {
|
} else {
|
||||||
stats.Added++
|
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) {
|
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) {
|
||||||
|
|||||||
@@ -121,6 +121,45 @@ func TestScanner_Integration(t *testing.T) {
|
|||||||
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
||||||
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
|
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
|
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -78,9 +78,8 @@ type Server struct {
|
|||||||
CoverSettings *coverart.SettingsService
|
CoverSettings *coverart.SettingsService
|
||||||
LibraryScanner *library.Scanner
|
LibraryScanner *library.Scanner
|
||||||
ScanCfg library.RunScanConfig
|
ScanCfg library.RunScanConfig
|
||||||
Scheduler *library.Scheduler
|
|
||||||
// Bus is the live-event bus shared with background workers (the
|
// 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).
|
// When nil, Router() constructs a local fallback (test contexts).
|
||||||
Bus *eventbus.Bus
|
Bus *eventbus.Bus
|
||||||
// PlaylistScheduler fires per-user daily system-playlist builds at
|
// PlaylistScheduler fires per-user daily system-playlist builds at
|
||||||
@@ -97,8 +96,8 @@ type Server struct {
|
|||||||
StreamSecret []byte
|
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 {
|
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, Scheduler: scheduler}
|
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 {
|
func (s *Server) Router() http.Handler {
|
||||||
@@ -144,7 +143,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
if bus == nil {
|
if bus == nil {
|
||||||
bus = eventbus.New()
|
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
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthz(t *testing.T) {
|
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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_ServesSPAAtRoot(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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_DeepLinkFallbackReturnsSPA(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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_APIPathNotSwallowedBySPA(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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_RestPathNotSwallowedBySPA(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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
|
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())
|
ts := httptest.NewServer(s.Router())
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
|
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
|
||||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { api } from './client';
|
|
||||||
|
|
||||||
describe('admin scan-schedule API', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it('getScanSchedule GETs the correct path', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'daily',
|
|
||||||
interval_hours: null,
|
|
||||||
time_of_day: '03:00',
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: '2026-05-07T03:00:00Z'
|
|
||||||
};
|
|
||||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await getScanSchedule();
|
|
||||||
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
|
|
||||||
expect(got).toEqual(sample);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updateScanSchedule PATCHes with the right body', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'interval',
|
|
||||||
interval_hours: 6,
|
|
||||||
time_of_day: null,
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: '2026-05-06T20:00:00Z'
|
|
||||||
};
|
|
||||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
|
|
||||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
|
|
||||||
mode: 'interval',
|
|
||||||
interval_hours: 6
|
|
||||||
});
|
|
||||||
expect(got.mode).toBe('interval');
|
|
||||||
expect(got.interval_hours).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updateScanSchedule with mode=off sends just mode', async () => {
|
|
||||||
const sample: ScanSchedule = {
|
|
||||||
mode: 'off',
|
|
||||||
interval_hours: null,
|
|
||||||
time_of_day: null,
|
|
||||||
weekly_day: null,
|
|
||||||
next_scheduled_at: null
|
|
||||||
};
|
|
||||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
|
||||||
const got = await updateScanSchedule({ mode: 'off' });
|
|
||||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
|
|
||||||
expect(got.next_scheduled_at).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -383,43 +383,6 @@ export function createCoverProvidersQuery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan schedule -----------------------------------------------------------
|
|
||||||
|
|
||||||
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
|
||||||
|
|
||||||
export type ScanSchedule = {
|
|
||||||
mode: ScanScheduleMode;
|
|
||||||
interval_hours: number | null;
|
|
||||||
time_of_day: string | null; // 'HH:MM'
|
|
||||||
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
|
||||||
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ScanScheduleUpdate = {
|
|
||||||
mode: ScanScheduleMode;
|
|
||||||
interval_hours?: number | null;
|
|
||||||
time_of_day?: string | null;
|
|
||||||
weekly_day?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getScanSchedule(): Promise<ScanSchedule> {
|
|
||||||
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
|
||||||
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings rarely change in operator time. 60s staleTime; refetches
|
|
||||||
// on window focus + after any PATCH invalidation — no polling.
|
|
||||||
export function createScanScheduleQuery() {
|
|
||||||
return createQuery({
|
|
||||||
queryKey: qk.scanSchedule(),
|
|
||||||
queryFn: getScanSchedule,
|
|
||||||
staleTime: 60_000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin user-management ------------------------------------------------------
|
// Admin user-management ------------------------------------------------------
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const qk = {
|
|||||||
adminPlaybackErrors: (resolved?: boolean) =>
|
adminPlaybackErrors: (resolved?: boolean) =>
|
||||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||||
scanStatus: () => ['scanStatus'] as const,
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
scanSchedule: () => ['scanSchedule'] as const,
|
|
||||||
coverage: () => ['coverage'] as const,
|
coverage: () => ['coverage'] as const,
|
||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
adminUsers: () => ['adminUsers'] as const,
|
adminUsers: () => ['adminUsers'] as const,
|
||||||
@@ -57,6 +56,8 @@ export const qk = {
|
|||||||
home: () => ['home'] as const,
|
home: () => ['home'] as const,
|
||||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||||
artistTracks: (artistId: string) => ['artistTracks', artistId] 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?: 'user' | 'system' | 'all') =>
|
||||||
['playlists', { kind: kind ?? 'user' }] as const,
|
['playlists', { kind: kind ?? 'user' }] as const,
|
||||||
playlist: (id: string) => ['playlist', id] as const,
|
playlist: (id: string) => ['playlist', id] as const,
|
||||||
@@ -89,6 +90,22 @@ export function createAlbumQuery(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSimilarArtistsQuery(id: string) {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.similarArtists(id),
|
||||||
|
queryFn: () => api.get<ArtistRef[]>(`/api/artists/${id}/similar`),
|
||||||
|
enabled: id.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createArtistTopTracksQuery(id: string) {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.artistTopTracks(id),
|
||||||
|
queryFn: () => api.get<TrackRef[]>(`/api/artists/${id}/top-tracks`),
|
||||||
|
enabled: id.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createSearchQuery(q: string) {
|
export function createSearchQuery(q: string) {
|
||||||
return createQuery({
|
return createQuery({
|
||||||
queryKey: qk.search(q),
|
queryKey: qk.search(q),
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
createAdminQuarantineQuery,
|
createAdminQuarantineQuery,
|
||||||
createScanStatusQuery,
|
createScanStatusQuery,
|
||||||
createCoverageQuery,
|
createCoverageQuery,
|
||||||
createScanScheduleQuery,
|
|
||||||
updateScanSchedule,
|
|
||||||
approveRequest,
|
approveRequest,
|
||||||
rejectRequest,
|
rejectRequest,
|
||||||
resolveQuarantine,
|
resolveQuarantine,
|
||||||
@@ -19,9 +17,7 @@
|
|||||||
deleteQuarantineViaLidarr,
|
deleteQuarantineViaLidarr,
|
||||||
refetchMissingCovers,
|
refetchMissingCovers,
|
||||||
triggerScan,
|
triggerScan,
|
||||||
researchMissingArt,
|
researchMissingArt
|
||||||
type ScanSchedule,
|
|
||||||
type ScanScheduleMode
|
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
@@ -211,89 +207,6 @@
|
|||||||
return new Date(iso).toLocaleString();
|
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 ----
|
// ---- Cover art bulk refetch ----
|
||||||
let bulkBusy = $state(false);
|
let bulkBusy = $state(false);
|
||||||
let bulkResult = $state<string | null>(null);
|
let bulkResult = $state<string | null>(null);
|
||||||
@@ -517,77 +430,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Scan schedule -->
|
|
||||||
<section class="rounded border border-border bg-surface p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-display text-lg font-medium">Scan schedule</h2>
|
|
||||||
<p class="mt-1 text-sm text-text-secondary">
|
|
||||||
Run a library scan automatically on a recurring cadence. Manual triggers
|
|
||||||
and boot scans are unaffected.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if schedule}
|
|
||||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
|
||||||
<label class="text-sm text-text-secondary" for="schedule-mode">Mode</label>
|
|
||||||
<select id="schedule-mode" bind:value={scheduleLocal.mode}
|
|
||||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
|
||||||
<option value="off">Off</option>
|
|
||||||
<option value="interval">Every N hours</option>
|
|
||||||
<option value="daily">Daily at time</option>
|
|
||||||
<option value="weekly">Weekly at day + time</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{#if scheduleLocal.mode === 'interval'}
|
|
||||||
<label class="text-sm text-text-secondary" for="schedule-interval">Interval</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input id="schedule-interval" type="number" min="1" max="168"
|
|
||||||
bind:value={scheduleLocal.interval_hours}
|
|
||||||
class="w-20 text-sm rounded border border-border bg-background px-2 py-1" />
|
|
||||||
<span class="text-sm text-text-muted">hours</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'}
|
|
||||||
<label class="text-sm text-text-secondary" for="schedule-time">Time</label>
|
|
||||||
<input id="schedule-time" type="time" bind:value={scheduleLocal.time_of_day}
|
|
||||||
class="text-sm rounded border border-border bg-background px-2 py-1" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if scheduleLocal.mode === 'weekly'}
|
|
||||||
<label class="text-sm text-text-secondary" for="schedule-day">Day</label>
|
|
||||||
<select id="schedule-day" bind:value={scheduleLocal.weekly_day}
|
|
||||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
|
||||||
<option value={1}>Monday</option>
|
|
||||||
<option value={2}>Tuesday</option>
|
|
||||||
<option value={3}>Wednesday</option>
|
|
||||||
<option value={4}>Thursday</option>
|
|
||||||
<option value={5}>Friday</option>
|
|
||||||
<option value={6}>Saturday</option>
|
|
||||||
<option value={7}>Sunday</option>
|
|
||||||
</select>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="button" disabled={!scheduleHasChanges() || scheduleSaving}
|
|
||||||
onclick={onSaveSchedule}
|
|
||||||
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50">
|
|
||||||
{scheduleSaving ? 'Saving…' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
{#if schedule.next_scheduled_at}
|
|
||||||
<span class="text-xs text-text-muted">
|
|
||||||
Next scan: {formatRelative(schedule.next_scheduled_at)}
|
|
||||||
</span>
|
|
||||||
{:else if scheduleLocal.mode === 'off'}
|
|
||||||
<span class="text-xs text-text-muted">Schedule is off.</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-text-muted">Loading…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Cover art bulk refetch -->
|
<!-- Cover art bulk refetch -->
|
||||||
<section class="rounded border border-border bg-surface p-4">
|
<section class="rounded border border-border bg-surface p-4">
|
||||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||||
|
|||||||
@@ -42,21 +42,7 @@ vi.mock('$lib/api/admin', async () => {
|
|||||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||||
triggerScan: vi.fn().mockResolvedValue({}),
|
triggerScan: vi.fn().mockResolvedValue({}),
|
||||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
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({})
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 () => {
|
test('renders Re-search missing art button and invokes on click', async () => {
|
||||||
setupOverview();
|
setupOverview();
|
||||||
render(OverviewPage);
|
render(OverviewPage);
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createArtistQuery } from '$lib/api/queries';
|
import {
|
||||||
|
createArtistQuery,
|
||||||
|
createSimilarArtistsQuery,
|
||||||
|
createArtistTopTracksQuery
|
||||||
|
} from '$lib/api/queries';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||||
|
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
||||||
|
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
||||||
|
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
@@ -11,13 +18,20 @@
|
|||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '$lib/api/client';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
const id = $derived(page.params.id ?? '');
|
const id = $derived(page.params.id ?? '');
|
||||||
const queryStore = $derived(createArtistQuery(id));
|
const queryStore = $derived(createArtistQuery(id));
|
||||||
const query = $derived($queryStore);
|
const query = $derived($queryStore);
|
||||||
const showSkeleton = useDelayed(() => query.isPending);
|
const showSkeleton = useDelayed(() => query.isPending);
|
||||||
|
|
||||||
|
// Secondary sections, lazy-loaded alongside the detail. Each hides itself
|
||||||
|
// when empty (no similarity matches yet / user hasn't played this artist).
|
||||||
|
const similarStore = $derived(createSimilarArtistsQuery(id));
|
||||||
|
const similarQuery = $derived($similarStore);
|
||||||
|
const topTracksStore = $derived(createArtistTopTracksQuery(id));
|
||||||
|
const topTracksQuery = $derived($topTracksStore);
|
||||||
|
|
||||||
const notFound = $derived(
|
const notFound = $derived(
|
||||||
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
||||||
);
|
);
|
||||||
@@ -97,5 +111,30 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if (topTracksQuery.data?.length ?? 0) > 0}
|
||||||
|
{@const top = topTracksQuery.data ?? []}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<HorizontalScrollRow rows={[top]} title="Top tracks" ariaLabel={`Top tracks for ${detail.name}`}>
|
||||||
|
{#snippet item(track: TrackRef, globalIdx: number)}
|
||||||
|
<CompactTrackCard {track} sectionTracks={top} index={globalIdx} />
|
||||||
|
{/snippet}
|
||||||
|
</HorizontalScrollRow>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if (similarQuery.data?.length ?? 0) > 0}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<HorizontalScrollRow
|
||||||
|
rows={[similarQuery.data ?? []]}
|
||||||
|
title="Similar artists"
|
||||||
|
ariaLabel="Similar artists"
|
||||||
|
>
|
||||||
|
{#snippet item(artist: ArtistRef)}
|
||||||
|
<div class="w-36"><ArtistCard {artist} /></div>
|
||||||
|
{/snippet}
|
||||||
|
</HorizontalScrollRow>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { flushSync } from 'svelte';
|
import { flushSync } from 'svelte';
|
||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
@@ -16,13 +16,19 @@ vi.mock('$app/state', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/api/queries', () => ({
|
vi.mock('$lib/api/queries', () => ({
|
||||||
qk: { artist: (id: string) => ['artist', id] },
|
qk: { artist: (id: string) => ['artist', id] },
|
||||||
createArtistQuery: vi.fn()
|
createArtistQuery: vi.fn(),
|
||||||
|
createSimilarArtistsQuery: vi.fn(),
|
||||||
|
createArtistTopTracksQuery: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||||
|
|
||||||
import ArtistPage from './+page.svelte';
|
import ArtistPage from './+page.svelte';
|
||||||
import { createArtistQuery } from '$lib/api/queries';
|
import {
|
||||||
|
createArtistQuery,
|
||||||
|
createSimilarArtistsQuery,
|
||||||
|
createArtistTopTracksQuery
|
||||||
|
} from '$lib/api/queries';
|
||||||
|
|
||||||
function album(id: string, title: string, year?: number): AlbumRef {
|
function album(id: string, title: string, year?: number): AlbumRef {
|
||||||
return {
|
return {
|
||||||
@@ -40,6 +46,13 @@ afterEach(() => {
|
|||||||
state.pageParams = { id: 'abc' };
|
state.pageParams = { id: 'abc' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Secondary sections default to empty so the existing album-grid assertions
|
||||||
|
// are unaffected; individual tests can override to exercise the new strips.
|
||||||
|
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||||
|
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||||
|
});
|
||||||
|
|
||||||
describe('artist detail page', () => {
|
describe('artist detail page', () => {
|
||||||
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||||
const detail: ArtistDetail = {
|
const detail: ArtistDetail = {
|
||||||
@@ -54,6 +67,28 @@ describe('artist detail page', () => {
|
|||||||
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
|
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders top-tracks panel and similar-artists strip when present', () => {
|
||||||
|
const detail: ArtistDetail = {
|
||||||
|
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||||
|
albums: [album('a1', 'First', 2020)]
|
||||||
|
};
|
||||||
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||||
|
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
data: [{
|
||||||
|
id: 't1', title: 'Hit Song', album_id: 'a1', album_title: 'First',
|
||||||
|
artist_id: 'abc', artist_name: 'Alice', duration_sec: 200, stream_url: '/s/t1'
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
|
data: [{ id: 'sim1', name: 'Bob', sort_name: 'Bob', album_count: 2, cover_url: '' }]
|
||||||
|
}));
|
||||||
|
render(ArtistPage);
|
||||||
|
expect(screen.getByText(/top tracks/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hit Song')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/similar artists/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/sim1');
|
||||||
|
});
|
||||||
|
|
||||||
test('back link points to Library', () => {
|
test('back link points to Library', () => {
|
||||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||||
data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] }
|
data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '$lib/api/requests';
|
||||||
|
import { qk } from '$lib/api/queries';
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
|
import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
|
||||||
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
||||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
@@ -12,6 +14,8 @@
|
|||||||
LidarrSearchResult
|
LidarrSearchResult
|
||||||
} from '$lib/api/types';
|
} from '$lib/api/types';
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Local input is the source of truth for the discover query — Shell's global
|
// Local input is the source of truth for the discover query — Shell's global
|
||||||
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
||||||
// pushing into `debouncedQ`, which is what the query factory observes.
|
// pushing into `debouncedQ`, which is what the query factory observes.
|
||||||
@@ -87,6 +91,8 @@
|
|||||||
const next = new Set(optimisticRequested);
|
const next = new Set(optimisticRequested);
|
||||||
next.add(r.mbid);
|
next.add(r.mbid);
|
||||||
optimisticRequested = next;
|
optimisticRequested = next;
|
||||||
|
// Surface the new request on /requests right away (+ start its poll) (#369).
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.myRequests() });
|
||||||
} catch {
|
} catch {
|
||||||
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
||||||
// in 'requestable' so the user can retry.
|
// in 'requestable' so the user can retry.
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
vi.mock('$lib/api/client', () => apiClientMock());
|
||||||
|
|
||||||
|
// The page calls useQueryClient() to invalidate qk.myRequests() after a
|
||||||
|
// submit (#369); stub it so rendering doesn't require a real QueryClient and
|
||||||
|
// we can assert the invalidation fires.
|
||||||
|
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }));
|
||||||
|
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@tanstack/svelte-query')>();
|
||||||
|
return { ...actual, useQueryClient: () => queryClientMock };
|
||||||
|
});
|
||||||
|
|
||||||
import DiscoverPage from './+page.svelte';
|
import DiscoverPage from './+page.svelte';
|
||||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||||||
@@ -187,6 +196,27 @@ describe('Discover page', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('submitting a request invalidates myRequests so /requests updates (#369)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const r = result({ mbid: 'art-mbid', artist_mbid: 'art-mbid', name: 'Boards of Canada' });
|
||||||
|
mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] }));
|
||||||
|
render(DiscoverPage);
|
||||||
|
const input = screen.getByLabelText(/search lidarr/i);
|
||||||
|
await fireEvent.input(input, { target: { value: 'boards' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const requestBtn = await screen.findByRole('button', {
|
||||||
|
name: /request boards of canada/i
|
||||||
|
});
|
||||||
|
await fireEvent.click(requestBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['myRequests']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
|
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const r = result({
|
const r = result({
|
||||||
|
|||||||
Reference in New Issue
Block a user