chore: port to Java using AI

This commit is contained in:
2026-05-13 20:17:49 +02:00
parent 2b778b4583
commit 8ef0cebfa9
46 changed files with 1585 additions and 835 deletions

View File

@@ -1,8 +1,5 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
@@ -19,30 +16,24 @@ android {
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")
implementation("androidx.lifecycle:lifecycle-livedata:2.8.7")
annotationProcessor("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")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.google.code.gson:gson:2.11.0")
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

@@ -0,0 +1,177 @@
package at.lockstep.jukebox;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Transformations;
import at.lockstep.jukebox.api.LockstepApiException;
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.db.PlaylistImageEntity;
import at.lockstep.jukebox.db.PlaylistWithImages;
import at.lockstep.jukebox.db.TrackRow;
import at.lockstep.jukebox.sync.SyncCoordinator;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import okhttp3.Interceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
/**
* Default implementation of {@link PlaylistRepository}: reads cached playlists and tracks from Room,
* and runs network sync through {@link SyncCoordinator}.
* <p>
* Sync methods block the calling thread and perform I/O; call them from a background executor, not
* the UI thread. {@link #observePlaylists()} and {@link #observeTracks(String)} return
* {@link LiveData} for lifecycle-safe UI observation; {@link #getPlaylists()} and
* {@link #getTracks(String)} issue one-off queries and also block.
* <p>
* Prefer {@link #create(Context, Interceptor)} (or {@link Jukebox#playlistRepository(Context, Interceptor)})
* so Retrofit, Gson, OkHttp, and the database are wired consistently.
*/
public final class DefaultPlaylistRepository implements PlaylistRepository {
private final PlaylistDao dao;
private final SyncCoordinator syncCoordinator;
/**
* @param dao room DAO for playlist cache tables
* @param syncCoordinator coordinates API fetch and DB writes (usually shares {@code dao}'s database)
*/
public DefaultPlaylistRepository(@NonNull PlaylistDao dao, @NonNull SyncCoordinator syncCoordinator) {
this.dao = dao;
this.syncCoordinator = syncCoordinator;
}
@Override
public void syncInitial() throws IOException, LockstepApiException {
syncCoordinator.syncInitial();
}
@Override
public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException {
syncCoordinator.syncDelta(retainRemovedPlaylists);
}
@Override
@NonNull
public LiveData<List<PlaylistSummary>> observePlaylists() {
return Transformations.map(
dao.observePlaylistsWithImages(),
rows -> {
if (rows == null) {
return Collections.emptyList();
}
List<PlaylistSummary> out = new ArrayList<>(rows.size());
for (PlaylistWithImages r : rows) {
out.add(toSummary(r));
}
return out;
}
);
}
@Override
@NonNull
public List<PlaylistSummary> getPlaylists() {
List<PlaylistWithImages> rows = dao.getPlaylistsWithImages();
List<PlaylistSummary> out = new ArrayList<>(rows.size());
for (PlaylistWithImages r : rows) {
out.add(toSummary(r));
}
return out;
}
@Override
@NonNull
public LiveData<List<TrackRow>> observeTracks(@NonNull String playlistId) {
return dao.observeTracksForPlaylist(playlistId);
}
@Override
@NonNull
public List<TrackRow> getTracks(@NonNull String playlistId) {
return dao.getTracksForPlaylist(playlistId);
}
@NonNull
private static PlaylistSummary toSummary(@NonNull PlaylistWithImages row) {
List<PlaylistImageEntity> images = row.getImages();
if (images == null) {
images = Collections.emptyList();
}
List<PlaylistImageEntity> sorted = new ArrayList<>(images);
sorted.sort((a, b) -> Integer.compare(a.getImageIndex(), b.getImageIndex()));
List<String> urls = new ArrayList<>(sorted.size());
for (PlaylistImageEntity img : sorted) {
urls.add(img.getUrl());
}
return new PlaylistSummary(
row.getPlaylist().getId(),
row.getPlaylist().getName(),
row.getPlaylist().getDescription(),
row.getPlaylist().getPrimaryColor(),
row.getPlaylist().getSnapshotId(),
row.getPlaylist().getTracksTotal(),
urls
);
}
/**
* Builds a repository with the default Lockstep API base URL ({@code https://api.lockstep.at/}).
*
* @param context application or activity context (used for the Room DB path)
* @param authInterceptor attaches session cookies, bearer tokens, or other auth required by the API
*/
@NonNull
public static DefaultPlaylistRepository create(
@NonNull Context context,
@NonNull Interceptor authInterceptor
) {
return create(context, authInterceptor, "https://api.lockstep.at/");
}
/**
* Builds a repository with a custom API base URL (trailing slashes normalized to a single {@code /}).
*
* @param context application or activity context (used for the Room DB path)
* @param authInterceptor attaches session cookies, bearer tokens, or other auth required by the API
* @param baseUrl Retrofit base URL, e.g. {@code https://api.lockstep.at/}
*/
@NonNull
public static DefaultPlaylistRepository create(
@NonNull Context context,
@NonNull Interceptor authInterceptor,
@NonNull String baseUrl
) {
String normalized = baseUrl.trim().replaceAll("/+$", "") + "/";
Gson gson = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create();
okhttp3.OkHttpClient okHttp = new okhttp3.OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(normalized)
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create(gson))
.build();
PlaylistRetrofitApi retrofitApi = retrofit.create(PlaylistRetrofitApi.class);
PlaylistRemoteClient remote = new PlaylistRemoteClient(retrofitApi);
JukeboxDatabase db = JukeboxDatabase.create(context.getApplicationContext());
SyncCoordinator sync = new SyncCoordinator(db.playlistDao(), remote);
return new DefaultPlaylistRepository(db.playlistDao(), sync);
}
}

