initial: AI-generated jukebox metadata lib (Cursor's Composer 2)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
147
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.kt
Normal file
147
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.kt
Normal 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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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>,
|
||||
)
|
||||
10
jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.kt
Normal file
10
jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.kt
Normal 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?,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user