initial: AI-generated jukebox metadata lib (Cursor's Composer 2)

This commit is contained in:
2026-05-13 16:50:34 +02:00
commit 2b778b4583
27 changed files with 1408 additions and 0 deletions

48
jukebox/build.gradle.kts Normal file
View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "at.lockstep.jukebox"
compileSdk = 35
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
val room = "2.6.1"
implementation("androidx.room:room-runtime:$room")
implementation("androidx.room:room-ktx:$room")
ksp("androidx.room:room-compiler:$room")
testImplementation("androidx.room:room-testing:$room")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core:1.6.1")
}

View File

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,88 @@
package at.lockstep.jukebox
import android.content.Context
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.PlaylistRetrofitApi
import at.lockstep.jukebox.db.JukeboxDatabase
import at.lockstep.jukebox.db.PlaylistDao
import at.lockstep.jukebox.sync.SyncCoordinator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlin.coroutines.CoroutineContext
class DefaultPlaylistRepository internal constructor(
private val dao: PlaylistDao,
private val syncCoordinator: SyncCoordinator,
private val ioDispatcher: CoroutineContext,
) : PlaylistRepository {
override suspend fun syncInitial() {
syncCoordinator.syncInitial()
}
override suspend fun syncDelta(retainRemovedPlaylists: Boolean) {
syncCoordinator.syncDelta(retainRemovedPlaylists)
}
override fun observePlaylists(): Flow<List<PlaylistSummary>> =
dao.observePlaylistsWithImages().map { rows -> rows.map { it.toSummary() } }
override suspend fun getPlaylists(): List<PlaylistSummary> =
withContext(ioDispatcher) {
dao.getPlaylistsWithImages().map { it.toSummary() }
}
override fun observeTracks(playlistId: String): Flow<List<TrackRow>> =
dao.observeTracksForPlaylist(playlistId)
override suspend fun getTracks(playlistId: String): List<TrackRow> =
withContext(ioDispatcher) {
dao.getTracksForPlaylist(playlistId)
}
companion object {
fun create(
context: Context,
authInterceptor: Interceptor,
baseUrl: String = "https://api.lockstep.at/",
ioDispatcher: CoroutineContext = Dispatchers.IO,
): DefaultPlaylistRepository {
val normalizedBase = baseUrl.trim().trimEnd('/') + "/"
val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
val okHttp = okhttp3.OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(normalizedBase)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
val retrofitApi = retrofit.create(PlaylistRetrofitApi::class.java)
val remote = PlaylistRemoteClient(retrofitApi)
val db = JukeboxDatabase.create(context.applicationContext)
val sync = SyncCoordinator(db.playlistDao(), remote, ioDispatcher)
return DefaultPlaylistRepository(db.playlistDao(), sync, ioDispatcher)
}
}
}
object Jukebox {
fun playlistRepository(
context: Context,
authInterceptor: Interceptor,
baseUrl: String = "https://api.lockstep.at/",
ioDispatcher: CoroutineContext = Dispatchers.IO,
): PlaylistRepository =
DefaultPlaylistRepository.create(context, authInterceptor, baseUrl, ioDispatcher)
}

View File

@@ -0,0 +1,43 @@
package at.lockstep.jukebox
import at.lockstep.jukebox.db.PlaylistWithImages
import kotlinx.coroutines.flow.Flow
data class PlaylistSummary(
val id: String,
val name: String,
val description: String?,
val primaryColor: String?,
val snapshotId: String,
val tracksTotal: Int?,
val imageUrls: List<String>,
)
typealias TrackRow = at.lockstep.jukebox.db.TrackRow
interface PlaylistRepository {
suspend fun syncInitial()
suspend fun syncDelta(retainRemovedPlaylists: Boolean)
fun observePlaylists(): Flow<List<PlaylistSummary>>
suspend fun getPlaylists(): List<PlaylistSummary>
fun observeTracks(playlistId: String): Flow<List<TrackRow>>
suspend fun getTracks(playlistId: String): List<TrackRow>
}
internal fun PlaylistWithImages.toSummary(): PlaylistSummary {
val sortedImages = images.sortedBy { it.image_index }
return PlaylistSummary(
id = playlist.id,
name = playlist.name,
description = playlist.description,
primaryColor = playlist.primary_color,
snapshotId = playlist.snapshot_id,
tracksTotal = playlist.tracks_total,
imageUrls = sortedImages.map { it.url },
)
}

View File