View File

@@ -1,88 +0,0 @@
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,30 @@
package at.lockstep.jukebox;
import android.content.Context;
import androidx.annotation.NonNull;
import okhttp3.Interceptor;
public final class Jukebox {
private Jukebox() {
}
@NonNull
public static PlaylistRepository playlistRepository(
@NonNull Context context,
@NonNull Interceptor authInterceptor
) {
return DefaultPlaylistRepository.create(context, authInterceptor);
}
@NonNull
public static PlaylistRepository playlistRepository(
@NonNull Context context,
@NonNull Interceptor authInterceptor,
@NonNull String baseUrl
) {
return DefaultPlaylistRepository.create(context, authInterceptor, baseUrl);
}
}

View File

@@ -0,0 +1,30 @@
package at.lockstep.jukebox;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import at.lockstep.jukebox.db.TrackRow;
import at.lockstep.jukebox.api.LockstepApiException;
import java.io.IOException;
import java.util.List;
public interface PlaylistRepository {
void syncInitial() throws IOException, LockstepApiException;
void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException;
@NonNull
LiveData<List<PlaylistSummary>> observePlaylists();
@NonNull
List<PlaylistSummary> getPlaylists();
@NonNull
LiveData<List<TrackRow>> observeTracks(@NonNull String playlistId);
@NonNull
List<TrackRow> getTracks(@NonNull String playlistId);
}

View File

@@ -1,43 +0,0 @@
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,44 @@
package at.lockstep.jukebox;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Collections;
import java.util.List;
public final class PlaylistSummary {
@NonNull
public final String id;
@NonNull
public final String name;
@Nullable
public final String description;
@Nullable
public final String primaryColor;
@NonNull
public final String snapshotId;
@Nullable
public final Integer tracksTotal;
@NonNull
public final List<String> imageUrls;
public PlaylistSummary(
@NonNull String id,
@NonNull String name,
@Nullable String description,
@Nullable String primaryColor,
@NonNull String snapshotId,
@Nullable Integer tracksTotal,
@NonNull List<String> imageUrls
) {
this.id = id;
this.name = name;
this.description = description;
this.primaryColor = primaryColor;
this.snapshotId = snapshotId;
this.tracksTotal = tracksTotal;
this.imageUrls = imageUrls;
}
}

View File

@@ -0,0 +1,7 @@
package at.lockstep.jukebox.api;
public class LockstepApiException extends Exception {
public LockstepApiException(String message) {
super(message);
}
}

View File

@@ -1,4 +0,0 @@
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,18 @@
package at.lockstep.jukebox.api;
import androidx.annotation.NonNull;
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import java.io.IOException;
import java.util.List;
public interface LockstepPlaylistClient {
@NonNull
List<SimplifiedPlaylistDto> fetchPlaylistSummaries() throws IOException, LockstepApiException;
@NonNull
FullPlaylistDto fetchPlaylistDetail(@NonNull String id) throws IOException, LockstepApiException;
}

View File

@@ -0,0 +1,60 @@
package at.lockstep.jukebox.api;
import androidx.annotation.NonNull;
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse;
import at.lockstep.jukebox.api.dto.PlaylistListResponse;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import retrofit2.Response;
public final class PlaylistRemoteClient implements LockstepPlaylistClient {
private final PlaylistRetrofitApi api;
public PlaylistRemoteClient(@NonNull PlaylistRetrofitApi api) {
this.api = api;
}
@Override
@NonNull
public List<SimplifiedPlaylistDto> fetchPlaylistSummaries() throws IOException, LockstepApiException {
Response<PlaylistListResponse> resp = api.getPlaylists().execute();
if (!resp.isSuccessful()) {
throw new IOException("playlists HTTP " + resp.code());
}
PlaylistListResponse body = resp.body();
if (body == null) {
throw new IOException("playlists empty body");
}
if (!body.ok) {
throw new LockstepApiException(body.error != null ? body.error : "playlists request failed");
}
return body.items != null ? body.items : Collections.emptyList();
}
@Override
@NonNull
public FullPlaylistDto fetchPlaylistDetail(@NonNull String id) throws IOException, LockstepApiException {
Response<PlaylistDetailResponse> resp = api.getPlaylist(id).execute();
if (!resp.isSuccessful()) {
throw new IOException("playlist detail HTTP " + resp.code() + " for " + id);
}
PlaylistDetailResponse body = resp.body();
if (body == null) {
throw new IOException("playlist detail empty body for " + id);
}
if (!body.ok) {
throw new LockstepApiException(body.error != null ? body.error : ("playlist detail failed for " + id));
}
if (body.playlist == null) {
throw new LockstepApiException("playlist missing in response for " + id);
}
return body.playlist;
}
}

