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:
@@ -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