@@ -0,0 +1,4 @@
package at.lockstep.jukebox.api
/** Thrown when the Lockstep API returns `ok: false` or an unexpected payload. */
class LockstepApiException(message: String) : Exception(message)

View File

@@ -0,0 +1,24 @@
package at.lockstep.jukebox.api
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
internal class PlaylistRemoteClient(
private val api: PlaylistRetrofitApi,
) {
suspend fun fetchPlaylistSummaries(): List<SimplifiedPlaylistDto> {
val body = api.getPlaylists()
if (!body.ok) {
throw LockstepApiException(body.error ?: "playlists request failed")
}
return body.items.orEmpty()
}
suspend fun fetchPlaylistDetail(id: String): FullPlaylistDto {
val body = api.getPlaylist(id)
if (!body.ok) {
throw LockstepApiException(body.error ?: "playlist detail failed for $id")
}
return body.playlist ?: throw LockstepApiException("playlist missing in response for $id")
}
}

View File

@@ -0,0 +1,14 @@
package at.lockstep.jukebox.api
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse
import at.lockstep.jukebox.api.dto.PlaylistListResponse
import retrofit2.http.GET
import retrofit2.http.Path
internal interface PlaylistRetrofitApi {
@GET("playlists")
suspend fun getPlaylists(): PlaylistListResponse
@GET("playlists/{id}")
suspend fun getPlaylist(@Path("id") id: String): PlaylistDetailResponse
}

View File

@@ -0,0 +1,79 @@
package at.lockstep.jukebox.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistListResponse(
val ok: Boolean,
val error: String? = null,
val total: Int? = null,
val items: List<SimplifiedPlaylistDto>? = null,
)
@Serializable
data class PlaylistDetailResponse(
val ok: Boolean,
val error: String? = null,
val playlist: FullPlaylistDto? = null,
)
@Serializable
data class ImageDto(
val url: String,
val height: Int? = null,
val width: Int? = null,
)
@Serializable
data class ArtistDto(
val id: String? = null,
val name: String? = null,
)
@Serializable
data class TrackDto(
val id: String,
val name: String,
val duration_ms: Int,
val artists: List<ArtistDto> = emptyList(),
)
@Serializable
data class PlaylistTrackItemDto(
val track: TrackDto? = null,
)
@Serializable
data class TracksPageDto(
val href: String? = null,
val total: Int? = null,
val items: List<PlaylistTrackItemDto> = emptyList(),
)
@Serializable
data class TracksStubDto(
val href: String? = null,
val total: Int? = null,
)
@Serializable
data class SimplifiedPlaylistDto(
val id: String,
val name: String,
val description: String? = null,
val images: List<ImageDto> = emptyList(),
val primary_color: String? = null,
val snapshot_id: String,
val tracks: TracksStubDto? = null,
)
@Serializable
data class FullPlaylistDto(
val id: String,
val name: String,
val description: String? = null,
val images: List<ImageDto> = emptyList(),
val primary_color: String? = null,
val snapshot_id: String,
val tracks: TracksPageDto? = null,
)

View File

@@ -0,0 +1,27 @@
package at.lockstep.jukebox.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
PlaylistEntity::class,
PlaylistImageEntity::class,
TrackEntity::class,
PlaylistTrackEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class JukeboxDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao
companion object {
fun create(context: Context, name: String = "jukebox.db"): JukeboxDatabase =
Room.databaseBuilder(context.applicationContext, JukeboxDatabase::class.java, name)
.fallbackToDestructiveMigration()
.build()
}
}

View File