View File

@@ -1,24 +0,0 @@
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,16 @@
package at.lockstep.jukebox.api;
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse;
import at.lockstep.jukebox.api.dto.PlaylistListResponse;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Path;
public interface PlaylistRetrofitApi {
@GET("playlists")
Call<PlaylistListResponse> getPlaylists();
@GET("playlists/{id}")
Call<PlaylistDetailResponse> getPlaylist(@Path("id") String id);
}

View File

@@ -1,14 +0,0 @@
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,6 @@
package at.lockstep.jukebox.api.dto;
public class ArtistDto {
public String id;
public String name;
}

View File

@@ -0,0 +1,18 @@
package at.lockstep.jukebox.api.dto;
import java.util.Collections;
import java.util.List;
public class FullPlaylistDto {
public String id;
public String name;
public String description;
public List<ImageDto> images;
public String primaryColor;
public String snapshotId;
public TracksPageDto tracks;
public List<ImageDto> imagesOrEmpty() {
return images != null ? images : Collections.emptyList();
}
}

View File

@@ -0,0 +1,7 @@
package at.lockstep.jukebox.api.dto;
public class ImageDto {
public String url;
public Integer height;
public Integer width;
}

View File

@@ -0,0 +1,7 @@
package at.lockstep.jukebox.api.dto;
public class PlaylistDetailResponse {
public boolean ok;
public String error;
public FullPlaylistDto playlist;
}

View File

@@ -1,79 +0,0 @@
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,10 @@
package at.lockstep.jukebox.api.dto;
import java.util.List;
public class PlaylistListResponse {
public boolean ok;
public String error;
public Integer total;
public List<SimplifiedPlaylistDto> items;
}

View File

@@ -0,0 +1,5 @@
package at.lockstep.jukebox.api.dto;
public class PlaylistTrackItemDto {
public TrackDto track;
}

View File

@@ -0,0 +1,18 @@
package at.lockstep.jukebox.api.dto;
import java.util.Collections;
import java.util.List;
public class SimplifiedPlaylistDto {
public String id;
public String name;
public String description;
public List<ImageDto> images;
public String primaryColor;
public String snapshotId;
public TracksStubDto tracks;
public List<ImageDto> imagesOrEmpty() {
return images != null ? images : Collections.emptyList();
}
}

View File

@@ -0,0 +1,15 @@
package at.lockstep.jukebox.api.dto;
import java.util.Collections;
import java.util.List;
public class TrackDto {
public String id;
public String name;
public int durationMs;
public List<ArtistDto> artists;
public List<ArtistDto> artistsOrEmpty() {
return artists != null ? artists : Collections.emptyList();
}
}

View File

@@ -0,0 +1,14 @@
package at.lockstep.jukebox.api.dto;
import java.util.Collections;
import java.util.List;
public class TracksPageDto {
public String href;
public Integer total;
public List<PlaylistTrackItemDto> items;
public List<PlaylistTrackItemDto> itemsOrEmpty() {
return items != null ? items : Collections.emptyList();
}
}

View File

@@ -0,0 +1,6 @@
package at.lockstep.jukebox.api.dto;
public class TracksStubDto {
public String href;
public Integer total;
}

View File

@@ -0,0 +1,35 @@
package at.lockstep.jukebox.db;
import android.content.Context;
import androidx.annotation.NonNull;
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
)
public abstract class JukeboxDatabase extends RoomDatabase {
public abstract PlaylistDao playlistDao();
@NonNull
public static JukeboxDatabase create(@NonNull Context context) {
return create(context, "jukebox.db");
}
@NonNull
public static JukeboxDatabase create(@NonNull Context context, @NonNull String name) {
return Room.databaseBuilder(context.getApplicationContext(), JukeboxDatabase.class, name)
.fallbackToDestructiveMigration()
.build();
}
}

View File

