mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
scrobble playback back to the server
Send a now-playing notification when a track starts playing and one submission once it passes half its duration or four minutes, using the Subsonic scrobble endpoint's submission and time parameters.
This commit is contained in:
@@ -20,6 +20,12 @@ interface MusicSource {
|
||||
starred: Boolean,
|
||||
)
|
||||
|
||||
suspend fun scrobble(
|
||||
songId: String,
|
||||
submission: Boolean,
|
||||
time: Long? = null,
|
||||
)
|
||||
|
||||
fun artists(): Flow<List<Artist>>
|
||||
|
||||
fun albums(): Flow<List<Album>>
|
||||
|
||||
@@ -63,6 +63,23 @@ class SubsonicSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun scrobble(
|
||||
songId: String,
|
||||
submission: Boolean,
|
||||
time: Long?,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
client.check(
|
||||
"scrobble",
|
||||
buildMap {
|
||||
put("id", songId)
|
||||
put("submission", submission.toString())
|
||||
time?.let { put("time", it.toString()) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun artists(): Flow<List<Artist>> =
|
||||
entityBatches(
|
||||
method = "getArtists",
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlayerConnection
|
||||
import com.subtracks.playback.Scrobbler
|
||||
import com.subtracks.ui.RootViewModel
|
||||
import com.subtracks.ui.downloads.DownloadsViewModel
|
||||
import com.subtracks.ui.library.AlbumDetailViewModel
|
||||
@@ -71,6 +72,12 @@ fun appModule(
|
||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
|
||||
single(createdAtStart = true) {
|
||||
val playback = get<PlaybackController>()
|
||||
Scrobbler(source = { get<SourceRepository>().activeMusicSource() }).also {
|
||||
it.attach(playback.state, playback.positionMs)
|
||||
}
|
||||
}
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get()) }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface Scrobble {
|
||||
val songId: String
|
||||
|
||||
data class NowPlaying(
|
||||
override val songId: String,
|
||||
) : Scrobble
|
||||
|
||||
data class Submission(
|
||||
override val songId: String,
|
||||
val time: Long,
|
||||
) : Scrobble
|
||||
}
|
||||
|
||||
class ScrobblePolicy(
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
private var songId: String? = null
|
||||
private var durationMs = 0L
|
||||
private var startedAt = 0L
|
||||
private var playedMs = 0L
|
||||
private var lastPositionMs = 0L
|
||||
private var nowPlayingSent = false
|
||||
private var submitted = false
|
||||
|
||||
fun advance(
|
||||
item: QueueItem?,
|
||||
isPlaying: Boolean,
|
||||
positionMs: Long,
|
||||
): Scrobble? {
|
||||
val previousPosition = lastPositionMs
|
||||
if (item?.id != songId || positionMs < previousPosition) {
|
||||
songId = item?.id
|
||||
durationMs = item?.durationMs ?: 0L
|
||||
playedMs = 0
|
||||
lastPositionMs = positionMs
|
||||
nowPlayingSent = false
|
||||
submitted = false
|
||||
if (item == null || !isPlaying) return null
|
||||
nowPlayingSent = true
|
||||
startedAt = now()
|
||||
return Scrobble.NowPlaying(item.id)
|
||||
}
|
||||
lastPositionMs = positionMs
|
||||
val id = songId ?: return null
|
||||
if (!isPlaying) return null
|
||||
if (!nowPlayingSent) {
|
||||
nowPlayingSent = true
|
||||
startedAt = now()
|
||||
return Scrobble.NowPlaying(id)
|
||||
}
|
||||
playedMs += (positionMs - previousPosition).coerceIn(0, MAX_STEP_MS)
|
||||
if (submitted || playedMs < thresholdMs()) return null
|
||||
submitted = true
|
||||
return Scrobble.Submission(id, startedAt)
|
||||
}
|
||||
|
||||
private fun thresholdMs(): Long {
|
||||
if (durationMs in 1 until MIN_DURATION_MS) return Long.MAX_VALUE
|
||||
if (durationMs <= 0) return MAX_THRESHOLD_MS
|
||||
return minOf(durationMs / 2, MAX_THRESHOLD_MS)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_DURATION_MS = 30_000L
|
||||
const val MAX_THRESHOLD_MS = 4 * 60_000L
|
||||
const val MAX_STEP_MS = 2_000L
|
||||
}
|
||||
}
|
||||
|
||||
class Scrobbler(
|
||||
private val source: suspend () -> MusicSource?,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
private val policy: ScrobblePolicy = ScrobblePolicy(),
|
||||
) {
|
||||
fun attach(
|
||||
state: StateFlow<PlaybackState>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user