mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
gate scrobbling behind a setting and funnel server writes through a sink
Scrobbling can be turned off in Settings (on by default). Now-playing, scrobbles and stars all go through a ServerActionSink so a persistent offline outbox can replace the network implementation later without touching the callers.
This commit is contained in:
@@ -105,6 +105,14 @@ class UserPreferences(
|
||||
|
||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||
|
||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||
|
||||
suspend fun setScrobbling(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[scrobblingKey] = enabled }
|
||||
}
|
||||
|
||||
private val scrobblingKey = booleanPreferencesKey("scrobbling")
|
||||
|
||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||
val prefs = store.data.first()
|
||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val showMessage: (String) -> Unit = {},
|
||||
) {
|
||||
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
||||
@@ -239,14 +241,13 @@ class LibraryRepository(
|
||||
starred: Boolean,
|
||||
): Result<Unit> {
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()
|
||||
val source = sourceRepository.activeMusicSource()
|
||||
if (sourceId == null || source == null) {
|
||||
if (sourceId == null) {
|
||||
return Result.failure(IllegalStateException("No active server"))
|
||||
}
|
||||
val previous = starredValue(sourceId, type, id)
|
||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||
return try {
|
||||
source.setStar(type, id, starred)
|
||||
serverActions.setStar(type, id, starred)
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
|
||||
class NetworkServerActionSink(
|
||||
private val sources: SourceRepository,
|
||||
) : ServerActionSink {
|
||||
override suspend fun nowPlaying(songId: String) {
|
||||
sources.activeMusicSource()?.scrobble(songId, submission = false)
|
||||
}
|
||||
|
||||
override suspend fun scrobble(
|
||||
songId: String,
|
||||
time: Long,
|
||||
) {
|
||||
sources.activeMusicSource()?.scrobble(songId, submission = true, time = time)
|
||||
}
|
||||
|
||||
override suspend fun setStar(
|
||||
type: StarType,
|
||||
id: String,
|
||||
starred: Boolean,
|
||||
) {
|
||||
val source = sources.activeMusicSource() ?: throw IllegalStateException("No active server")
|
||||
source.setStar(type, id, starred)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.subtracks.data.source
|
||||
|
||||
/**
|
||||
* Writes the app pushes back to the server. Today they go straight over the
|
||||
* network, but funneling them through one seam leaves room to persist them and
|
||||
* replay later while the server is unreachable.
|
||||
*/
|
||||
interface ServerActionSink {
|
||||
suspend fun nowPlaying(songId: String)
|
||||
|
||||
suspend fun scrobble(
|
||||
songId: String,
|
||||
time: Long,
|
||||
)
|
||||
|
||||
suspend fun setStar(
|
||||
type: StarType,
|
||||
id: String,
|
||||
starred: Boolean,
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,16 @@ import com.subtracks.data.download.DownloadNotifier
|
||||
import com.subtracks.data.download.OkHttpArtworkFetcher
|
||||
import com.subtracks.data.download.SystemDownloadEngine
|
||||
import com.subtracks.data.net.networkMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.createUserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -51,7 +54,8 @@ fun appModule(
|
||||
single { http }
|
||||
single { createUserPreferences(context) }
|
||||
single { SourceRepository(get(), get(), get(), get(), networkMode = networkMode(context.applicationContext), showMessage = toast) }
|
||||
single { LibraryRepository(get(), get(), toast) }
|
||||
single<ServerActionSink> { NetworkServerActionSink(get()) }
|
||||
single { LibraryRepository(get(), get(), get(), toast) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get()) }
|
||||
single { QueueRepository(get()) }
|
||||
@@ -74,7 +78,7 @@ fun appModule(
|
||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
|
||||
single(createdAtStart = true) {
|
||||
val playback = get<PlaybackController>()
|
||||
Scrobbler(source = { get<SourceRepository>().activeMusicSource() }).also {
|
||||
Scrobbler(sink = get(), enabled = get<UserPreferences>().scrobbling()).also {
|
||||
it.attach(playback.state, playback.positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -34,6 +35,14 @@ class ScrobblePolicy(
|
||||
private var nowPlayingSent = false
|
||||
private var submitted = false
|
||||
|
||||
fun reset() {
|
||||
songId = null
|
||||
playedMs = 0
|
||||
lastPositionMs = 0
|
||||
nowPlayingSent = false
|
||||
submitted = false
|
||||
}
|
||||
|
||||
fun advance(
|
||||
item: QueueItem?,
|
||||
isPlaying: Boolean,
|
||||
@@ -80,7 +89,8 @@ class ScrobblePolicy(
|
||||
}
|
||||
|
||||
class Scrobbler(
|
||||
private val source: suspend () -> MusicSource?,
|
||||
private val sink: ServerActionSink,
|
||||
private val enabled: Flow<Boolean>,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
private val policy: ScrobblePolicy = ScrobblePolicy(),
|
||||
) {
|
||||
@@ -89,28 +99,25 @@ class Scrobbler(
|
||||
positionMs: StateFlow<Long>,
|
||||
) {
|
||||
scope.launch {
|
||||
combine(state, positionMs) { current, position -> current to position }.collect { (current, position) ->
|
||||
val event = policy.advance(current.item, current.isPlaying, position) ?: return@collect
|
||||
submit(event)
|
||||
}
|
||||
combine(state, positionMs, enabled) { current, position, on -> Triple(current, position, on) }
|
||||
.collect { (current, position, on) ->
|
||||
if (!on) {
|
||||
policy.reset()
|
||||
return@collect
|
||||
}
|
||||
val event = policy.advance(current.item, current.isPlaying, position) ?: return@collect
|
||||
submit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = scope.cancel()
|
||||
|
||||
private suspend fun submit(event: Scrobble) {
|
||||
val source =
|
||||
try {
|
||||
source()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return
|
||||
try {
|
||||
when (event) {
|
||||
is Scrobble.NowPlaying -> source.scrobble(event.songId, submission = false)
|
||||
is Scrobble.Submission -> source.scrobble(event.songId, submission = true, time = event.time)
|
||||
is Scrobble.NowPlaying -> sink.nowPlaying(event.songId)
|
||||
is Scrobble.Submission -> sink.scrobble(event.songId, event.time)
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
|
||||
@@ -73,6 +73,7 @@ fun SettingsRoute(
|
||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
@@ -81,6 +82,7 @@ fun SettingsRoute(
|
||||
syncConcurrency = syncConcurrency,
|
||||
downloadQuality = downloadQuality,
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onEditServer = onEditServer,
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
@@ -88,6 +90,7 @@ fun SettingsRoute(
|
||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onAddServer = onAddServer,
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onBack = onBack,
|
||||
@@ -104,6 +107,7 @@ fun SettingsScreen(
|
||||
syncConcurrency: Int,
|
||||
downloadQuality: StreamQuality,
|
||||
downloadOverMetered: Boolean,
|
||||
scrobbling: Boolean,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -111,6 +115,7 @@ fun SettingsScreen(
|
||||
onSyncConcurrencyChange: (Int) -> Unit,
|
||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onAddServer: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
@@ -197,6 +202,27 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader("Playback") }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Scrobble to server") },
|
||||
supportingContent = { Text("Send now playing and play counts") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = scrobbling,
|
||||
onCheckedChange = onScrobblingChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onScrobblingChange(!scrobbling) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader("Downloads") }
|
||||
item {
|
||||
ListItem(
|
||||
|
||||
@@ -48,6 +48,11 @@ class SettingsViewModel(
|
||||
.syncConcurrency()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||
|
||||
val scrobbling: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.scrobbling()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
}
|
||||
@@ -71,4 +76,8 @@ class SettingsViewModel(
|
||||
fun setSyncConcurrency(value: Int) {
|
||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||
}
|
||||
|
||||
fun setScrobbling(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user