@@ -1,27 +0,0 @@
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,119 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
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 java.util.List;
@Dao
public abstract class PlaylistDao {
@Transaction
public void clearAllTables() {
clearPlaylistTracks();
clearImages();
clearTracks();
clearPlaylists();
}
/**
* Replaces images, track membership rows, and upserts tracks for one playlist in one transaction.
*/
@Transaction
public void replacePlaylistContent(
@NonNull PlaylistEntity playlist,
@NonNull List<PlaylistImageEntity> images,
@NonNull List<TrackEntity> tracks,
@NonNull List<PlaylistTrackEntity> playlistTracks
) {
upsertPlaylist(playlist);
deleteImagesForPlaylist(playlist.getId());
if (!images.isEmpty()) {
insertImages(images);
}
deletePlaylistTracksForPlaylist(playlist.getId());
if (!tracks.isEmpty()) {
upsertTracks(tracks);
}
if (!playlistTracks.isEmpty()) {
insertPlaylistTracks(playlistTracks);
}
}
@Upsert
public abstract void upsertPlaylists(@NonNull List<PlaylistEntity> playlists);
@Upsert
public abstract void upsertPlaylist(@NonNull PlaylistEntity playlist);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insertImages(@NonNull List<PlaylistImageEntity> images);
@Query("DELETE FROM playlist_images WHERE playlist_id = :playlistId")
public abstract void deleteImagesForPlaylist(@NonNull String playlistId);
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId")
public abstract void deletePlaylistTracksForPlaylist(@NonNull String playlistId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void insertPlaylistTracks(@NonNull List<PlaylistTrackEntity> rows);
@Upsert
public abstract void upsertTracks(@NonNull List<TrackEntity> tracks);
@Transaction
@Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
public abstract LiveData<List<PlaylistWithImages>> observePlaylistsWithImages();
@Transaction
@Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
public abstract List<PlaylistWithImages> getPlaylistsWithImages();
@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")
public abstract LiveData<List<TrackRow>> observeTracksForPlaylist(@NonNull String playlistId);
@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")
public abstract List<TrackRow> getTracksForPlaylist(@NonNull String playlistId);
@Query("SELECT id, snapshot_id FROM playlists")
public abstract List<PlaylistSnapshotRow> getPlaylistSnapshots();
@Query("DELETE FROM playlists WHERE id IN (:ids)")
public abstract void deletePlaylistsByIds(@NonNull List<String> ids);
/**
* Removes tracks not referenced by any playlist_tracks row.
*/
@Query("DELETE FROM tracks WHERE id NOT IN (SELECT DISTINCT track_id FROM playlist_tracks WHERE track_id IS NOT NULL)")
public abstract void deleteOrphanTracks();
@Query("DELETE FROM playlist_tracks")
public abstract void clearPlaylistTracks();
@Query("DELETE FROM playlist_images")
public abstract void clearImages();
@Query("DELETE FROM tracks")
public abstract void clearTracks();
@Query("DELETE FROM playlists")
public abstract void clearPlaylists();
}

View File

@@ -1,147 +0,0 @@
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,118 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "playlists")
public class PlaylistEntity {
@PrimaryKey
@NonNull
private String id;
@Nullable
private String description;
@NonNull
private String name;
@Nullable
@ColumnInfo(name = "primary_color")
private String primaryColor;
@NonNull
@ColumnInfo(name = "snapshot_id")
private String snapshotId;
@Nullable
@ColumnInfo(name = "tracks_href")
private String tracksHref;
@Nullable
@ColumnInfo(name = "tracks_total")
private Integer tracksTotal;
public PlaylistEntity(
@NonNull String id,
@Nullable String description,
@NonNull String name,
@Nullable String primaryColor,
@NonNull String snapshotId,
@Nullable String tracksHref,
@Nullable Integer tracksTotal
) {
this.id = id;
this.description = description;
this.name = name;
this.primaryColor = primaryColor;
this.snapshotId = snapshotId;
this.tracksHref = tracksHref;
this.tracksTotal = tracksTotal;
}
@NonNull
public String getId() {
return id;
}
public void setId(@NonNull String id) {
this.id = id;
}
@Nullable
public String getDescription() {
return description;
}
public void setDescription(@Nullable String description) {
this.description = description;
}
@NonNull
public String getName() {
return name;
}
public void setName(@NonNull String name) {
this.name = name;
}
@Nullable
public String getPrimaryColor() {
return primaryColor;
}
public void setPrimaryColor(@Nullable String primaryColor) {
this.primaryColor = primaryColor;
}
@NonNull
public String getSnapshotId() {
return snapshotId;
}
public void setSnapshotId(@NonNull String snapshotId) {
this.snapshotId = snapshotId;
}
@Nullable
public String getTracksHref() {
return tracksHref;
}
public void setTracksHref(@Nullable String tracksHref) {
this.tracksHref = tracksHref;
}
@Nullable
public Integer getTracksTotal() {
return tracksTotal;
}
public void setTracksTotal(@Nullable Integer tracksTotal) {
this.tracksTotal = tracksTotal;
}
}

View File

