diff --git a/build.gradle.kts b/build.gradle.kts
index 936cb62..e3a4cf8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,3 @@
plugins {
id("com.android.library") version "8.7.2" apply false
- id("org.jetbrains.kotlin.android") version "2.0.21" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
- id("com.google.devtools.ksp") version "2.0.21-1.0.25" apply false
}
diff --git a/jukebox/build.gradle.kts b/jukebox/build.gradle.kts
index c662d46..3a94700 100644
--- a/jukebox/build.gradle.kts
+++ b/jukebox/build.gradle.kts
@@ -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")
}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.java b/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.java
new file mode 100644
index 0000000..7999a09
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.java
@@ -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}.
+ *
+ * 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.
+ *
+ * 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> observePlaylists() {
+ return Transformations.map(
+ dao.observePlaylistsWithImages(),
+ rows -> {
+ if (rows == null) {
+ return Collections.emptyList();
+ }
+ List out = new ArrayList<>(rows.size());
+ for (PlaylistWithImages r : rows) {
+ out.add(toSummary(r));
+ }
+ return out;
+ }
+ );
+ }
+
+ @Override
+ @NonNull
+ public List getPlaylists() {
+ List rows = dao.getPlaylistsWithImages();
+ List out = new ArrayList<>(rows.size());
+ for (PlaylistWithImages r : rows) {
+ out.add(toSummary(r));
+ }
+ return out;
+ }
+
+ @Override
+ @NonNull
+ public LiveData> observeTracks(@NonNull String playlistId) {
+ return dao.observeTracksForPlaylist(playlistId);
+ }
+
+ @Override
+ @NonNull
+ public List getTracks(@NonNull String playlistId) {
+ return dao.getTracksForPlaylist(playlistId);
+ }
+
+ @NonNull
+ private static PlaylistSummary toSummary(@NonNull PlaylistWithImages row) {
+ List images = row.getImages();
+ if (images == null) {
+ images = Collections.emptyList();
+ }
+ List sorted = new ArrayList<>(images);
+ sorted.sort((a, b) -> Integer.compare(a.getImageIndex(), b.getImageIndex()));
+ List 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);
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.kt b/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.kt
deleted file mode 100644
index e0e9f87..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/DefaultPlaylistRepository.kt
+++ /dev/null
@@ -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> =
- dao.observePlaylistsWithImages().map { rows -> rows.map { it.toSummary() } }
-
- override suspend fun getPlaylists(): List =
- withContext(ioDispatcher) {
- dao.getPlaylistsWithImages().map { it.toSummary() }
- }
-
- override fun observeTracks(playlistId: String): Flow> =
- dao.observeTracksForPlaylist(playlistId)
-
- override suspend fun getTracks(playlistId: String): List =
- 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)
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/Jukebox.java b/jukebox/src/main/java/at/lockstep/jukebox/Jukebox.java
new file mode 100644
index 0000000..675c923
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/Jukebox.java
@@ -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);
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.java b/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.java
new file mode 100644
index 0000000..5069cec
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.java
@@ -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> observePlaylists();
+
+ @NonNull
+ List getPlaylists();
+
+ @NonNull
+ LiveData> observeTracks(@NonNull String playlistId);
+
+ @NonNull
+ List getTracks(@NonNull String playlistId);
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.kt b/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.kt
deleted file mode 100644
index 23d1d52..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/PlaylistRepository.kt
+++ /dev/null
@@ -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,
-)
-
-typealias TrackRow = at.lockstep.jukebox.db.TrackRow
-
-interface PlaylistRepository {
- suspend fun syncInitial()
-
- suspend fun syncDelta(retainRemovedPlaylists: Boolean)
-
- fun observePlaylists(): Flow>
-
- suspend fun getPlaylists(): List
-
- fun observeTracks(playlistId: String): Flow>
-
- suspend fun getTracks(playlistId: String): List
-}
-
-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 },
- )
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/PlaylistSummary.java b/jukebox/src/main/java/at/lockstep/jukebox/PlaylistSummary.java
new file mode 100644
index 0000000..82ac6cb
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/PlaylistSummary.java
@@ -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 imageUrls;
+
+ public PlaylistSummary(
+ @NonNull String id,
+ @NonNull String name,
+ @Nullable String description,
+ @Nullable String primaryColor,
+ @NonNull String snapshotId,
+ @Nullable Integer tracksTotal,
+ @NonNull List imageUrls
+ ) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ this.primaryColor = primaryColor;
+ this.snapshotId = snapshotId;
+ this.tracksTotal = tracksTotal;
+ this.imageUrls = imageUrls;
+ }
+
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.java b/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.java
new file mode 100644
index 0000000..322a56a
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.java
@@ -0,0 +1,7 @@
+package at.lockstep.jukebox.api;
+
+public class LockstepApiException extends Exception {
+ public LockstepApiException(String message) {
+ super(message);
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.kt b/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.kt
deleted file mode 100644
index 004e323..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepApiException.kt
+++ /dev/null
@@ -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)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepPlaylistClient.java b/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepPlaylistClient.java
new file mode 100644
index 0000000..591df9f
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/LockstepPlaylistClient.java
@@ -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 fetchPlaylistSummaries() throws IOException, LockstepApiException;
+
+ @NonNull
+ FullPlaylistDto fetchPlaylistDetail(@NonNull String id) throws IOException, LockstepApiException;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.java b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.java
new file mode 100644
index 0000000..9d9415d
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.java
@@ -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 fetchPlaylistSummaries() throws IOException, LockstepApiException {
+ Response 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 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;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.kt b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.kt
deleted file mode 100644
index 59116f3..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRemoteClient.kt
+++ /dev/null
@@ -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 {
- 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")
- }
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.java b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.java
new file mode 100644
index 0000000..9ce0a52
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.java
@@ -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 getPlaylists();
+
+ @GET("playlists/{id}")
+ Call getPlaylist(@Path("id") String id);
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.kt b/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.kt
deleted file mode 100644
index 99b5dc7..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/api/PlaylistRetrofitApi.kt
+++ /dev/null
@@ -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
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ArtistDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ArtistDto.java
new file mode 100644
index 0000000..f69b83a
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ArtistDto.java
@@ -0,0 +1,6 @@
+package at.lockstep.jukebox.api.dto;
+
+public class ArtistDto {
+ public String id;
+ public String name;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/FullPlaylistDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/FullPlaylistDto.java
new file mode 100644
index 0000000..736085d
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/FullPlaylistDto.java
@@ -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 images;
+ public String primaryColor;
+ public String snapshotId;
+ public TracksPageDto tracks;
+
+ public List imagesOrEmpty() {
+ return images != null ? images : Collections.emptyList();
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ImageDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ImageDto.java
new file mode 100644
index 0000000..aa5940a
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/ImageDto.java
@@ -0,0 +1,7 @@
+package at.lockstep.jukebox.api.dto;
+
+public class ImageDto {
+ public String url;
+ public Integer height;
+ public Integer width;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDetailResponse.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDetailResponse.java
new file mode 100644
index 0000000..243c901
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDetailResponse.java
@@ -0,0 +1,7 @@
+package at.lockstep.jukebox.api.dto;
+
+public class PlaylistDetailResponse {
+ public boolean ok;
+ public String error;
+ public FullPlaylistDto playlist;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDtos.kt b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDtos.kt
deleted file mode 100644
index 5815c6c..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistDtos.kt
+++ /dev/null
@@ -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? = 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 = emptyList(),
-)
-
-@Serializable
-data class PlaylistTrackItemDto(
- val track: TrackDto? = null,
-)
-
-@Serializable
-data class TracksPageDto(
- val href: String? = null,
- val total: Int? = null,
- val items: List = 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 = 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 = emptyList(),
- val primary_color: String? = null,
- val snapshot_id: String,
- val tracks: TracksPageDto? = null,
-)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistListResponse.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistListResponse.java
new file mode 100644
index 0000000..66ce4d0
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistListResponse.java
@@ -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 items;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistTrackItemDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistTrackItemDto.java
new file mode 100644
index 0000000..d5c1cc0
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/PlaylistTrackItemDto.java
@@ -0,0 +1,5 @@
+package at.lockstep.jukebox.api.dto;
+
+public class PlaylistTrackItemDto {
+ public TrackDto track;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/SimplifiedPlaylistDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/SimplifiedPlaylistDto.java
new file mode 100644
index 0000000..8461d67
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/SimplifiedPlaylistDto.java
@@ -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 images;
+ public String primaryColor;
+ public String snapshotId;
+ public TracksStubDto tracks;
+
+ public List imagesOrEmpty() {
+ return images != null ? images : Collections.emptyList();
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TrackDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TrackDto.java
new file mode 100644
index 0000000..e81606d
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TrackDto.java
@@ -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 artists;
+
+ public List artistsOrEmpty() {
+ return artists != null ? artists : Collections.emptyList();
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksPageDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksPageDto.java
new file mode 100644
index 0000000..c417053
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksPageDto.java
@@ -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 items;
+
+ public List itemsOrEmpty() {
+ return items != null ? items : Collections.emptyList();
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksStubDto.java b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksStubDto.java
new file mode 100644
index 0000000..b591843
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/api/dto/TracksStubDto.java
@@ -0,0 +1,6 @@
+package at.lockstep.jukebox.api.dto;
+
+public class TracksStubDto {
+ public String href;
+ public Integer total;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.java b/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.java
new file mode 100644
index 0000000..4e43fe3
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.java
@@ -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();
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.kt b/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.kt
deleted file mode 100644
index 1e00ca5..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/db/JukeboxDatabase.kt
+++ /dev/null
@@ -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()
- }
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.java
new file mode 100644
index 0000000..b4f39ac
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.java
@@ -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 images,
+ @NonNull List tracks,
+ @NonNull List 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 playlists);
+
+ @Upsert
+ public abstract void upsertPlaylist(@NonNull PlaylistEntity playlist);
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ public abstract void insertImages(@NonNull List 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 rows);
+
+ @Upsert
+ public abstract void upsertTracks(@NonNull List tracks);
+
+ @Transaction
+ @Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
+ public abstract LiveData> observePlaylistsWithImages();
+
+ @Transaction
+ @Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
+ public abstract List 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> 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 getTracksForPlaylist(@NonNull String playlistId);
+
+ @Query("SELECT id, snapshot_id FROM playlists")
+ public abstract List getPlaylistSnapshots();
+
+ @Query("DELETE FROM playlists WHERE id IN (:ids)")
+ public abstract void deletePlaylistsByIds(@NonNull List 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();
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.kt b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.kt
deleted file mode 100644
index 640bcb4..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.kt
+++ /dev/null
@@ -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)
-
- @Upsert
- suspend fun upsertPlaylist(playlist: PlaylistEntity)
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertImages(images: List)
-
- @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)
-
- @Upsert
- suspend fun upsertTracks(tracks: List)
-
- @Transaction
- @Query(
- """
- SELECT * FROM playlists
- ORDER BY name COLLATE NOCASE ASC
- """,
- )
- fun observePlaylistsWithImages(): Flow>
-
- @Transaction
- @Query(
- """
- SELECT * FROM playlists
- ORDER BY name COLLATE NOCASE ASC
- """,
- )
- suspend fun getPlaylistsWithImages(): List
-
- @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>
-
- @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
-
- @Query("SELECT id, snapshot_id FROM playlists")
- suspend fun getPlaylistSnapshots(): List
-
- @Query("DELETE FROM playlists WHERE id IN (:ids)")
- suspend fun deletePlaylistsByIds(ids: List)
-
- /**
- * 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,
- tracks: List,
- playlistTracks: List,
- ) {
- 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,
-)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.java
new file mode 100644
index 0000000..7210f86
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.java
@@ -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;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.kt b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.kt
deleted file mode 100644
index eb2d3ff..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.kt
+++ /dev/null
@@ -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?,
-)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistImageEntity.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistImageEntity.java
new file mode 100644
index 0000000..6f4be2b
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistImageEntity.java
@@ -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;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistSnapshotRow.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistSnapshotRow.java
new file mode 100644
index 0000000..be30dcc
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistSnapshotRow.java
@@ -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;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistTrackEntity.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistTrackEntity.java
new file mode 100644
index 0000000..a7cd78d
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistTrackEntity.java
@@ -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;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.java b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.java
new file mode 100644
index 0000000..28160ae
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.java
@@ -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 images;
+
+ public PlaylistEntity getPlaylist() {
+ return playlist;
+ }
+
+ public void setPlaylist(PlaylistEntity playlist) {
+ this.playlist = playlist;
+ }
+
+ public List getImages() {
+ return images;
+ }
+
+ public void setImages(List images) {
+ this.images = images;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.kt b/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.kt
deleted file mode 100644
index ba3f50c..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistWithImages.kt
+++ /dev/null
@@ -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,
-)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/TrackEntity.java b/jukebox/src/main/java/at/lockstep/jukebox/db/TrackEntity.java
new file mode 100644
index 0000000..a4927c7
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/TrackEntity.java
@@ -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;
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.java b/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.java
new file mode 100644
index 0000000..3e6a475
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.java
@@ -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;
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.kt b/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.kt
deleted file mode 100644
index 08e9045..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.kt
+++ /dev/null
@@ -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?,
-)
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.java b/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.java
new file mode 100644
index 0000000..0c6343d
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.java
@@ -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 artists) {
+ List 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 images = new ArrayList<>();
+ List imageDtos = dto.imagesOrEmpty();
+ for (int i = 0; i < imageDtos.size(); i++) {
+ images.add(toImageEntity(imageDtos.get(i), dto.id, i));
+ }
+ List items = dto.tracks != null ? dto.tracks.itemsOrEmpty() : new ArrayList<>();
+
+ Map 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 trackEntities = new ArrayList<>(trackById.values());
+
+ List 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 images;
+ @NonNull
+ public final List tracks;
+ @NonNull
+ public final List playlistTracks;
+
+ public PlaylistStorageRows(
+ @NonNull List images,
+ @NonNull List tracks,
+ @NonNull List playlistTracks
+ ) {
+ this.images = images;
+ this.tracks = tracks;
+ this.playlistTracks = playlistTracks;
+ }
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.kt b/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.kt
deleted file mode 100644
index 47d7e7a..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/map/PlaylistMappers.kt
+++ /dev/null
@@ -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.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, List> {
- 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)
-}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.java b/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.java
new file mode 100644
index 0000000..e18d31b
--- /dev/null
+++ b/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.java
@@ -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.
+ *
+ * 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 summaries = remote.fetchPlaylistSummaries();
+ List ids = new ArrayList<>(summaries.size());
+ for (SimplifiedPlaylistDto s : summaries) {
+ ids.add(s.id);
+ }
+ List 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 remoteSummaries = remote.fetchPlaylistSummaries();
+ Map localSnapshots = new HashMap<>();
+ for (PlaylistSnapshotRow row : dao.getPlaylistSnapshots()) {
+ localSnapshots.put(row.id, row.snapshotId);
+ }
+ List 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 details = fetchDetailsParallel(idsToRefresh);
+ for (FullPlaylistDto detail : details) {
+ persistFullPlaylist(detail);
+ }
+ if (!retainRemovedPlaylists) {
+ Set remoteIds = new HashSet<>();
+ for (SimplifiedPlaylistDto s : remoteSummaries) {
+ remoteIds.add(s.id);
+ }
+ List 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 fetchDetailsParallel(@NonNull List ids)
+ throws IOException, LockstepApiException {
+ if (ids.isEmpty()) {
+ return new ArrayList<>();
+ }
+ ExecutorService pool = Executors.newFixedThreadPool(detailParallelism);
+ try {
+ List> futures = new ArrayList<>(ids.size());
+ for (String id : ids) {
+ futures.add(pool.submit(() -> remote.fetchPlaylistDetail(id)));
+ }
+ List out = new ArrayList<>(ids.size());
+ try {
+ for (Future 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
+ );
+ }
+}
diff --git a/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.kt b/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.kt
deleted file mode 100644
index 2bd76b2..0000000
--- a/jukebox/src/main/java/at/lockstep/jukebox/sync/SyncCoordinator.kt
+++ /dev/null
@@ -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): List {
- 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)
- }
-}
diff --git a/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.java b/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.java
new file mode 100644
index 0000000..98d3ab6
--- /dev/null
+++ b/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.java
@@ -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 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 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 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 snapshotIds() {
+ List 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 detailCallIds = new ArrayList<>();
+ final List listItems = new ArrayList<>();
+ final Map details = new HashMap<>();
+ String failListWithMessage;
+
+ @Override
+ public List 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;
+ }
+ }
+}
diff --git a/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.kt b/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.kt
deleted file mode 100644
index 0e64ebe..0000000
--- a/jukebox/src/test/java/at/lockstep/jukebox/sync/SyncCoordinatorTest.kt
+++ /dev/null
@@ -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()
- 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()
- var listItems: List = emptyList()
- val detailById = mutableMapOf()
- 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)
- }
- }
- }
-}