@@ -0,0 +1,147 @@
package at.lockstep.jukebox.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface PlaylistDao {
@Upsert
suspend fun upsertPlaylists(playlists: List<PlaylistEntity>)
@Upsert
suspend fun upsertPlaylist(playlist: PlaylistEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertImages(images: List<PlaylistImageEntity>)
@Query("DELETE FROM playlist_images WHERE playlist_id = :playlistId")
suspend fun deleteImagesForPlaylist(playlistId: String)
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId")
suspend fun deletePlaylistTracksForPlaylist(playlistId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPlaylistTracks(rows: List<PlaylistTrackEntity>)
@Upsert
suspend fun upsertTracks(tracks: List<TrackEntity>)
@Transaction
@Query(
"""
SELECT * FROM playlists
ORDER BY name COLLATE NOCASE ASC
""",
)
fun observePlaylistsWithImages(): Flow<List<PlaylistWithImages>>
@Transaction
@Query(
"""
SELECT * FROM playlists
ORDER BY name COLLATE NOCASE ASC
""",
)
suspend fun getPlaylistsWithImages(): List<PlaylistWithImages>
@Query(
"""
SELECT pt.position AS position, pt.track_id AS trackId,
t.track_name AS trackName, t.artist_name AS artistName,
t.duration_ms AS durationMs
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
WHERE pt.playlist_id = :playlistId
ORDER BY pt.position ASC
""",
)
fun observeTracksForPlaylist(playlistId: String): Flow<List<TrackRow>>
@Query(
"""
SELECT pt.position AS position, pt.track_id AS trackId,
t.track_name AS trackName, t.artist_name AS artistName,
t.duration_ms AS durationMs
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
WHERE pt.playlist_id = :playlistId
ORDER BY pt.position ASC
""",
)
suspend fun getTracksForPlaylist(playlistId: String): List<TrackRow>
@Query("SELECT id, snapshot_id FROM playlists")
suspend fun getPlaylistSnapshots(): List<PlaylistSnapshotRow>
@Query("DELETE FROM playlists WHERE id IN (:ids)")
suspend fun deletePlaylistsByIds(ids: List<String>)
/**
* Removes [TrackEntity] rows that are not referenced by any [PlaylistTrackEntity].track_id.
* Run after removing playlists when you want a compact cache; skipped when retaining
* playlists removed on Spotify so those rows can keep referencing tracks.
*/
@Query(
"""
DELETE FROM tracks WHERE id NOT IN (
SELECT DISTINCT track_id FROM playlist_tracks WHERE track_id IS NOT NULL
)
""",
)
suspend fun deleteOrphanTracks()
@Query("DELETE FROM playlist_tracks")
suspend fun clearPlaylistTracks()
@Query("DELETE FROM playlist_images")
suspend fun clearImages()
@Query("DELETE FROM tracks")
suspend fun clearTracks()
@Query("DELETE FROM playlists")
suspend fun clearPlaylists()
@Transaction
suspend fun clearAllTables() {
clearPlaylistTracks()
clearImages()
clearTracks()
clearPlaylists()
}
/**
* Replaces images, track membership rows, and upserts tracks for one playlist in one transaction.
*/
@Transaction
suspend fun replacePlaylistContent(
playlist: PlaylistEntity,
images: List<PlaylistImageEntity>,
tracks: List<TrackEntity>,
playlistTracks: List<PlaylistTrackEntity>,
) {
upsertPlaylist(playlist)
deleteImagesForPlaylist(playlist.id)
if (images.isNotEmpty()) {
insertImages(images)
}
deletePlaylistTracksForPlaylist(playlist.id)
if (tracks.isNotEmpty()) {
upsertTracks(tracks)
}
if (playlistTracks.isNotEmpty()) {
insertPlaylistTracks(playlistTracks)
}
}
}
data class PlaylistSnapshotRow(
val id: String,
val snapshot_id: String,
)

View File

@@ -0,0 +1,71 @@
package at.lockstep.jukebox.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "playlists")
data class PlaylistEntity(
@PrimaryKey val id: String,
val description: String?,
val name: String,
val primary_color: String?,
val snapshot_id: String,
val tracks_href: String?,
val tracks_total: Int?,
)
@Entity(
tableName = "playlist_images",
primaryKeys = ["playlist_id", "image_index"],
foreignKeys = [
ForeignKey(
entity = PlaylistEntity::class,
parentColumns = ["id"],
childColumns = ["playlist_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("playlist_id")],
)
data class PlaylistImageEntity(
val playlist_id: String,
val image_index: Int,
val url: String,
val height: Int?,
val width: Int?,
)
@Entity(tableName = "tracks")
data class TrackEntity(
@PrimaryKey val id: String,
val track_name: String,
val artist_name: String,
val duration_ms: Int,
)
@Entity(
tableName = "playlist_tracks",
primaryKeys = ["playlist_id", "position"],
foreignKeys = [
ForeignKey(
entity = PlaylistEntity::class,
parentColumns = ["id"],
childColumns = ["playlist_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TrackEntity::class,
parentColumns = ["id"],
childColumns = ["track_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [Index("track_id")],
)
data class PlaylistTrackEntity(
val playlist_id: String,
val position: Int,
val track_id: String?,
)

View File

@@ -0,0 +1,14 @@
package at.lockstep.jukebox.db
import androidx.room.Embedded
import androidx.room.Relation
data class PlaylistWithImages(
@Embedded val playlist: PlaylistEntity,
@Relation(
parentColumn = "id",
entityColumn = "playlist_id",
entity = PlaylistImageEntity::class,
)
val images: List<PlaylistImageEntity>,
)

View File

@@ -0,0 +1,10 @@
package at.lockstep.jukebox.db
/** One row in an ordered playlist listing (join of [PlaylistTrackEntity] and [TrackEntity]). */
data class TrackRow(
val position: Int,
val trackId: String?,
val trackName: String?,
val artistName: String?,
val durationMs: Int?,
)

View File

@@ -0,0 +1,56 @@
package at.lockstep.jukebox.map
import at.lockstep.jukebox.api.dto.ArtistDto
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.ImageDto
import at.lockstep.jukebox.db.PlaylistEntity
import at.lockstep.jukebox.db.PlaylistImageEntity
import at.lockstep.jukebox.db.PlaylistTrackEntity
import at.lockstep.jukebox.db.TrackEntity
internal fun List<ArtistDto>.toArtistDisplayName(): String {
val names = mapNotNull { dto -> dto.name?.takeIf { it.isNotBlank() } }
return if (names.isEmpty()) "Unknown Artist" else names.joinToString(", ")
}
internal fun ImageDto.toEntity(playlistId: String, index: Int): PlaylistImageEntity =
PlaylistImageEntity(
playlist_id = playlistId,
image_index = index,
url = url,
height = height,
width = width,
)
internal fun FullPlaylistDto.toPlaylistEntity(): PlaylistEntity =
PlaylistEntity(
id = id,
description = description,
name = name,
primary_color = primary_color,
snapshot_id = snapshot_id,
tracks_href = tracks?.href,
tracks_total = tracks?.total,
)
/** Maps a full playlist into rows for SQLite (playlist row was upserted separately if needed). */
internal fun FullPlaylistDto.toPlaylistStorageRows(): Triple<List<PlaylistImageEntity>, List<TrackEntity>, List<PlaylistTrackEntity>> {
val images = images.mapIndexed { index, dto -> dto.toEntity(id, index) }
val items = tracks?.items.orEmpty()
val trackEntities = items.mapNotNull { it.track }.map { dto ->
TrackEntity(
id = dto.id,
track_name = dto.name,
artist_name = dto.artists.toArtistDisplayName(),
duration_ms = dto.duration_ms,
)
}.distinctBy { it.id }
val playlistTrackEntities = items.mapIndexed { index, wrapper ->
PlaylistTrackEntity(
playlist_id = id,
position = index,
track_id = wrapper.track?.id,
)
}
return Triple(images, trackEntities, playlistTrackEntities)
}

View File

@@ -0,0 +1,70 @@
package at.lockstep.jukebox.sync
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.db.PlaylistDao
import at.lockstep.jukebox.map.toPlaylistEntity
import at.lockstep.jukebox.map.toPlaylistStorageRows
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
internal class SyncCoordinator(
private val dao: PlaylistDao,
private val remote: PlaylistRemoteClient,
private val ioDispatcher: CoroutineContext,
private val detailParallelism: Int = 6,
) {
suspend fun syncInitial(): Unit = withContext(ioDispatcher) {
dao.clearAllTables()
val summaries = remote.fetchPlaylistSummaries()
val details = fetchDetailsParallel(summaries.map { it.id })
for (detail in details) {
persistFullPlaylist(detail)
}
}
suspend fun syncDelta(retainRemovedPlaylists: Boolean): Unit = withContext(ioDispatcher) {
val remoteSummaries = remote.fetchPlaylistSummaries()
val localSnapshots = dao.getPlaylistSnapshots().associate { it.id to it.snapshot_id }
val idsToRefresh = remoteSummaries
.filter { summary ->
localSnapshots[summary.id] != summary.snapshot_id
}
.map { it.id }
val details = fetchDetailsParallel(idsToRefresh)
for (detail in details) {
persistFullPlaylist(detail)
}
if (!retainRemovedPlaylists) {
val remoteIds = remoteSummaries.map { it.id }.toSet()
val removedLocally = localSnapshots.keys.filter { it !in remoteIds }
if (removedLocally.isNotEmpty()) {
dao.deletePlaylistsByIds(removedLocally)
dao.deleteOrphanTracks()
}
}
}
private suspend fun fetchDetailsParallel(ids: List<String>): List<FullPlaylistDto> {
if (ids.isEmpty()) return emptyList()
return coroutineScope {
val semaphore = Semaphore(detailParallelism)
ids.map { id ->
async {
semaphore.withPermit { remote.fetchPlaylistDetail(id) }
}
}.awaitAll()
}
}
private suspend fun persistFullPlaylist(detail: FullPlaylistDto) {
val playlist = detail.toPlaylistEntity()
val (images, tracks, playlistTracks) = detail.toPlaylistStorageRows()
dao.replacePlaylistContent(playlist, images, tracks, playlistTracks)
}
}

View File

@@ -0,0 +1,172 @@
package at.lockstep.jukebox.sync
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import at.lockstep.jukebox.api.LockstepApiException
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.PlaylistRetrofitApi
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.ImageDto
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse
import at.lockstep.jukebox.api.dto.PlaylistListResponse
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
import at.lockstep.jukebox.api.dto.TrackDto
import at.lockstep.jukebox.api.dto.TracksPageDto
import at.lockstep.jukebox.db.JukeboxDatabase
import at.lockstep.jukebox.map.toPlaylistEntity
import at.lockstep.jukebox.map.toPlaylistStorageRows
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class SyncCoordinatorTest {
private lateinit var db: JukeboxDatabase
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Application>()
db = Room.inMemoryDatabaseBuilder(context, JukeboxDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun syncDelta_sameSnapshot_skipsDetailFetch() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "snap1"),
)
remote.detailById["p1"] = detailWithTrack("p1", "snap1", "t1", "Song")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.detailCallIds.clear()
coordinator.syncDelta(retainRemovedPlaylists = true)
assertTrue(remote.detailCallIds.isEmpty())
}
@Test
fun syncDelta_changedSnapshot_refetchesDetail() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
remote.detailById["p1"] = detailWithTrack("p1", "s1", "t1", "Old")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s2"),
)
remote.detailById["p1"] = detailWithTrack("p1", "s2", "t2", "New")
remote.detailCallIds.clear()
coordinator.syncDelta(retainRemovedPlaylists = true)
assertEquals(listOf("p1"), remote.detailCallIds)
val tracks = db.playlistDao().getTracksForPlaylist("p1")
assertEquals("t2", tracks.single().trackId)
assertEquals("New", tracks.single().trackName)
}
@Test
fun syncDelta_removesPlaylistWhenNotRetained() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
SimplifiedPlaylistDto(id = "gone", name = "B", snapshot_id = "sg"),
)
remote.detailById["p1"] = minimalDetail("p1", "s1")
remote.detailById["gone"] = minimalDetail("gone", "sg")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
coordinator.syncDelta(retainRemovedPlaylists = false)
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
assertEquals(setOf("p1"), ids)
}
@Test
fun syncDelta_retainsRemovedPlaylist() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
remote.detailById["p1"] = minimalDetail("p1", "s1")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
val orphan = minimalDetail("orphan", "so")
val (im, tr, pt) = orphan.toPlaylistStorageRows()
db.playlistDao().replacePlaylistContent(orphan.toPlaylistEntity(), im, tr, pt)
coordinator.syncDelta(retainRemovedPlaylists = true)
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
assertTrue(ids.contains("orphan"))
assertTrue(ids.contains("p1"))
}
@Test(expected = LockstepApiException::class)
fun listOkFalse_throws() = runBlocking {
val remote = FakeRemote()
remote.listResponseOverride = PlaylistListResponse(ok = false, error = "nope")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
}
private fun detailWithTrack(
id: String,
snapshot: String,
trackId: String,
trackName: String,
) = FullPlaylistDto(
id = id,
name = id,
snapshot_id = snapshot,
images = listOf(ImageDto(url = "https://x.example/a.png")),
tracks = TracksPageDto(
items = listOf(
PlaylistTrackItemDto(
track = TrackDto(id = trackId, name = trackName, duration_ms = 1000),
),
),
),
)
private fun minimalDetail(id: String, snapshot: String) = detailWithTrack(id, snapshot, "t$id", "Song")
private class FakeRemote {
val detailCallIds = mutableListOf<String>()
var listItems: List<SimplifiedPlaylistDto> = emptyList()
val detailById = mutableMapOf<String, FullPlaylistDto>()
var listResponseOverride: PlaylistListResponse? = null
val api: PlaylistRetrofitApi = object : PlaylistRetrofitApi {
override suspend fun getPlaylists(): PlaylistListResponse =
listResponseOverride ?: PlaylistListResponse(
ok = true,
total = listItems.size,
items = listItems,
)
override suspend fun getPlaylist(id: String): PlaylistDetailResponse {
detailCallIds.add(id)
val pl = detailById[id] ?: error("no detail for $id")
return PlaylistDetailResponse(ok = true, playlist = pl)
}
}
}
}