@@ -1,71 +0,0 @@
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,98 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
@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")}
)
public class PlaylistImageEntity {
@NonNull
@ColumnInfo(name = "playlist_id")
private String playlistId;
@ColumnInfo(name = "image_index")
private int imageIndex;
@NonNull
private String url;
@Nullable
private Integer height;
@Nullable
private Integer width;
public PlaylistImageEntity(
@NonNull String playlistId,
int imageIndex,
@NonNull String url,
@Nullable Integer height,
@Nullable Integer width
) {
this.playlistId = playlistId;
this.imageIndex = imageIndex;
this.url = url;
this.height = height;
this.width = width;
}
@NonNull
public String getPlaylistId() {
return playlistId;
}
public void setPlaylistId(@NonNull String playlistId) {
this.playlistId = playlistId;
}
public int getImageIndex() {
return imageIndex;
}
public void setImageIndex(int imageIndex) {
this.imageIndex = imageIndex;
}
@NonNull
public String getUrl() {
return url;
}
public void setUrl(@NonNull String url) {
this.url = url;
}
@Nullable
public Integer getHeight() {
return height;
}
public void setHeight(@Nullable Integer height) {
this.height = height;
}
@Nullable
public Integer getWidth() {
return width;
}
public void setWidth(@Nullable Integer width) {
this.width = width;
}
}

View File

@@ -0,0 +1,18 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
/**
* Playlist id and snapshot for delta sync decisions.
*/
public class PlaylistSnapshotRow {
@NonNull
public String id;
@NonNull
@ColumnInfo(name = "snapshot_id")
public String snapshotId;
}

View File

@@ -0,0 +1,72 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
@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")}
)
public class PlaylistTrackEntity {
@NonNull
@ColumnInfo(name = "playlist_id")
private String playlistId;
private int position;
@Nullable
@ColumnInfo(name = "track_id")
private String trackId;
public PlaylistTrackEntity(@NonNull String playlistId, int position, @Nullable String trackId) {
this.playlistId = playlistId;
this.position = position;
this.trackId = trackId;
}
@NonNull
public String getPlaylistId() {
return playlistId;
}
public void setPlaylistId(@NonNull String playlistId) {
this.playlistId = playlistId;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
@Nullable
public String getTrackId() {
return trackId;
}
public void setTrackId(@Nullable String trackId) {
this.trackId = trackId;
}
}

View File

@@ -0,0 +1,35 @@
package at.lockstep.jukebox.db;
import androidx.room.Embedded;
import androidx.room.Relation;
import java.util.List;
public class PlaylistWithImages {
@Embedded
private PlaylistEntity playlist;
@Relation(
parentColumn = "id",
entityColumn = "playlist_id",
entity = PlaylistImageEntity.class
)
private List<PlaylistImageEntity> images;
public PlaylistEntity getPlaylist() {
return playlist;
}
public void setPlaylist(PlaylistEntity playlist) {
this.playlist = playlist;
}
public List<PlaylistImageEntity> getImages() {
return images;
}
public void setImages(List<PlaylistImageEntity> images) {
this.images = images;
}
}

View File

@@ -1,14 +0,0 @@
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,67 @@
package at.lockstep.jukebox.db;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "tracks")
public class TrackEntity {
@PrimaryKey
@NonNull
private String id;
@NonNull
@ColumnInfo(name = "track_name")
private String trackName;
@NonNull
@ColumnInfo(name = "artist_name")
private String artistName;
@ColumnInfo(name = "duration_ms")
private int durationMs;
public TrackEntity(@NonNull String id, @NonNull String trackName, @NonNull String artistName, int durationMs) {
this.id = id;
this.trackName = trackName;
this.artistName = artistName;
this.durationMs = durationMs;
}
@NonNull
public String getId() {
return id;
}
public void setId(@NonNull String id) {
this.id = id;
}
@NonNull
public String getTrackName() {
return trackName;
}
public void setTrackName(@NonNull String trackName) {
this.trackName = trackName;
}
@NonNull
public String getArtistName() {
return artistName;
}
public void setArtistName(@NonNull String artistName) {
this.artistName = artistName;
}
public int getDurationMs() {
return durationMs;
}
public void setDurationMs(int durationMs) {
this.durationMs = durationMs;
}
}

View File

@@ -0,0 +1,23 @@
package at.lockstep.jukebox.db;
import androidx.annotation.Nullable;
/**
* One row in an ordered playlist listing (join of playlist_tracks and tracks).
*/
public class TrackRow {
public int position;
@Nullable
public String trackId;
@Nullable
public String trackName;
@Nullable
public String artistName;
@Nullable
public Integer durationMs;
}

View File

