add star/unstar and long-press item menus to the library lists

Star is a new write path: MusicSource.setStar posts star.view/unstar.view and
LibraryRepository updates the local row on success (toast on failure). Long-press an
album, artist, song or playlist in the library to open a bottom sheet with play/shuffle,
star/unstar and view-album/view-artist.
This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent ed7a2e7390
commit fb9e985680
15 changed files with 490 additions and 20 deletions
@@ -62,6 +62,27 @@ interface LibraryDao {
@Upsert
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
@Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setArtistStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("UPDATE albums SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setAlbumStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("UPDATE songs SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
suspend fun setSongStar(
sourceId: Long,
id: String,
starred: Long?,
)
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
suspend fun artistIds(sourceId: Long): List<String>
@@ -16,11 +16,13 @@ import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.Flow
class LibraryRepository(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val showMessage: (String) -> Unit = {},
) {
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
@@ -234,6 +236,33 @@ class LibraryRepository(
playlistId: String,
): Flow<PagingData<SongListItem>> = pager(60) { db.libraryDao().playlistSongs(sourceId, playlistId) }
suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
): Result<Unit> {
val sourceId = sourceRepository.activeSourceIdOnce()
val source = sourceRepository.activeMusicSource()
if (sourceId == null || source == null) return Result.failure(IllegalStateException("No active server"))
return runCatching { source.setStar(type, id, starred) }
.onSuccess { updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null) }
.onFailure { showMessage("Could not update star") }
}
private suspend fun updateStarred(
sourceId: Long,
type: StarType,
id: String,
starred: Long?,
) {
val dao = db.libraryDao()
when (type) {
StarType.Song -> dao.setSongStar(sourceId, id, starred)
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
}
}
private fun <T : Any> pager(
pageSize: Int,
source: () -> PagingSource<Int, T>,
@@ -12,6 +12,12 @@ interface MusicSource {
suspend fun ping()
suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
)
fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>>
@@ -22,3 +28,9 @@ interface MusicSource {
fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>>
}
enum class StarType {
Song,
Album,
Artist,
}
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -40,6 +41,22 @@ class SubsonicSource(
withContext(Dispatchers.IO) { client.check("ping") }
}
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
val param =
when (type) {
StarType.Song -> "id"
StarType.Album -> "albumId"
StarType.Artist -> "artistId"
}
withContext(Dispatchers.IO) {
client.check(if (starred) "star" else "unstar", mapOf(param to id))
}
}
override fun artists(): Flow<List<Artist>> =
entityBatches(
method = "getArtists",
@@ -32,20 +32,14 @@ fun appModule(
http: OkHttpClient,
) = module {
val mainHandler = Handler(Looper.getMainLooper())
val toast: (String) -> Unit = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
}
single { createAndroidDatabase(context) }
single { http }
single { createUserPreferences(context) }
single {
SourceRepository(
get(),
get(),
get(),
showMessage = { message ->
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
},
)
}
single { LibraryRepository(get(), get()) }
single { SourceRepository(get(), get(), get(), showMessage = toast) }
single { LibraryRepository(get(), get(), toast) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) }
single { QueueRepository(get()) }
@@ -220,6 +220,8 @@ private fun MainNavigation() {
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onViewAlbum = { albumId -> navController.navigate(Routes.album(albumId)) },
onViewArtist = { artistId -> navController.navigate(Routes.artist(artistId)) },
bottomInset = bottomInset,
)
}
@@ -0,0 +1,170 @@
package com.subtracks.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import com.subtracks.data.source.StarType
import com.subtracks.data.model.Album as AlbumModel
import com.subtracks.data.model.Artist as ArtistModel
import com.subtracks.data.model.Playlist as PlaylistModel
import com.subtracks.data.model.Song as SongModel
sealed interface MenuTarget {
val title: String
val subtitle: String?
data class Song(
val song: SongModel,
) : MenuTarget {
override val title: String get() = song.title
override val subtitle: String? get() = song.artist
}
data class Album(
val album: AlbumModel,
) : MenuTarget {
override val title: String get() = album.name
override val subtitle: String? get() = album.albumArtist
}
data class Artist(
val artist: ArtistModel,
) : MenuTarget {
override val title: String get() = artist.name
override val subtitle: String? get() = null
}
data class Playlist(
val playlist: PlaylistModel,
) : MenuTarget {
override val title: String get() = playlist.name
override val subtitle: String? get() = null
}
}
class ItemActions(
val playSong: (SongModel) -> Unit = {},
val playAlbum: (AlbumModel) -> Unit = {},
val shuffleAlbum: (AlbumModel) -> Unit = {},
val playPlaylist: (PlaylistModel) -> Unit = {},
val shufflePlaylist: (PlaylistModel) -> Unit = {},
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: (String) -> Unit = {},
val viewArtist: (String) -> Unit = {},
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemContextMenu(
target: MenuTarget,
actions: ItemActions,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
val dismissThen: (() -> Unit) -> Unit = { action ->
onDismiss()
action()
}
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playSong(target.song) } }
MenuItem(starIcon(target.song.starred), starLabel(target.song.starred)) {
dismissThen { actions.setStar(StarType.Song, target.song.id, target.song.starred == null) }
}
target.song.albumId?.let { albumId ->
MenuItem(Icons.Rounded.Album, "View album") { dismissThen { actions.viewAlbum(albumId) } }
}
target.song.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
}
}
is MenuTarget.Album -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shuffleAlbum(target.album) } }
MenuItem(starIcon(target.album.starred), starLabel(target.album.starred)) {
dismissThen { actions.setStar(StarType.Album, target.album.id, target.album.starred == null) }
}
target.album.artistId?.let { artistId ->
MenuItem(Icons.Rounded.Person, "View artist") { dismissThen { actions.viewArtist(artistId) } }
}
}
is MenuTarget.Artist -> {
MenuItem(starIcon(target.artist.starred), starLabel(target.artist.starred)) {
dismissThen { actions.setStar(StarType.Artist, target.artist.id, target.artist.starred == null) }
}
}
is MenuTarget.Playlist -> {
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismissThen { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismissThen { actions.shufflePlaylist(target.playlist) } }
}
}
}
}
@Composable
private fun MenuHeader(target: MenuTarget) {
ListItem(
headlineContent = {
Text(
text = target.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent =
target.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
{
Text(
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@Composable
private fun MenuItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(label) },
leadingContent = { Icon(icon, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick),
)
}
private fun starIcon(starred: Long?): ImageVector = if (starred == null) Icons.Rounded.StarBorder else Icons.Rounded.Star
private fun starLabel(starred: Long?): String = if (starred == null) "Star" else "Unstar"
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -40,6 +41,7 @@ fun AlbumsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onAlbumClick: (Album) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -90,8 +92,10 @@ fun AlbumsContent(
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.clickable { onAlbumClick(album) }
.testTag(ALBUM_COVER_TAG),
.combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album)) },
).testTag(ALBUM_COVER_TAG),
)
}
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -38,6 +39,7 @@ fun ArtistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -93,7 +95,11 @@ fun ArtistsContent(
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
modifier = Modifier.clickable { onArtistClick(artist) },
modifier =
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist)) },
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -114,6 +114,9 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SongSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor
@@ -189,12 +192,15 @@ fun LibraryRoute(
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onOpenSettings: () -> Unit,
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
bottomInset: Dp,
viewModel: LibraryViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var previousTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
var menuTarget by remember { mutableStateOf<MenuTarget?>(null) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
val listTab = selectedTab.listTab()
@@ -231,6 +237,7 @@ fun LibraryRoute(
onSongClick = viewModel::playSong,
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
onItemLongClick = { menuTarget = it },
playingSongId = playingSongId,
listQuery = listQuery,
resetKeys = resetKeys,
@@ -254,6 +261,23 @@ fun LibraryRoute(
search = search,
onSearchChange = { viewModel.setSearch(listTab, it) },
)
menuTarget?.let { target ->
ItemContextMenu(
target = target,
actions =
ItemActions(
playSong = { viewModel.playSong(it.id) },
playAlbum = { viewModel.playAlbum(it.id) },
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playPlaylist = { viewModel.playPlaylist(it.id) },
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
setStar = viewModel::setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
),
onDismiss = { menuTarget = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -272,6 +296,7 @@ fun LibraryScreen(
onSongClick: (String) -> Unit,
onSync: () -> Unit,
onOpenSettings: () -> Unit,
onItemLongClick: (MenuTarget) -> Unit = {},
syncing: Boolean = false,
playingSongId: String? = null,
bottomInset: Dp = 0.dp,
@@ -364,6 +389,7 @@ fun LibraryScreen(
dismissSearch()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -381,6 +407,7 @@ fun LibraryScreen(
dismissSearch()
onArtistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -398,6 +425,7 @@ fun LibraryScreen(
dismissSearch()
onSongClick(it)
},
onLongClick = onItemLongClick,
playingSongId,
filtered = filtersActive,
onClearFilters = onClearFilters,
@@ -416,6 +444,7 @@ fun LibraryScreen(
dismissSearch()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
resetKey = resetKey,
@@ -20,6 +20,7 @@ import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueSongQuery
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
@@ -138,6 +139,38 @@ class LibraryViewModel(
}
}
fun playAlbum(albumId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.playAlbum(it, albumId, 0) }
}
}
fun shuffleAlbum(albumId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.shuffleAlbum(it, albumId) }
}
}
fun playPlaylist(playlistId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.playPlaylist(it, playlistId, 0) }
}
}
fun shufflePlaylist(playlistId: String) {
viewModelScope.launch {
sourceRepository.activeSourceIdOnce()?.let { playbackController.shufflePlaylist(it, playlistId) }
}
}
fun setStar(
type: StarType,
id: String,
starred: Boolean,
) {
viewModelScope.launch { libraryRepository.setStar(type, id, starred) }
}
fun sync() = syncManager.requestSync()
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +28,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@@ -37,6 +38,7 @@ fun PlaylistsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onPlaylistClick: (Playlist) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
resetKey: Any? = null,
@@ -91,7 +93,11 @@ fun PlaylistsContent(
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
)
},
modifier = Modifier.clickable { onPlaylistClick(playlist) },
modifier =
Modifier.combinedClickable(
onClick = { onPlaylistClick(playlist) },
onLongClick = { onLongClick(MenuTarget.Playlist(playlist)) },
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@@ -1,6 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -39,6 +39,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.rememberViewportFill
@@ -48,6 +49,7 @@ fun SongsContent(
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onSongClick: (String) -> Unit,
onLongClick: (MenuTarget) -> Unit = {},
playingSongId: String? = null,
filtered: Boolean = false,
onClearFilters: () -> Unit = {},
@@ -86,7 +88,11 @@ fun SongsContent(
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(item.song.id) },
modifier =
Modifier.combinedClickable(
onClick = { onSongClick(item.song.id) },
onLongClick = { onLongClick(MenuTarget.Song(item.song)) },
),
)
}
}
@@ -0,0 +1,134 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LibraryRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var repository: LibraryRepository
private val messages = ArrayList<String>()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
repository = LibraryRepository(db, sourceRepository, showMessage = { messages += it })
}
@After
fun tearDown() {
sourceRepository.close()
db.close()
}
@Test
fun starringASongPostsToTheServerAndUpdatesTheRow() =
runBlocking {
withSource { server ->
server.enqueue(ok())
assertTrue(repository.setStar(StarType.Song, "s1", true).isSuccess)
val request = server.takeRequest()
assertEquals("/rest/star.view", request.requestUrl!!.encodedPath)
assertEquals("s1", request.requestUrl!!.queryParameter("id"))
assertNotNull(starred())
}
}
@Test
fun unstarringPostsToTheServerAndClearsTheRow() =
runBlocking {
withSource { server ->
server.enqueue(ok())
repository.setStar(StarType.Song, "s1", true)
server.enqueue(ok())
repository.setStar(StarType.Song, "s1", false)
server.takeRequest()
assertEquals("/rest/unstar.view", server.takeRequest().requestUrl!!.encodedPath)
assertNull(starred())
}
}
@Test
fun aRejectedStarLeavesTheRowUntouchedAndReports() =
runBlocking {
withSource { server ->
server.enqueue(failed(40))
assertTrue(repository.setStar(StarType.Song, "s1", true).isFailure)
assertNull(starred())
assertEquals(listOf("Could not update star"), messages)
}
}
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer()
server.start()
try {
val sourceId = sourceRepository.addSource("nav", server.url("/").toString(), "u", "p", false)
db.libraryDao().upsertSongs(listOf(seedSong(sourceId)))
block(server)
} finally {
server.shutdown()
}
}
private suspend fun starred(): Long? =
repository
.albumSongs(sourceRepository.activeSourceIdOnce()!!, "al1")
.first()
.first { it.id == "s1" }
.starred
private fun seedSong(sourceId: Long) =
Song(
sourceId = sourceId,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Track",
album = "Album",
artist = "Artist",
duration = 1_000,
track = 1,
disc = 1,
starred = null,
genre = null,
)
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
private fun failed(code: Int) =
MockResponse().setBody("<subsonic-response status=\"failed\"><error code=\"$code\" message=\"nope\"/></subsonic-response>")
}
@@ -16,6 +16,7 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@@ -679,6 +680,12 @@ private class FakeMusicSource(
override suspend fun ping() = Unit
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) = Unit
override fun artists(): Flow<List<Artist>> = batches(artists)
override fun albums(): Flow<List<Album>> = batches(albums)