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) - } - } - } -}