@@ -1,10 +0,0 @@
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,140 @@
package at.lockstep.jukebox.map;
import androidx.annotation.NonNull;
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.api.dto.PlaylistTrackItemDto;
import at.lockstep.jukebox.api.dto.TrackDto;
import at.lockstep.jukebox.db.PlaylistEntity;
import at.lockstep.jukebox.db.PlaylistImageEntity;
import at.lockstep.jukebox.db.PlaylistTrackEntity;
import at.lockstep.jukebox.db.TrackEntity;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Pure mapping helpers from Lockstep/Spotify-shaped API DTOs ({@link FullPlaylistDto}, etc.) to
* {@link androidx.room.Room} entities. Used by {@link at.lockstep.jukebox.sync.SyncCoordinator} when
* persisting a full playlist response.
*/
public final class PlaylistMappers {
private PlaylistMappers() {
}
/**
* Builds a single display string for track artists (comma-separated; {@code "Unknown Artist"} if none).
*/
@NonNull
public static String artistDisplayName(@NonNull List<ArtistDto> artists) {
List<String> names = new ArrayList<>();
for (ArtistDto dto : artists) {
if (dto.name != null && !dto.name.trim().isEmpty()) {
names.add(dto.name);
}
}
if (names.isEmpty()) {
return "Unknown Artist";
}
return String.join(", ", names);
}
/**
* Converts one Spotify image object into a {@link PlaylistImageEntity} for the given playlist and index.
*/
@NonNull
public static PlaylistImageEntity toImageEntity(@NonNull ImageDto dto, @NonNull String playlistId, int index) {
return new PlaylistImageEntity(
playlistId,
index,
dto.url,
dto.height,
dto.width
);
}
/**
* Maps the playlist metadata from a full playlist response to a {@link PlaylistEntity} row.
*/
@NonNull
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) {
String tracksHref = dto.tracks != null ? dto.tracks.href : null;
Integer tracksTotal = dto.tracks != null ? dto.tracks.total : null;
return new PlaylistEntity(
dto.id,
dto.description,
dto.name,
dto.primaryColor,
dto.snapshotId,
tracksHref,
tracksTotal
);
}
/**
* Flattens a full playlist payload into the rows needed for one transactional replace: cover images,
* deduplicated {@link TrackEntity} rows, and ordered {@link PlaylistTrackEntity} rows (including
* {@code null} track ids for removed playlist items).
*
* @return images, unique tracks, and playlist track membership in API order
*/
@NonNull
public static PlaylistStorageRows toPlaylistStorageRows(@NonNull FullPlaylistDto dto) {
List<PlaylistImageEntity> images = new ArrayList<>();
List<ImageDto> imageDtos = dto.imagesOrEmpty();
for (int i = 0; i < imageDtos.size(); i++) {
images.add(toImageEntity(imageDtos.get(i), dto.id, i));
}
List<PlaylistTrackItemDto> items = dto.tracks != null ? dto.tracks.itemsOrEmpty() : new ArrayList<>();
Map<String, TrackEntity> trackById = new LinkedHashMap<>();
for (PlaylistTrackItemDto wrapper : items) {
if (wrapper.track != null) {
TrackDto t = wrapper.track;
TrackEntity entity = new TrackEntity(
t.id,
t.name,
artistDisplayName(t.artistsOrEmpty()),
t.durationMs
);
trackById.put(entity.getId(), entity);
}
}
List<TrackEntity> trackEntities = new ArrayList<>(trackById.values());
List<PlaylistTrackEntity> playlistTrackEntities = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
PlaylistTrackItemDto wrapper = items.get(i);
String tid = wrapper.track != null ? wrapper.track.id : null;
playlistTrackEntities.add(new PlaylistTrackEntity(dto.id, i, tid));
}
return new PlaylistStorageRows(images, trackEntities, playlistTrackEntities);
}
/**
* Bundles the three lists written together when replacing one playlist's cache content.
*/
public static final class PlaylistStorageRows {
@NonNull
public final List<PlaylistImageEntity> images;
@NonNull
public final List<TrackEntity> tracks;
@NonNull
public final List<PlaylistTrackEntity> playlistTracks;
public PlaylistStorageRows(
@NonNull List<PlaylistImageEntity> images,
@NonNull List<TrackEntity> tracks,
@NonNull List<PlaylistTrackEntity> playlistTracks
) {
this.images = images;
this.tracks = tracks;
this.playlistTracks = playlistTracks;
}
}
}

View File

