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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -545,6 +546,33 @@ class SubsonicSourceTest {
|
||||
assertFalse(uri, uri.contains("estimateContentLength"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrobbleSubmitsTheSongWithItsTime() =
|
||||
runBlocking {
|
||||
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\"/>"))
|
||||
|
||||
SubsonicSource(1, client()).scrobble("s1", submission = true, time = 1_700_000_000_000L)
|
||||
|
||||
val url = server.takeRequest().requestUrl!!
|
||||
assertEquals("/rest/scrobble.view", url.encodedPath)
|
||||
assertEquals("s1", url.queryParameter("id"))
|
||||
assertEquals("true", url.queryParameter("submission"))
|
||||
assertEquals("1700000000000", url.queryParameter("time"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scrobbleSendsNowPlayingWithoutATime() =
|
||||
runBlocking {
|
||||
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\"/>"))
|
||||
|
||||
SubsonicSource(1, client()).scrobble("s1", submission = false)
|
||||
|
||||
val url = server.takeRequest().requestUrl!!
|
||||
assertEquals("s1", url.queryParameter("id"))
|
||||
assertEquals("false", url.queryParameter("submission"))
|
||||
assertNull(url.queryParameter("time"))
|
||||
}
|
||||
|
||||
private fun client() =
|
||||
SubsonicClient(
|
||||
baseUrl = server.url("/"),
|
||||
|
||||
@@ -730,6 +730,12 @@ private class FakeMusicSource(
|
||||
starred: Boolean,
|
||||
) = Unit
|
||||
|
||||
override suspend fun scrobble(
|
||||
songId: String,
|
||||
submission: Boolean,
|
||||
time: Long?,
|
||||
) = Unit
|
||||
|
||||
override fun artists(): Flow<List<Artist>> = batches(artists)
|
||||
|
||||
override fun albums(): Flow<List<Album>> = batches(albums)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ScrobblePolicyTest {
|
||||
private var clock = 1_000L
|
||||
private val policy = ScrobblePolicy(now = { clock })
|
||||
|
||||
private fun track(
|
||||
id: String = "s1",
|
||||
durationMs: Long = 200_000,
|
||||
) = QueueItem(id = id, title = "Title", artist = "Artist", album = "Album", coverArtId = null, durationMs = durationMs)
|
||||
|
||||
private fun play(
|
||||
item: QueueItem?,
|
||||
toPositionMs: Long,
|
||||
stepMs: Long = 500,
|
||||
playing: Boolean = true,
|
||||
): List<Scrobble> {
|
||||
val events = mutableListOf<Scrobble>()
|
||||
var position = 0L
|
||||
while (position <= toPositionMs) {
|
||||
policy.advance(item, playing, position)?.let(events::add)
|
||||
position += stepMs
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendsNowPlayingWhenPlaybackStarts() {
|
||||
val events = play(track(), toPositionMs = 0)
|
||||
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun waitsForPlaybackBeforeNowPlaying() {
|
||||
val item = track()
|
||||
assertNull(policy.advance(item, isPlaying = false, positionMs = 0))
|
||||
clock = 5_000L
|
||||
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitsAfterHalfTheDuration() {
|
||||
val events = play(track(durationMs = 200_000), toPositionMs = 100_000)
|
||||
assertEquals(Scrobble.NowPlaying("s1"), events.first())
|
||||
assertEquals(1, events.filterIsInstance<Scrobble.Submission>().size)
|
||||
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotSubmitBeforeHalfTheDuration() {
|
||||
val events = play(track(durationMs = 200_000), toPositionMs = 99_500)
|
||||
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitsAfterFourMinutesForLongTracks() {
|
||||
val events = play(track(durationMs = 10 * 60_000), toPositionMs = 4 * 60_000)
|
||||
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotSubmitShortTracks() {
|
||||
val events = play(track(durationMs = 20_000), toPositionMs = 20_000)
|
||||
assertEquals(listOf<Scrobble>(Scrobble.NowPlaying("s1")), events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitsOnlyOncePerTrack() {
|
||||
val events = play(track(durationMs = 200_000), toPositionMs = 200_000)
|
||||
assertEquals(1, events.filterIsInstance<Scrobble.Submission>().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seekingAheadDoesNotCountAsPlayback() {
|
||||
val item = track(durationMs = 200_000)
|
||||
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
|
||||
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_000))
|
||||
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_500))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pausedTimeIsNotCounted() {
|
||||
val item = track(durationMs = 200_000)
|
||||
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
|
||||
assertNull(policy.advance(item, isPlaying = false, positionMs = 120_000))
|
||||
assertNull(policy.advance(item, isPlaying = true, positionMs = 120_500))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newTrackResetsAndScrobblesAgain() {
|
||||
val first = play(track(id = "s1"), toPositionMs = 100_000)
|
||||
assertEquals(Scrobble.Submission("s1", 1_000L), first.last())
|
||||
clock = 60_000L
|
||||
val second = play(track(id = "s2"), toPositionMs = 100_000)
|
||||
assertEquals(Scrobble.NowPlaying("s2"), second.first())
|
||||
assertEquals(Scrobble.Submission("s2", 60_000L), second.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restartingTheSameTrackScrobblesAgain() {
|
||||
val item = track(durationMs = 200_000)
|
||||
play(item, toPositionMs = 100_000)
|
||||
clock = 120_000L
|
||||
val repeat = play(item, toPositionMs = 100_000)
|
||||
assertEquals(Scrobble.NowPlaying("s1"), repeat.first())
|
||||
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
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.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ScrobblerTest {
|
||||
private data class Call(
|
||||
val songId: String,
|
||||
val submission: Boolean,
|
||||
val time: Long?,
|
||||
)
|
||||
|
||||
private class RecordingSource : MusicSource {
|
||||
override val id: Long = 1
|
||||
val calls = mutableListOf<Call>()
|
||||
|
||||
override suspend fun ping() = Unit
|
||||
|
||||
override suspend fun setStar(
|
||||
type: StarType,
|
||||
id: String,
|
||||
starred: Boolean,
|
||||
) = Unit
|
||||
|
||||
override suspend fun scrobble(
|
||||
songId: String,
|
||||
submission: Boolean,
|
||||
time: Long?,
|
||||
) {
|
||||
calls += Call(songId, submission, time)
|
||||
}
|
||||
|
||||
override fun artists(): Flow<List<Artist>> = emptyFlow()
|
||||
|
||||
override fun albums(): Flow<List<Album>> = emptyFlow()
|
||||
|
||||
override fun songs(): Flow<List<Song>> = emptyFlow()
|
||||
|
||||
override fun playlists(): Flow<List<Playlist>> = emptyFlow()
|
||||
|
||||
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = emptyFlow()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendsNowPlayingThenScrobbleOnce() =
|
||||
runTest {
|
||||
val source = RecordingSource()
|
||||
val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + SupervisorJob())
|
||||
val scrobbler = Scrobbler(source = { source }, scope = scope, policy = ScrobblePolicy(now = { 1_000L }))
|
||||
val state = MutableStateFlow(PlaybackState())
|
||||
val position = MutableStateFlow(0L)
|
||||
scrobbler.attach(state, position)
|
||||
val item = QueueItem("s1", "Title", "Artist", "Album", null, durationMs = 30_000)
|
||||
|
||||
state.value = PlaybackState(item = item, isPlaying = true)
|
||||
assertEquals(listOf(Call("s1", submission = false, time = null)), source.calls)
|
||||
|
||||
var tick = 500L
|
||||
while (tick <= 15_000L) {
|
||||
position.value = tick
|
||||
tick += 500
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Call("s1", submission = false, time = null),
|
||||
Call("s1", submission = true, time = 1_000L),
|
||||
),
|
||||
source.calls,
|
||||
)
|
||||
|
||||
position.value = 25_000L
|
||||
assertEquals(2, source.calls.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user