@@ -1,56 +0,0 @@
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,172 @@
package at.lockstep.jukebox.sync;
import androidx.annotation.NonNull;
import at.lockstep.jukebox.api.LockstepApiException;
import at.lockstep.jukebox.api.LockstepPlaylistClient;
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import at.lockstep.jukebox.db.PlaylistDao;
import at.lockstep.jukebox.db.PlaylistSnapshotRow;
import at.lockstep.jukebox.map.PlaylistMappers;
import at.lockstep.jukebox.map.PlaylistMappers.PlaylistStorageRows;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* Orchestrates loading playlist metadata from {@link LockstepPlaylistClient} and writing it into
* {@link PlaylistDao}: full initial import (clear + fetch all details) and incremental sync using
* {@code snapshot_id} from the playlist list endpoint. Individual playlists are always replaced
* wholesale (images, memberships, and tracks for that playlist) inside a single DAO transaction.
* <p>
* Network detail fetches run in parallel up to the pool size passed to
* {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)}; methods block until finished and
* must not be called on the Android main thread.
*/
public final class SyncCoordinator {
private final PlaylistDao dao;
private final LockstepPlaylistClient remote;
private final int detailParallelism;
/**
* @param detailParallelism maximum concurrent calls to {@link LockstepPlaylistClient#fetchPlaylistDetail(String)}
*/
public SyncCoordinator(
@NonNull PlaylistDao dao,
@NonNull LockstepPlaylistClient remote,
int detailParallelism
) {
this.dao = dao;
this.remote = remote;
this.detailParallelism = detailParallelism;
}
/**
* Same as {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)} with parallel detail
* concurrency of {@code 6}.
*/
public SyncCoordinator(@NonNull PlaylistDao dao, @NonNull LockstepPlaylistClient remote) {
this(dao, remote, 6);
}
/**
* Clears all local cache tables, fetches every playlist id from the list endpoint, loads each full
* playlist, and persists them. Use for first-time or “reset” sync.
*/
public void syncInitial() throws IOException, LockstepApiException {
dao.clearAllTables();
List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries();
List<String> ids = new ArrayList<>(summaries.size());
for (SimplifiedPlaylistDto s : summaries) {
ids.add(s.id);
}
List<FullPlaylistDto> details = fetchDetailsParallel(ids);
for (FullPlaylistDto detail : details) {
persistFullPlaylist(detail);
}
}
/**
* Fetches the current playlist list, compares each {@code snapshot_id} to the local DB, and refetches
* full detail only for new or changed playlists. Playlists missing from the remote list are deleted
* locally (including cascading images and track links) only when {@code retainRemovedPlaylists} is
* {@code false}, in which case orphan {@link at.lockstep.jukebox.db.TrackEntity} rows are pruned.
*/
public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException {
List<SimplifiedPlaylistDto> remoteSummaries = remote.fetchPlaylistSummaries();
Map<String, String> localSnapshots = new HashMap<>();
for (PlaylistSnapshotRow row : dao.getPlaylistSnapshots()) {
localSnapshots.put(row.id, row.snapshotId);
}
List<String> idsToRefresh = new ArrayList<>();
for (SimplifiedPlaylistDto summary : remoteSummaries) {
String local = localSnapshots.get(summary.id);
if (local == null || !local.equals(summary.snapshotId)) {
idsToRefresh.add(summary.id);
}
}
List<FullPlaylistDto> details = fetchDetailsParallel(idsToRefresh);
for (FullPlaylistDto detail : details) {
persistFullPlaylist(detail);
}
if (!retainRemovedPlaylists) {
Set<String> remoteIds = new HashSet<>();
for (SimplifiedPlaylistDto s : remoteSummaries) {
remoteIds.add(s.id);
}
List<String> removedLocally = new ArrayList<>();
for (String localId : localSnapshots.keySet()) {
if (!remoteIds.contains(localId)) {
removedLocally.add(localId);
}
}
if (!removedLocally.isEmpty()) {
dao.deletePlaylistsByIds(removedLocally);
dao.deleteOrphanTracks();
}
}
}
/**
* Loads full playlist JSON for each id using a bounded thread pool; preserves input order in the result.
*/
@NonNull
private List<FullPlaylistDto> fetchDetailsParallel(@NonNull List<String> ids)
throws IOException, LockstepApiException {
if (ids.isEmpty()) {
return new ArrayList<>();
}
ExecutorService pool = Executors.newFixedThreadPool(detailParallelism);
try {
List<Future<FullPlaylistDto>> futures = new ArrayList<>(ids.size());
for (String id : ids) {
futures.add(pool.submit(() -> remote.fetchPlaylistDetail(id)));
}
List<FullPlaylistDto> out = new ArrayList<>(ids.size());
try {
for (Future<FullPlaylistDto> f : futures) {
out.add(f.get());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
} catch (ExecutionException e) {
Throwable c = e.getCause();
if (c instanceof IOException) {
throw (IOException) c;
}
if (c instanceof LockstepApiException) {
throw (LockstepApiException) c;
}
throw new IOException(c != null ? c : e);
}
return out;
} finally {
pool.shutdown();
}
}
/**
* Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist.
*/
private void persistFullPlaylist(@NonNull FullPlaylistDto detail) {
PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail);
dao.replacePlaylistContent(
PlaylistMappers.toPlaylistEntity(detail),
rows.images,
rows.tracks,
rows.playlistTracks
);
}
}

View File

@@ -1,70 +0,0 @@
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,196 @@
package at.lockstep.jukebox.sync;
import android.app.Application;
import androidx.room.Room;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import at.lockstep.jukebox.api.LockstepApiException;
import at.lockstep.jukebox.api.LockstepPlaylistClient;
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.ImageDto;
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.db.TrackRow;
import at.lockstep.jukebox.map.PlaylistMappers;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34, application = Application.class)
public class SyncCoordinatorTest {
private JukeboxDatabase db;
@Before
public void setup() {
Application context = ApplicationProvider.getApplicationContext();
db = Room.inMemoryDatabaseBuilder(context, JukeboxDatabase.class)
.allowMainThreadQueries()
.build();
}
@After
public void tearDown() {
db.close();
}
@Test
public void syncDelta_sameSnapshot_skipsDetailFetch() throws Exception {
FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "snap1"));
remote.details.put("p1", detailWithTrack("p1", "snap1", "t1", "Song"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
remote.detailCallIds.clear();
coordinator.syncDelta(true);
Assert.assertTrue(remote.detailCallIds.isEmpty());
}
@Test
public void syncDelta_changedSnapshot_refetchesDetail() throws Exception {
FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1"));
remote.details.put("p1", detailWithTrack("p1", "s1", "t1", "Old"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
remote.listItems.clear();
remote.listItems.add(simplified("p1", "s2"));
remote.details.put("p1", detailWithTrack("p1", "s2", "t2", "New"));
remote.detailCallIds.clear();
coordinator.syncDelta(true);
Assert.assertEquals(Collections.singletonList("p1"), remote.detailCallIds);
List<TrackRow> tracks = db.playlistDao().getTracksForPlaylist("p1");
Assert.assertEquals(1, tracks.size());
Assert.assertEquals("t2", tracks.get(0).trackId);
Assert.assertEquals("New", tracks.get(0).trackName);
}
@Test
public void syncDelta_removesPlaylistWhenNotRetained() throws Exception {
FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1"));
remote.listItems.add(simplified("gone", "sg"));
remote.details.put("p1", minimalDetail("p1", "s1"));
remote.details.put("gone", minimalDetail("gone", "sg"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
remote.listItems.clear();
remote.listItems.add(simplified("p1", "s1"));
coordinator.syncDelta(false);
List<String> ids = snapshotIds();
Assert.assertEquals(new HashSet<>(Collections.singletonList("p1")), new HashSet<>(ids));
}
@Test
public void syncDelta_retainsRemovedPlaylist() throws Exception {
FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1"));
remote.details.put("p1", minimalDetail("p1", "s1"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
FullPlaylistDto orphan = minimalDetail("orphan", "so");
PlaylistMappers.PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(orphan);
db.playlistDao().replacePlaylistContent(
PlaylistMappers.toPlaylistEntity(orphan),
rows.images,
rows.tracks,
rows.playlistTracks
);
coordinator.syncDelta(true);
List<String> ids = snapshotIds();
Assert.assertTrue(ids.contains("orphan"));
Assert.assertTrue(ids.contains("p1"));
}
@Test(expected = LockstepApiException.class)
public void listOkFalse_throws() throws Exception {
FakeRemote remote = new FakeRemote();
remote.failListWithMessage = "nope";
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
}
private List<String> snapshotIds() {
List<String> out = new ArrayList<>();
for (at.lockstep.jukebox.db.PlaylistSnapshotRow row : db.playlistDao().getPlaylistSnapshots()) {
out.add(row.id);
}
return out;
}
private static SimplifiedPlaylistDto simplified(String id, String snapshot) {
SimplifiedPlaylistDto d = new SimplifiedPlaylistDto();
d.id = id;
d.name = id;
d.snapshotId = snapshot;
return d;
}
private static FullPlaylistDto detailWithTrack(String id, String snap, String trackId, String trackName) {
FullPlaylistDto d = new FullPlaylistDto();
d.id = id;
d.name = id;
d.snapshotId = snap;
d.images = List.of(new ImageDto());
d.images.get(0).url = "https://x.example/a.png";
TracksPageDto page = new TracksPageDto();
PlaylistTrackItemDto item = new PlaylistTrackItemDto();
TrackDto t = new TrackDto();
t.id = trackId;
t.name = trackName;
t.durationMs = 1000;
item.track = t;
page.items = new ArrayList<>();
page.items.add(item);
d.tracks = page;
return d;
}
private static FullPlaylistDto minimalDetail(String id, String snapshot) {
return detailWithTrack(id, snapshot, "t" + id, "Song");
}
private static final class FakeRemote implements LockstepPlaylistClient {
final List<String> detailCallIds = new ArrayList<>();
final List<SimplifiedPlaylistDto> listItems = new ArrayList<>();
final Map<String, FullPlaylistDto> details = new HashMap<>();
String failListWithMessage;
@Override
public List<SimplifiedPlaylistDto> fetchPlaylistSummaries()
throws IOException, LockstepApiException {
if (failListWithMessage != null) {
throw new LockstepApiException(failListWithMessage);
}
return new ArrayList<>(listItems);
}
@Override
public FullPlaylistDto fetchPlaylistDetail(String id) throws IOException, LockstepApiException {
detailCallIds.add(id);
FullPlaylistDto d = details.get(id);
if (d == null) {
throw new IOException("no detail for " + id);
}
return d;
}
}
}

View File

@@ -1,172 +0,0 @@
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)
}
}
}
}