chore: port to Java using AI
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package at.lockstep.jukebox;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import at.lockstep.jukebox.api.LockstepApiException;
|
||||
import at.lockstep.jukebox.api.PlaylistRemoteClient;
|
||||
import at.lockstep.jukebox.api.PlaylistRetrofitApi;
|
||||
import at.lockstep.jukebox.db.JukeboxDatabase;
|
||||
import at.lockstep.jukebox.db.PlaylistDao;
|
||||
import at.lockstep.jukebox.db.PlaylistImageEntity;
|
||||
import at.lockstep.jukebox.db.PlaylistWithImages;
|
||||
import at.lockstep.jukebox.db.TrackRow;
|
||||
import at.lockstep.jukebox.sync.SyncCoordinator;
|
||||
|
||||
import com.google.gson.FieldNamingPolicy;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link PlaylistRepository}: reads cached playlists and tracks from Room,
|
||||
* and runs network sync through {@link SyncCoordinator}.
|
||||
* <p>
|
||||
* Sync methods block the calling thread and perform I/O; call them from a background executor, not
|
||||
* the UI thread. {@link #observePlaylists()} and {@link #observeTracks(String)} return
|
||||
* {@link LiveData} for lifecycle-safe UI observation; {@link #getPlaylists()} and
|
||||
* {@link #getTracks(String)} issue one-off queries and also block.
|
||||
* <p>
|
||||
* Prefer {@link #create(Context, Interceptor)} (or {@link Jukebox#playlistRepository(Context, Interceptor)})
|
||||
* so Retrofit, Gson, OkHttp, and the database are wired consistently.
|
||||
*/
|
||||
public final class DefaultPlaylistRepository implements PlaylistRepository {
|
||||
|
||||
private final PlaylistDao dao;
|
||||
private final SyncCoordinator syncCoordinator;
|
||||
|
||||
/**
|
||||
* @param dao room DAO for playlist cache tables
|
||||
* @param syncCoordinator coordinates API fetch and DB writes (usually shares {@code dao}'s database)
|
||||
*/
|
||||
public DefaultPlaylistRepository(@NonNull PlaylistDao dao, @NonNull SyncCoordinator syncCoordinator) {
|
||||
this.dao = dao;
|
||||
this.syncCoordinator = syncCoordinator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncInitial() throws IOException, LockstepApiException {
|
||||
syncCoordinator.syncInitial();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException {
|
||||
syncCoordinator.syncDelta(retainRemovedPlaylists);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public LiveData<List<PlaylistSummary>> observePlaylists() {
|
||||
return Transformations.map(
|
||||
dao.observePlaylistsWithImages(),
|
||||
rows -> {
|
||||
if (rows == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<PlaylistSummary> out = new ArrayList<>(rows.size());
|
||||
for (PlaylistWithImages r : rows) {
|
||||
out.add(toSummary(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public List<PlaylistSummary> getPlaylists() {
|
||||
List<PlaylistWithImages> rows = dao.getPlaylistsWithImages();
|
||||
List<PlaylistSummary> out = new ArrayList<>(rows.size());
|
||||
for (PlaylistWithImages r : rows) {
|
||||
out.add(toSummary(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public LiveData<List<TrackRow>> observeTracks(@NonNull String playlistId) {
|
||||
return dao.observeTracksForPlaylist(playlistId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public List<TrackRow> getTracks(@NonNull String playlistId) {
|
||||
return dao.getTracksForPlaylist(playlistId);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static PlaylistSummary toSummary(@NonNull PlaylistWithImages row) {
|
||||
List<PlaylistImageEntity> images = row.getImages();
|
||||
if (images == null) {
|
||||
images = Collections.emptyList();
|
||||
}
|
||||
List<PlaylistImageEntity> sorted = new ArrayList<>(images);
|
||||
sorted.sort((a, b) -> Integer.compare(a.getImageIndex(), b.getImageIndex()));
|
||||
List<String> urls = new ArrayList<>(sorted.size());
|
||||
for (PlaylistImageEntity img : sorted) {
|
||||
urls.add(img.getUrl());
|
||||
}
|
||||
return new PlaylistSummary(
|
||||
row.getPlaylist().getId(),
|
||||
row.getPlaylist().getName(),
|
||||
row.getPlaylist().getDescription(),
|
||||
row.getPlaylist().getPrimaryColor(),
|
||||
row.getPlaylist().getSnapshotId(),
|
||||
row.getPlaylist().getTracksTotal(),
|
||||
urls
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a repository with the default Lockstep API base URL ({@code https://api.lockstep.at/}).
|
||||
*
|
||||
* @param context application or activity context (used for the Room DB path)
|
||||
* @param authInterceptor attaches session cookies, bearer tokens, or other auth required by the API
|
||||
*/
|
||||
@NonNull
|
||||
public static DefaultPlaylistRepository create(
|
||||
@NonNull Context context,
|
||||
@NonNull Interceptor authInterceptor
|
||||
) {
|
||||
return create(context, authInterceptor, "https://api.lockstep.at/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a repository with a custom API base URL (trailing slashes normalized to a single {@code /}).
|
||||
*
|
||||
* @param context application or activity context (used for the Room DB path)
|
||||
* @param authInterceptor attaches session cookies, bearer tokens, or other auth required by the API
|
||||
* @param baseUrl Retrofit base URL, e.g. {@code https://api.lockstep.at/}
|
||||
*/
|
||||
@NonNull
|
||||
public static DefaultPlaylistRepository create(
|
||||
@NonNull Context context,
|
||||
@NonNull Interceptor authInterceptor,
|
||||
@NonNull String baseUrl
|
||||
) {
|
||||
String normalized = baseUrl.trim().replaceAll("/+$", "") + "/";
|
||||
Gson gson = new GsonBuilder()
|
||||
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||
.create();
|
||||
okhttp3.OkHttpClient okHttp = new okhttp3.OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build();
|
||||
Retrofit retrofit = new Retrofit.Builder()
|
||||
.baseUrl(normalized)
|
||||
.client(okHttp)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build();
|
||||
PlaylistRetrofitApi retrofitApi = retrofit.create(PlaylistRetrofitApi.class);
|
||||
PlaylistRemoteClient remote = new PlaylistRemoteClient(retrofitApi);
|
||||
JukeboxDatabase db = JukeboxDatabase.create(context.getApplicationContext());
|
||||
SyncCoordinator sync = new SyncCoordinator(db.playlistDao(), remote);
|
||||
return new DefaultPlaylistRepository(db.playlistDao(), sync);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package at.lockstep.jukebox
|
||||
|
||||
import android.content.Context
|
||||
import at.lockstep.jukebox.api.PlaylistRemoteClient
|
||||
import at.lockstep.jukebox.api.PlaylistRetrofitApi
|
||||
import at.lockstep.jukebox.db.JukeboxDatabase
|
||||
import at.lockstep.jukebox.db.PlaylistDao
|
||||
import at.lockstep.jukebox.sync.SyncCoordinator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
class DefaultPlaylistRepository internal constructor(
|
||||
private val dao: PlaylistDao,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val ioDispatcher: CoroutineContext,
|
||||
) : PlaylistRepository {
|
||||
|
||||
override suspend fun syncInitial() {
|
||||
syncCoordinator.syncInitial()
|
||||
}
|
||||
|
||||
override suspend fun syncDelta(retainRemovedPlaylists: Boolean) {
|
||||
syncCoordinator.syncDelta(retainRemovedPlaylists)
|
||||
}
|
||||
|
||||
override fun observePlaylists(): Flow<List<PlaylistSummary>> =
|
||||
dao.observePlaylistsWithImages().map { rows -> rows.map { it.toSummary() } }
|
||||
|
||||
override suspend fun getPlaylists(): List<PlaylistSummary> =
|
||||
withContext(ioDispatcher) {
|
||||
dao.getPlaylistsWithImages().map { it.toSummary() }
|
||||
}
|
||||
|
||||
override fun observeTracks(playlistId: String): Flow<List<TrackRow>> =
|
||||
dao.observeTracksForPlaylist(playlistId)
|
||||
|
||||
override suspend fun getTracks(playlistId: String): List<TrackRow> =
|
||||
withContext(ioDispatcher) {
|
||||
dao.getTracksForPlaylist(playlistId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
context: Context,
|
||||
authInterceptor: Interceptor,
|
||||
baseUrl: String = "https://api.lockstep.at/",
|
||||
ioDispatcher: CoroutineContext = Dispatchers.IO,
|
||||
): DefaultPlaylistRepository {
|
||||
val normalizedBase = baseUrl.trim().trimEnd('/') + "/"
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
val okHttp = okhttp3.OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(normalizedBase)
|
||||
.client(okHttp)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
val retrofitApi = retrofit.create(PlaylistRetrofitApi::class.java)
|
||||
val remote = PlaylistRemoteClient(retrofitApi)
|
||||
val db = JukeboxDatabase.create(context.applicationContext)
|
||||
val sync = SyncCoordinator(db.playlistDao(), remote, ioDispatcher)
|
||||
return DefaultPlaylistRepository(db.playlistDao(), sync, ioDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Jukebox {
|
||||
fun playlistRepository(
|
||||
context: Context,
|
||||
authInterceptor: Interceptor,
|
||||
baseUrl: String = "https://api.lockstep.at/",
|
||||
ioDispatcher: CoroutineContext = Dispatchers.IO,
|
||||
): PlaylistRepository =
|
||||
DefaultPlaylistRepository.create(context, authInterceptor, baseUrl, ioDispatcher)
|
||||
}
|
||||
30
jukebox/src/main/java/at/lockstep/jukebox/Jukebox.java
Normal file
30
jukebox/src/main/java/at/lockstep/jukebox/Jukebox.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package at.lockstep.jukebox;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
|
||||
public final class Jukebox {
|
||||
|
||||
private Jukebox() {
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static PlaylistRepository playlistRepository(
|
||||
@NonNull Context context,
|
||||
@NonNull Interceptor authInterceptor
|
||||
) {
|
||||
return DefaultPlaylistRepository.create(context, authInterceptor);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static PlaylistRepository playlistRepository(
|
||||
@NonNull Context context,
|
||||
@NonNull Interceptor authInterceptor,
|
||||
@NonNull String baseUrl
|
||||
) {
|
||||
return DefaultPlaylistRepository.create(context, authInterceptor, baseUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package at.lockstep.jukebox;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import at.lockstep.jukebox.db.TrackRow;
|
||||
|
||||
import at.lockstep.jukebox.api.LockstepApiException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public interface PlaylistRepository {
|
||||
|
||||
void syncInitial() throws IOException, LockstepApiException;
|
||||
|
||||
void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException;
|
||||
|
||||
@NonNull
|
||||
LiveData<List<PlaylistSummary>> observePlaylists();
|
||||
|
||||
@NonNull
|
||||
List<PlaylistSummary> getPlaylists();
|
||||
|
||||
@NonNull
|
||||
LiveData<List<TrackRow>> observeTracks(@NonNull String playlistId);
|
||||
|
||||
@NonNull
|
||||
List<TrackRow> getTracks(@NonNull String playlistId);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package at.lockstep.jukebox
|
||||
|
||||
import at.lockstep.jukebox.db.PlaylistWithImages
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class PlaylistSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val primaryColor: String?,
|
||||
val snapshotId: String,
|
||||
val tracksTotal: Int?,
|
||||
val imageUrls: List<String>,
|
||||
)
|
||||
|
||||
typealias TrackRow = at.lockstep.jukebox.db.TrackRow
|
||||
|
||||
interface PlaylistRepository {
|
||||
suspend fun syncInitial()
|
||||
|
||||
suspend fun syncDelta(retainRemovedPlaylists: Boolean)
|
||||
|
||||
fun observePlaylists(): Flow<List<PlaylistSummary>>
|
||||
|
||||
suspend fun getPlaylists(): List<PlaylistSummary>
|
||||
|
||||
fun observeTracks(playlistId: String): Flow<List<TrackRow>>
|
||||
|
||||
suspend fun getTracks(playlistId: String): List<TrackRow>
|
||||
}
|
||||
|
||||
internal fun PlaylistWithImages.toSummary(): PlaylistSummary {
|
||||
val sortedImages = images.sortedBy { it.image_index }
|
||||
return PlaylistSummary(
|
||||
id = playlist.id,
|
||||
name = playlist.name,
|
||||
description = playlist.description,
|
||||
primaryColor = playlist.primary_color,
|
||||
snapshotId = playlist.snapshot_id,
|
||||
tracksTotal = playlist.tracks_total,
|
||||
imageUrls = sortedImages.map { it.url },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package at.lockstep.jukebox;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class PlaylistSummary {
|
||||
|
||||
@NonNull
|
||||
public final String id;
|
||||
@NonNull
|
||||
public final String name;
|
||||
@Nullable
|
||||
public final String description;
|
||||
@Nullable
|
||||
public final String primaryColor;
|
||||
@NonNull
|
||||
public final String snapshotId;
|
||||
@Nullable
|
||||
public final Integer tracksTotal;
|
||||
@NonNull
|
||||
public final List<String> imageUrls;
|
||||
|
||||
public PlaylistSummary(
|
||||
@NonNull String id,
|
||||
@NonNull String name,
|
||||
@Nullable String description,
|
||||
@Nullable String primaryColor,
|
||||
@NonNull String snapshotId,
|
||||
@Nullable Integer tracksTotal,
|
||||
@NonNull List<String> imageUrls
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.primaryColor = primaryColor;
|
||||
this.snapshotId = snapshotId;
|
||||
this.tracksTotal = tracksTotal;
|
||||
this.imageUrls = imageUrls;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package at.lockstep.jukebox.api;
|
||||
|
||||
public class LockstepApiException extends Exception {
|
||||
public LockstepApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,18 @@
|
||||
package at.lockstep.jukebox.api;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public interface LockstepPlaylistClient {
|
||||
|
||||
@NonNull
|
||||
List<SimplifiedPlaylistDto> fetchPlaylistSummaries() throws IOException, LockstepApiException;
|
||||
|
||||
@NonNull
|
||||
FullPlaylistDto fetchPlaylistDetail(@NonNull String id) throws IOException, LockstepApiException;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package at.lockstep.jukebox.api;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse;
|
||||
import at.lockstep.jukebox.api.dto.PlaylistListResponse;
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import retrofit2.Response;
|
||||
|
||||
public final class PlaylistRemoteClient implements LockstepPlaylistClient {
|
||||
|
||||
private final PlaylistRetrofitApi api;
|
||||
|
||||
public PlaylistRemoteClient(@NonNull PlaylistRetrofitApi api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public List<SimplifiedPlaylistDto> fetchPlaylistSummaries() throws IOException, LockstepApiException {
|
||||
Response<PlaylistListResponse> resp = api.getPlaylists().execute();
|
||||
if (!resp.isSuccessful()) {
|
||||
throw new IOException("playlists HTTP " + resp.code());
|
||||
}
|
||||
PlaylistListResponse body = resp.body();
|
||||
if (body == null) {
|
||||
throw new IOException("playlists empty body");
|
||||
}
|
||||
if (!body.ok) {
|
||||
throw new LockstepApiException(body.error != null ? body.error : "playlists request failed");
|
||||
}
|
||||
return body.items != null ? body.items : Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public FullPlaylistDto fetchPlaylistDetail(@NonNull String id) throws IOException, LockstepApiException {
|
||||
Response<PlaylistDetailResponse> resp = api.getPlaylist(id).execute();
|
||||
if (!resp.isSuccessful()) {
|
||||
throw new IOException("playlist detail HTTP " + resp.code() + " for " + id);
|
||||
}
|
||||
PlaylistDetailResponse body = resp.body();
|
||||
if (body == null) {
|
||||
throw new IOException("playlist detail empty body for " + id);
|
||||
}
|
||||
if (!body.ok) {
|
||||
throw new LockstepApiException(body.error != null ? body.error : ("playlist detail failed for " + id));
|
||||
}
|
||||
if (body.playlist == null) {
|
||||
throw new LockstepApiException("playlist missing in response for " + id);
|
||||
}
|
||||
return body.playlist;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package at.lockstep.jukebox.api
|
||||
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
|
||||
|
||||
internal class PlaylistRemoteClient(
|
||||
private val api: PlaylistRetrofitApi,
|
||||
) {
|
||||
suspend fun fetchPlaylistSummaries(): List<SimplifiedPlaylistDto> {
|
||||
val body = api.getPlaylists()
|
||||
if (!body.ok) {
|
||||
throw LockstepApiException(body.error ?: "playlists request failed")
|
||||
}
|
||||
return body.items.orEmpty()
|
||||
}
|
||||
|
||||
suspend fun fetchPlaylistDetail(id: String): FullPlaylistDto {
|
||||
val body = api.getPlaylist(id)
|
||||
if (!body.ok) {
|
||||
throw LockstepApiException(body.error ?: "playlist detail failed for $id")
|
||||
}
|
||||
return body.playlist ?: throw LockstepApiException("playlist missing in response for $id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package at.lockstep.jukebox.api;
|
||||
|
||||
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse;
|
||||
import at.lockstep.jukebox.api.dto.PlaylistListResponse;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Path;
|
||||
|
||||
public interface PlaylistRetrofitApi {
|
||||
@GET("playlists")
|
||||
Call<PlaylistListResponse> getPlaylists();
|
||||
|
||||
@GET("playlists/{id}")
|
||||
Call<PlaylistDetailResponse> getPlaylist(@Path("id") String id);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
public class ArtistDto {
|
||||
public String id;
|
||||
public String name;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class FullPlaylistDto {
|
||||
public String id;
|
||||
public String name;
|
||||
public String description;
|
||||
public List<ImageDto> images;
|
||||
public String primaryColor;
|
||||
public String snapshotId;
|
||||
public TracksPageDto tracks;
|
||||
|
||||
public List<ImageDto> imagesOrEmpty() {
|
||||
return images != null ? images : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
public class ImageDto {
|
||||
public String url;
|
||||
public Integer height;
|
||||
public Integer width;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
public class PlaylistDetailResponse {
|
||||
public boolean ok;
|
||||
public String error;
|
||||
public FullPlaylistDto playlist;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package at.lockstep.jukebox.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlaylistListResponse(
|
||||
val ok: Boolean,
|
||||
val error: String? = null,
|
||||
val total: Int? = null,
|
||||
val items: List<SimplifiedPlaylistDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistDetailResponse(
|
||||
val ok: Boolean,
|
||||
val error: String? = null,
|
||||
val playlist: FullPlaylistDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageDto(
|
||||
val url: String,
|
||||
val height: Int? = null,
|
||||
val width: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistDto(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TrackDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val duration_ms: Int,
|
||||
val artists: List<ArtistDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistTrackItemDto(
|
||||
val track: TrackDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TracksPageDto(
|
||||
val href: String? = null,
|
||||
val total: Int? = null,
|
||||
val items: List<PlaylistTrackItemDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TracksStubDto(
|
||||
val href: String? = null,
|
||||
val total: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SimplifiedPlaylistDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val images: List<ImageDto> = emptyList(),
|
||||
val primary_color: String? = null,
|
||||
val snapshot_id: String,
|
||||
val tracks: TracksStubDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FullPlaylistDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val images: List<ImageDto> = emptyList(),
|
||||
val primary_color: String? = null,
|
||||
val snapshot_id: String,
|
||||
val tracks: TracksPageDto? = null,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PlaylistListResponse {
|
||||
public boolean ok;
|
||||
public String error;
|
||||
public Integer total;
|
||||
public List<SimplifiedPlaylistDto> items;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
public class PlaylistTrackItemDto {
|
||||
public TrackDto track;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class SimplifiedPlaylistDto {
|
||||
public String id;
|
||||
public String name;
|
||||
public String description;
|
||||
public List<ImageDto> images;
|
||||
public String primaryColor;
|
||||
public String snapshotId;
|
||||
public TracksStubDto tracks;
|
||||
|
||||
public List<ImageDto> imagesOrEmpty() {
|
||||
return images != null ? images : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class TrackDto {
|
||||
public String id;
|
||||
public String name;
|
||||
public int durationMs;
|
||||
public List<ArtistDto> artists;
|
||||
|
||||
public List<ArtistDto> artistsOrEmpty() {
|
||||
return artists != null ? artists : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class TracksPageDto {
|
||||
public String href;
|
||||
public Integer total;
|
||||
public List<PlaylistTrackItemDto> items;
|
||||
|
||||
public List<PlaylistTrackItemDto> itemsOrEmpty() {
|
||||
return items != null ? items : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package at.lockstep.jukebox.api.dto;
|
||||
|
||||
public class TracksStubDto {
|
||||
public String href;
|
||||
public Integer total;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
119
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.java
Normal file
119
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistDao.java
Normal file
@@ -0,0 +1,119 @@
|
||||
package at.lockstep.jukebox.db;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
import androidx.room.Upsert;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Dao
|
||||
public abstract class PlaylistDao {
|
||||
|
||||
@Transaction
|
||||
public void clearAllTables() {
|
||||
clearPlaylistTracks();
|
||||
clearImages();
|
||||
clearTracks();
|
||||
clearPlaylists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces images, track membership rows, and upserts tracks for one playlist in one transaction.
|
||||
*/
|
||||
@Transaction
|
||||
public void replacePlaylistContent(
|
||||
@NonNull PlaylistEntity playlist,
|
||||
@NonNull List<PlaylistImageEntity> images,
|
||||
@NonNull List<TrackEntity> tracks,
|
||||
@NonNull List<PlaylistTrackEntity> playlistTracks
|
||||
) {
|
||||
upsertPlaylist(playlist);
|
||||
deleteImagesForPlaylist(playlist.getId());
|
||||
if (!images.isEmpty()) {
|
||||
insertImages(images);
|
||||
}
|
||||
deletePlaylistTracksForPlaylist(playlist.getId());
|
||||
if (!tracks.isEmpty()) {
|
||||
upsertTracks(tracks);
|
||||
}
|
||||
if (!playlistTracks.isEmpty()) {
|
||||
insertPlaylistTracks(playlistTracks);
|
||||
}
|
||||
}
|
||||
|
||||
@Upsert
|
||||
public abstract void upsertPlaylists(@NonNull List<PlaylistEntity> playlists);
|
||||
|
||||
@Upsert
|
||||
public abstract void upsertPlaylist(@NonNull PlaylistEntity playlist);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
public abstract void insertImages(@NonNull List<PlaylistImageEntity> images);
|
||||
|
||||
@Query("DELETE FROM playlist_images WHERE playlist_id = :playlistId")
|
||||
public abstract void deleteImagesForPlaylist(@NonNull String playlistId);
|
||||
|
||||
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId")
|
||||
public abstract void deletePlaylistTracksForPlaylist(@NonNull String playlistId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
public abstract void insertPlaylistTracks(@NonNull List<PlaylistTrackEntity> rows);
|
||||
|
||||
@Upsert
|
||||
public abstract void upsertTracks(@NonNull List<TrackEntity> tracks);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
|
||||
public abstract LiveData<List<PlaylistWithImages>> observePlaylistsWithImages();
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
|
||||
public abstract List<PlaylistWithImages> getPlaylistsWithImages();
|
||||
|
||||
@Query("SELECT pt.position AS position, pt.track_id AS trackId, "
|
||||
+ "t.track_name AS trackName, t.artist_name AS artistName, "
|
||||
+ "t.duration_ms AS durationMs "
|
||||
+ "FROM playlist_tracks pt "
|
||||
+ "LEFT JOIN tracks t ON t.id = pt.track_id "
|
||||
+ "WHERE pt.playlist_id = :playlistId "
|
||||
+ "ORDER BY pt.position ASC")
|
||||
public abstract LiveData<List<TrackRow>> observeTracksForPlaylist(@NonNull String playlistId);
|
||||
|
||||
@Query("SELECT pt.position AS position, pt.track_id AS trackId, "
|
||||
+ "t.track_name AS trackName, t.artist_name AS artistName, "
|
||||
+ "t.duration_ms AS durationMs "
|
||||
+ "FROM playlist_tracks pt "
|
||||
+ "LEFT JOIN tracks t ON t.id = pt.track_id "
|
||||
+ "WHERE pt.playlist_id = :playlistId "
|
||||
+ "ORDER BY pt.position ASC")
|
||||
public abstract List<TrackRow> getTracksForPlaylist(@NonNull String playlistId);
|
||||
|
||||
@Query("SELECT id, snapshot_id FROM playlists")
|
||||
public abstract List<PlaylistSnapshotRow> getPlaylistSnapshots();
|
||||
|
||||
@Query("DELETE FROM playlists WHERE id IN (:ids)")
|
||||
public abstract void deletePlaylistsByIds(@NonNull List<String> ids);
|
||||
|
||||
/**
|
||||
* Removes tracks not referenced by any playlist_tracks row.
|
||||
*/
|
||||
@Query("DELETE FROM tracks WHERE id NOT IN (SELECT DISTINCT track_id FROM playlist_tracks WHERE track_id IS NOT NULL)")
|
||||
public abstract void deleteOrphanTracks();
|
||||
|
||||
@Query("DELETE FROM playlist_tracks")
|
||||
public abstract void clearPlaylistTracks();
|
||||
|
||||
@Query("DELETE FROM playlist_images")
|
||||
public abstract void clearImages();
|
||||
|
||||
@Query("DELETE FROM tracks")
|
||||
public abstract void clearTracks();
|
||||
|
||||
@Query("DELETE FROM playlists")
|
||||
public abstract void clearPlaylists();
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package at.lockstep.jukebox.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PlaylistDao {
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertPlaylists(playlists: List<PlaylistEntity>)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertPlaylist(playlist: PlaylistEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertImages(images: List<PlaylistImageEntity>)
|
||||
|
||||
@Query("DELETE FROM playlist_images WHERE playlist_id = :playlistId")
|
||||
suspend fun deleteImagesForPlaylist(playlistId: String)
|
||||
|
||||
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId")
|
||||
suspend fun deletePlaylistTracksForPlaylist(playlistId: String)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertPlaylistTracks(rows: List<PlaylistTrackEntity>)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertTracks(tracks: List<TrackEntity>)
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM playlists
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
""",
|
||||
)
|
||||
fun observePlaylistsWithImages(): Flow<List<PlaylistWithImages>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM playlists
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
""",
|
||||
)
|
||||
suspend fun getPlaylistsWithImages(): List<PlaylistWithImages>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT pt.position AS position, pt.track_id AS trackId,
|
||||
t.track_name AS trackName, t.artist_name AS artistName,
|
||||
t.duration_ms AS durationMs
|
||||
FROM playlist_tracks pt
|
||||
LEFT JOIN tracks t ON t.id = pt.track_id
|
||||
WHERE pt.playlist_id = :playlistId
|
||||
ORDER BY pt.position ASC
|
||||
""",
|
||||
)
|
||||
fun observeTracksForPlaylist(playlistId: String): Flow<List<TrackRow>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT pt.position AS position, pt.track_id AS trackId,
|
||||
t.track_name AS trackName, t.artist_name AS artistName,
|
||||
t.duration_ms AS durationMs
|
||||
FROM playlist_tracks pt
|
||||
LEFT JOIN tracks t ON t.id = pt.track_id
|
||||
WHERE pt.playlist_id = :playlistId
|
||||
ORDER BY pt.position ASC
|
||||
""",
|
||||
)
|
||||
suspend fun getTracksForPlaylist(playlistId: String): List<TrackRow>
|
||||
|
||||
@Query("SELECT id, snapshot_id FROM playlists")
|
||||
suspend fun getPlaylistSnapshots(): List<PlaylistSnapshotRow>
|
||||
|
||||
@Query("DELETE FROM playlists WHERE id IN (:ids)")
|
||||
suspend fun deletePlaylistsByIds(ids: List<String>)
|
||||
|
||||
/**
|
||||
* Removes [TrackEntity] rows that are not referenced by any [PlaylistTrackEntity].track_id.
|
||||
* Run after removing playlists when you want a compact cache; skipped when retaining
|
||||
* playlists removed on Spotify so those rows can keep referencing tracks.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM tracks WHERE id NOT IN (
|
||||
SELECT DISTINCT track_id FROM playlist_tracks WHERE track_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
)
|
||||
suspend fun deleteOrphanTracks()
|
||||
|
||||
@Query("DELETE FROM playlist_tracks")
|
||||
suspend fun clearPlaylistTracks()
|
||||
|
||||
@Query("DELETE FROM playlist_images")
|
||||
suspend fun clearImages()
|
||||
|
||||
@Query("DELETE FROM tracks")
|
||||
suspend fun clearTracks()
|
||||
|
||||
@Query("DELETE FROM playlists")
|
||||
suspend fun clearPlaylists()
|
||||
|
||||
@Transaction
|
||||
suspend fun clearAllTables() {
|
||||
clearPlaylistTracks()
|
||||
clearImages()
|
||||
clearTracks()
|
||||
clearPlaylists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces images, track membership rows, and upserts tracks for one playlist in one transaction.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replacePlaylistContent(
|
||||
playlist: PlaylistEntity,
|
||||
images: List<PlaylistImageEntity>,
|
||||
tracks: List<TrackEntity>,
|
||||
playlistTracks: List<PlaylistTrackEntity>,
|
||||
) {
|
||||
upsertPlaylist(playlist)
|
||||
deleteImagesForPlaylist(playlist.id)
|
||||
if (images.isNotEmpty()) {
|
||||
insertImages(images)
|
||||
}
|
||||
deletePlaylistTracksForPlaylist(playlist.id)
|
||||
if (tracks.isNotEmpty()) {
|
||||
upsertTracks(tracks)
|
||||
}
|
||||
if (playlistTracks.isNotEmpty()) {
|
||||
insertPlaylistTracks(playlistTracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaylistSnapshotRow(
|
||||
val id: String,
|
||||
val snapshot_id: String,
|
||||
)
|
||||
118
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.java
Normal file
118
jukebox/src/main/java/at/lockstep/jukebox/db/PlaylistEntity.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package at.lockstep.jukebox.db;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
@Entity(tableName = "playlists")
|
||||
public class PlaylistEntity {
|
||||
|
||||
@PrimaryKey
|
||||
@NonNull
|
||||
private String id;
|
||||
|
||||
@Nullable
|
||||
private String description;
|
||||
|
||||
@NonNull
|
||||
private String name;
|
||||
|
||||
@Nullable
|
||||
@ColumnInfo(name = "primary_color")
|
||||
private String primaryColor;
|
||||
|
||||
@NonNull
|
||||
@ColumnInfo(name = "snapshot_id")
|
||||
private String snapshotId;
|
||||
|
||||
@Nullable
|
||||
@ColumnInfo(name = "tracks_href")
|
||||
private String tracksHref;
|
||||
|
||||
@Nullable
|
||||
@ColumnInfo(name = "tracks_total")
|
||||
private Integer tracksTotal;
|
||||
|
||||
public PlaylistEntity(
|
||||
@NonNull String id,
|
||||
@Nullable String description,
|
||||
@NonNull String name,
|
||||
@Nullable String primaryColor,
|
||||
@NonNull String snapshotId,
|
||||
@Nullable String tracksHref,
|
||||
@Nullable Integer tracksTotal
|
||||
) {
|
||||
this.id = id;
|
||||
this.description = description;
|
||||
this.name = name;
|
||||
this.primaryColor = primaryColor;
|
||||
this.snapshotId = snapshotId;
|
||||
this.tracksHref = tracksHref;
|
||||
this.tracksTotal = tracksTotal;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(@NonNull String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(@Nullable String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(@NonNull String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPrimaryColor() {
|
||||
return primaryColor;
|
||||
}
|
||||
|
||||
public void setPrimaryColor(@Nullable String primaryColor) {
|
||||
this.primaryColor = primaryColor;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getSnapshotId() {
|
||||
return snapshotId;
|
||||
}
|
||||
|
||||
public void setSnapshotId(@NonNull String snapshotId) {
|
||||
this.snapshotId = snapshotId;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getTracksHref() {
|
||||
return tracksHref;
|
||||
}
|
||||
|
||||
public void setTracksHref(@Nullable String tracksHref) {
|
||||
this.tracksHref = tracksHref;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getTracksTotal() {
|
||||
return tracksTotal;
|
||||
}
|
||||
|
||||
public void setTracksTotal(@Nullable Integer tracksTotal) {
|
||||
this.tracksTotal = tracksTotal;
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package at.lockstep.jukebox.db;
|
||||
|
||||
import androidx.room.Embedded;
|
||||
import androidx.room.Relation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PlaylistWithImages {
|
||||
|
||||
@Embedded
|
||||
private PlaylistEntity playlist;
|
||||
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "playlist_id",
|
||||
entity = PlaylistImageEntity.class
|
||||
)
|
||||
private List<PlaylistImageEntity> images;
|
||||
|
||||
public PlaylistEntity getPlaylist() {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public void setPlaylist(PlaylistEntity playlist) {
|
||||
this.playlist = playlist;
|
||||
}
|
||||
|
||||
public List<PlaylistImageEntity> getImages() {
|
||||
return images;
|
||||
}
|
||||
|
||||
public void setImages(List<PlaylistImageEntity> images) {
|
||||
this.images = images;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package at.lockstep.jukebox.db
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class PlaylistWithImages(
|
||||
@Embedded val playlist: PlaylistEntity,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "playlist_id",
|
||||
entity = PlaylistImageEntity::class,
|
||||
)
|
||||
val images: List<PlaylistImageEntity>,
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
23
jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.java
Normal file
23
jukebox/src/main/java/at/lockstep/jukebox/db/TrackRow.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package at.lockstep.jukebox.db;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* One row in an ordered playlist listing (join of playlist_tracks and tracks).
|
||||
*/
|
||||
public class TrackRow {
|
||||
|
||||
public int position;
|
||||
|
||||
@Nullable
|
||||
public String trackId;
|
||||
|
||||
@Nullable
|
||||
public String trackName;
|
||||
|
||||
@Nullable
|
||||
public String artistName;
|
||||
|
||||
@Nullable
|
||||
public Integer durationMs;
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package at.lockstep.jukebox.map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import at.lockstep.jukebox.api.dto.ArtistDto;
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.ImageDto;
|
||||
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto;
|
||||
import at.lockstep.jukebox.api.dto.TrackDto;
|
||||
import at.lockstep.jukebox.db.PlaylistEntity;
|
||||
import at.lockstep.jukebox.db.PlaylistImageEntity;
|
||||
import at.lockstep.jukebox.db.PlaylistTrackEntity;
|
||||
import at.lockstep.jukebox.db.TrackEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Pure mapping helpers from Lockstep/Spotify-shaped API DTOs ({@link FullPlaylistDto}, etc.) to
|
||||
* {@link androidx.room.Room} entities. Used by {@link at.lockstep.jukebox.sync.SyncCoordinator} when
|
||||
* persisting a full playlist response.
|
||||
*/
|
||||
public final class PlaylistMappers {
|
||||
|
||||
private PlaylistMappers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single display string for track artists (comma-separated; {@code "Unknown Artist"} if none).
|
||||
*/
|
||||
@NonNull
|
||||
public static String artistDisplayName(@NonNull List<ArtistDto> artists) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (ArtistDto dto : artists) {
|
||||
if (dto.name != null && !dto.name.trim().isEmpty()) {
|
||||
names.add(dto.name);
|
||||
}
|
||||
}
|
||||
if (names.isEmpty()) {
|
||||
return "Unknown Artist";
|
||||
}
|
||||
return String.join(", ", names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts one Spotify image object into a {@link PlaylistImageEntity} for the given playlist and index.
|
||||
*/
|
||||
@NonNull
|
||||
public static PlaylistImageEntity toImageEntity(@NonNull ImageDto dto, @NonNull String playlistId, int index) {
|
||||
return new PlaylistImageEntity(
|
||||
playlistId,
|
||||
index,
|
||||
dto.url,
|
||||
dto.height,
|
||||
dto.width
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the playlist metadata from a full playlist response to a {@link PlaylistEntity} row.
|
||||
*/
|
||||
@NonNull
|
||||
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) {
|
||||
String tracksHref = dto.tracks != null ? dto.tracks.href : null;
|
||||
Integer tracksTotal = dto.tracks != null ? dto.tracks.total : null;
|
||||
return new PlaylistEntity(
|
||||
dto.id,
|
||||
dto.description,
|
||||
dto.name,
|
||||
dto.primaryColor,
|
||||
dto.snapshotId,
|
||||
tracksHref,
|
||||
tracksTotal
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a full playlist payload into the rows needed for one transactional replace: cover images,
|
||||
* deduplicated {@link TrackEntity} rows, and ordered {@link PlaylistTrackEntity} rows (including
|
||||
* {@code null} track ids for removed playlist items).
|
||||
*
|
||||
* @return images, unique tracks, and playlist track membership in API order
|
||||
*/
|
||||
@NonNull
|
||||
public static PlaylistStorageRows toPlaylistStorageRows(@NonNull FullPlaylistDto dto) {
|
||||
List<PlaylistImageEntity> images = new ArrayList<>();
|
||||
List<ImageDto> imageDtos = dto.imagesOrEmpty();
|
||||
for (int i = 0; i < imageDtos.size(); i++) {
|
||||
images.add(toImageEntity(imageDtos.get(i), dto.id, i));
|
||||
}
|
||||
List<PlaylistTrackItemDto> items = dto.tracks != null ? dto.tracks.itemsOrEmpty() : new ArrayList<>();
|
||||
|
||||
Map<String, TrackEntity> trackById = new LinkedHashMap<>();
|
||||
for (PlaylistTrackItemDto wrapper : items) {
|
||||
if (wrapper.track != null) {
|
||||
TrackDto t = wrapper.track;
|
||||
TrackEntity entity = new TrackEntity(
|
||||
t.id,
|
||||
t.name,
|
||||
artistDisplayName(t.artistsOrEmpty()),
|
||||
t.durationMs
|
||||
);
|
||||
trackById.put(entity.getId(), entity);
|
||||
}
|
||||
}
|
||||
List<TrackEntity> trackEntities = new ArrayList<>(trackById.values());
|
||||
|
||||
List<PlaylistTrackEntity> playlistTrackEntities = new ArrayList<>();
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
PlaylistTrackItemDto wrapper = items.get(i);
|
||||
String tid = wrapper.track != null ? wrapper.track.id : null;
|
||||
playlistTrackEntities.add(new PlaylistTrackEntity(dto.id, i, tid));
|
||||
}
|
||||
return new PlaylistStorageRows(images, trackEntities, playlistTrackEntities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles the three lists written together when replacing one playlist's cache content.
|
||||
*/
|
||||
public static final class PlaylistStorageRows {
|
||||
@NonNull
|
||||
public final List<PlaylistImageEntity> images;
|
||||
@NonNull
|
||||
public final List<TrackEntity> tracks;
|
||||
@NonNull
|
||||
public final List<PlaylistTrackEntity> playlistTracks;
|
||||
|
||||
public PlaylistStorageRows(
|
||||
@NonNull List<PlaylistImageEntity> images,
|
||||
@NonNull List<TrackEntity> tracks,
|
||||
@NonNull List<PlaylistTrackEntity> playlistTracks
|
||||
) {
|
||||
this.images = images;
|
||||
this.tracks = tracks;
|
||||
this.playlistTracks = playlistTracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package at.lockstep.jukebox.map
|
||||
|
||||
import at.lockstep.jukebox.api.dto.ArtistDto
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto
|
||||
import at.lockstep.jukebox.api.dto.ImageDto
|
||||
import at.lockstep.jukebox.db.PlaylistEntity
|
||||
import at.lockstep.jukebox.db.PlaylistImageEntity
|
||||
import at.lockstep.jukebox.db.PlaylistTrackEntity
|
||||
import at.lockstep.jukebox.db.TrackEntity
|
||||
|
||||
internal fun List<ArtistDto>.toArtistDisplayName(): String {
|
||||
val names = mapNotNull { dto -> dto.name?.takeIf { it.isNotBlank() } }
|
||||
return if (names.isEmpty()) "Unknown Artist" else names.joinToString(", ")
|
||||
}
|
||||
|
||||
internal fun ImageDto.toEntity(playlistId: String, index: Int): PlaylistImageEntity =
|
||||
PlaylistImageEntity(
|
||||
playlist_id = playlistId,
|
||||
image_index = index,
|
||||
url = url,
|
||||
height = height,
|
||||
width = width,
|
||||
)
|
||||
|
||||
internal fun FullPlaylistDto.toPlaylistEntity(): PlaylistEntity =
|
||||
PlaylistEntity(
|
||||
id = id,
|
||||
description = description,
|
||||
name = name,
|
||||
primary_color = primary_color,
|
||||
snapshot_id = snapshot_id,
|
||||
tracks_href = tracks?.href,
|
||||
tracks_total = tracks?.total,
|
||||
)
|
||||
|
||||
/** Maps a full playlist into rows for SQLite (playlist row was upserted separately if needed). */
|
||||
internal fun FullPlaylistDto.toPlaylistStorageRows(): Triple<List<PlaylistImageEntity>, List<TrackEntity>, List<PlaylistTrackEntity>> {
|
||||
val images = images.mapIndexed { index, dto -> dto.toEntity(id, index) }
|
||||
val items = tracks?.items.orEmpty()
|
||||
val trackEntities = items.mapNotNull { it.track }.map { dto ->
|
||||
TrackEntity(
|
||||
id = dto.id,
|
||||
track_name = dto.name,
|
||||
artist_name = dto.artists.toArtistDisplayName(),
|
||||
duration_ms = dto.duration_ms,
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
val playlistTrackEntities = items.mapIndexed { index, wrapper ->
|
||||
PlaylistTrackEntity(
|
||||
playlist_id = id,
|
||||
position = index,
|
||||
track_id = wrapper.track?.id,
|
||||
)
|
||||
}
|
||||
return Triple(images, trackEntities, playlistTrackEntities)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package at.lockstep.jukebox.sync;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import at.lockstep.jukebox.api.LockstepApiException;
|
||||
import at.lockstep.jukebox.api.LockstepPlaylistClient;
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
|
||||
import at.lockstep.jukebox.db.PlaylistDao;
|
||||
import at.lockstep.jukebox.db.PlaylistSnapshotRow;
|
||||
import at.lockstep.jukebox.map.PlaylistMappers;
|
||||
import at.lockstep.jukebox.map.PlaylistMappers.PlaylistStorageRows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Orchestrates loading playlist metadata from {@link LockstepPlaylistClient} and writing it into
|
||||
* {@link PlaylistDao}: full initial import (clear + fetch all details) and incremental sync using
|
||||
* {@code snapshot_id} from the playlist list endpoint. Individual playlists are always replaced
|
||||
* wholesale (images, memberships, and tracks for that playlist) inside a single DAO transaction.
|
||||
* <p>
|
||||
* Network detail fetches run in parallel up to the pool size passed to
|
||||
* {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)}; methods block until finished and
|
||||
* must not be called on the Android main thread.
|
||||
*/
|
||||
public final class SyncCoordinator {
|
||||
|
||||
private final PlaylistDao dao;
|
||||
private final LockstepPlaylistClient remote;
|
||||
private final int detailParallelism;
|
||||
|
||||
/**
|
||||
* @param detailParallelism maximum concurrent calls to {@link LockstepPlaylistClient#fetchPlaylistDetail(String)}
|
||||
*/
|
||||
public SyncCoordinator(
|
||||
@NonNull PlaylistDao dao,
|
||||
@NonNull LockstepPlaylistClient remote,
|
||||
int detailParallelism
|
||||
) {
|
||||
this.dao = dao;
|
||||
this.remote = remote;
|
||||
this.detailParallelism = detailParallelism;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)} with parallel detail
|
||||
* concurrency of {@code 6}.
|
||||
*/
|
||||
public SyncCoordinator(@NonNull PlaylistDao dao, @NonNull LockstepPlaylistClient remote) {
|
||||
this(dao, remote, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all local cache tables, fetches every playlist id from the list endpoint, loads each full
|
||||
* playlist, and persists them. Use for first-time or “reset” sync.
|
||||
*/
|
||||
public void syncInitial() throws IOException, LockstepApiException {
|
||||
dao.clearAllTables();
|
||||
List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries();
|
||||
List<String> ids = new ArrayList<>(summaries.size());
|
||||
for (SimplifiedPlaylistDto s : summaries) {
|
||||
ids.add(s.id);
|
||||
}
|
||||
List<FullPlaylistDto> details = fetchDetailsParallel(ids);
|
||||
for (FullPlaylistDto detail : details) {
|
||||
persistFullPlaylist(detail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current playlist list, compares each {@code snapshot_id} to the local DB, and refetches
|
||||
* full detail only for new or changed playlists. Playlists missing from the remote list are deleted
|
||||
* locally (including cascading images and track links) only when {@code retainRemovedPlaylists} is
|
||||
* {@code false}, in which case orphan {@link at.lockstep.jukebox.db.TrackEntity} rows are pruned.
|
||||
*/
|
||||
public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException {
|
||||
List<SimplifiedPlaylistDto> remoteSummaries = remote.fetchPlaylistSummaries();
|
||||
Map<String, String> localSnapshots = new HashMap<>();
|
||||
for (PlaylistSnapshotRow row : dao.getPlaylistSnapshots()) {
|
||||
localSnapshots.put(row.id, row.snapshotId);
|
||||
}
|
||||
List<String> idsToRefresh = new ArrayList<>();
|
||||
for (SimplifiedPlaylistDto summary : remoteSummaries) {
|
||||
String local = localSnapshots.get(summary.id);
|
||||
if (local == null || !local.equals(summary.snapshotId)) {
|
||||
idsToRefresh.add(summary.id);
|
||||
}
|
||||
}
|
||||
List<FullPlaylistDto> details = fetchDetailsParallel(idsToRefresh);
|
||||
for (FullPlaylistDto detail : details) {
|
||||
persistFullPlaylist(detail);
|
||||
}
|
||||
if (!retainRemovedPlaylists) {
|
||||
Set<String> remoteIds = new HashSet<>();
|
||||
for (SimplifiedPlaylistDto s : remoteSummaries) {
|
||||
remoteIds.add(s.id);
|
||||
}
|
||||
List<String> removedLocally = new ArrayList<>();
|
||||
for (String localId : localSnapshots.keySet()) {
|
||||
if (!remoteIds.contains(localId)) {
|
||||
removedLocally.add(localId);
|
||||
}
|
||||
}
|
||||
if (!removedLocally.isEmpty()) {
|
||||
dao.deletePlaylistsByIds(removedLocally);
|
||||
dao.deleteOrphanTracks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads full playlist JSON for each id using a bounded thread pool; preserves input order in the result.
|
||||
*/
|
||||
@NonNull
|
||||
private List<FullPlaylistDto> fetchDetailsParallel(@NonNull List<String> ids)
|
||||
throws IOException, LockstepApiException {
|
||||
if (ids.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
ExecutorService pool = Executors.newFixedThreadPool(detailParallelism);
|
||||
try {
|
||||
List<Future<FullPlaylistDto>> futures = new ArrayList<>(ids.size());
|
||||
for (String id : ids) {
|
||||
futures.add(pool.submit(() -> remote.fetchPlaylistDetail(id)));
|
||||
}
|
||||
List<FullPlaylistDto> out = new ArrayList<>(ids.size());
|
||||
try {
|
||||
for (Future<FullPlaylistDto> f : futures) {
|
||||
out.add(f.get());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable c = e.getCause();
|
||||
if (c instanceof IOException) {
|
||||
throw (IOException) c;
|
||||
}
|
||||
if (c instanceof LockstepApiException) {
|
||||
throw (LockstepApiException) c;
|
||||
}
|
||||
throw new IOException(c != null ? c : e);
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
pool.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist.
|
||||
*/
|
||||
private void persistFullPlaylist(@NonNull FullPlaylistDto detail) {
|
||||
PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail);
|
||||
dao.replacePlaylistContent(
|
||||
PlaylistMappers.toPlaylistEntity(detail),
|
||||
rows.images,
|
||||
rows.tracks,
|
||||
rows.playlistTracks
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package at.lockstep.jukebox.sync
|
||||
|
||||
import at.lockstep.jukebox.api.PlaylistRemoteClient
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto
|
||||
import at.lockstep.jukebox.db.PlaylistDao
|
||||
import at.lockstep.jukebox.map.toPlaylistEntity
|
||||
import at.lockstep.jukebox.map.toPlaylistStorageRows
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
internal class SyncCoordinator(
|
||||
private val dao: PlaylistDao,
|
||||
private val remote: PlaylistRemoteClient,
|
||||
private val ioDispatcher: CoroutineContext,
|
||||
private val detailParallelism: Int = 6,
|
||||
) {
|
||||
suspend fun syncInitial(): Unit = withContext(ioDispatcher) {
|
||||
dao.clearAllTables()
|
||||
val summaries = remote.fetchPlaylistSummaries()
|
||||
val details = fetchDetailsParallel(summaries.map { it.id })
|
||||
for (detail in details) {
|
||||
persistFullPlaylist(detail)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncDelta(retainRemovedPlaylists: Boolean): Unit = withContext(ioDispatcher) {
|
||||
val remoteSummaries = remote.fetchPlaylistSummaries()
|
||||
val localSnapshots = dao.getPlaylistSnapshots().associate { it.id to it.snapshot_id }
|
||||
val idsToRefresh = remoteSummaries
|
||||
.filter { summary ->
|
||||
localSnapshots[summary.id] != summary.snapshot_id
|
||||
}
|
||||
.map { it.id }
|
||||
val details = fetchDetailsParallel(idsToRefresh)
|
||||
for (detail in details) {
|
||||
persistFullPlaylist(detail)
|
||||
}
|
||||
if (!retainRemovedPlaylists) {
|
||||
val remoteIds = remoteSummaries.map { it.id }.toSet()
|
||||
val removedLocally = localSnapshots.keys.filter { it !in remoteIds }
|
||||
if (removedLocally.isNotEmpty()) {
|
||||
dao.deletePlaylistsByIds(removedLocally)
|
||||
dao.deleteOrphanTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchDetailsParallel(ids: List<String>): List<FullPlaylistDto> {
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
return coroutineScope {
|
||||
val semaphore = Semaphore(detailParallelism)
|
||||
ids.map { id ->
|
||||
async {
|
||||
semaphore.withPermit { remote.fetchPlaylistDetail(id) }
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistFullPlaylist(detail: FullPlaylistDto) {
|
||||
val playlist = detail.toPlaylistEntity()
|
||||
val (images, tracks, playlistTracks) = detail.toPlaylistStorageRows()
|
||||
dao.replacePlaylistContent(playlist, images, tracks, playlistTracks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package at.lockstep.jukebox.sync;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.room.Room;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import at.lockstep.jukebox.api.LockstepApiException;
|
||||
import at.lockstep.jukebox.api.LockstepPlaylistClient;
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.ImageDto;
|
||||
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto;
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
|
||||
import at.lockstep.jukebox.api.dto.TrackDto;
|
||||
import at.lockstep.jukebox.api.dto.TracksPageDto;
|
||||
import at.lockstep.jukebox.db.JukeboxDatabase;
|
||||
import at.lockstep.jukebox.db.TrackRow;
|
||||
import at.lockstep.jukebox.map.PlaylistMappers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34, application = Application.class)
|
||||
public class SyncCoordinatorTest {
|
||||
|
||||
private JukeboxDatabase db;
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
Application context = ApplicationProvider.getApplicationContext();
|
||||
db = Room.inMemoryDatabaseBuilder(context, JukeboxDatabase.class)
|
||||
.allowMainThreadQueries()
|
||||
.build();
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void syncDelta_sameSnapshot_skipsDetailFetch() throws Exception {
|
||||
FakeRemote remote = new FakeRemote();
|
||||
remote.listItems.add(simplified("p1", "snap1"));
|
||||
remote.details.put("p1", detailWithTrack("p1", "snap1", "t1", "Song"));
|
||||
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
|
||||
coordinator.syncInitial();
|
||||
remote.detailCallIds.clear();
|
||||
coordinator.syncDelta(true);
|
||||
Assert.assertTrue(remote.detailCallIds.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void syncDelta_changedSnapshot_refetchesDetail() throws Exception {
|
||||
FakeRemote remote = new FakeRemote();
|
||||
remote.listItems.add(simplified("p1", "s1"));
|
||||
remote.details.put("p1", detailWithTrack("p1", "s1", "t1", "Old"));
|
||||
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
|
||||
coordinator.syncInitial();
|
||||
remote.listItems.clear();
|
||||
remote.listItems.add(simplified("p1", "s2"));
|
||||
remote.details.put("p1", detailWithTrack("p1", "s2", "t2", "New"));
|
||||
remote.detailCallIds.clear();
|
||||
coordinator.syncDelta(true);
|
||||
Assert.assertEquals(Collections.singletonList("p1"), remote.detailCallIds);
|
||||
List<TrackRow> tracks = db.playlistDao().getTracksForPlaylist("p1");
|
||||
Assert.assertEquals(1, tracks.size());
|
||||
Assert.assertEquals("t2", tracks.get(0).trackId);
|
||||
Assert.assertEquals("New", tracks.get(0).trackName);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void syncDelta_removesPlaylistWhenNotRetained() throws Exception {
|
||||
FakeRemote remote = new FakeRemote();
|
||||
remote.listItems.add(simplified("p1", "s1"));
|
||||
remote.listItems.add(simplified("gone", "sg"));
|
||||
remote.details.put("p1", minimalDetail("p1", "s1"));
|
||||
remote.details.put("gone", minimalDetail("gone", "sg"));
|
||||
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
|
||||
coordinator.syncInitial();
|
||||
remote.listItems.clear();
|
||||
remote.listItems.add(simplified("p1", "s1"));
|
||||
coordinator.syncDelta(false);
|
||||
List<String> ids = snapshotIds();
|
||||
Assert.assertEquals(new HashSet<>(Collections.singletonList("p1")), new HashSet<>(ids));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void syncDelta_retainsRemovedPlaylist() throws Exception {
|
||||
FakeRemote remote = new FakeRemote();
|
||||
remote.listItems.add(simplified("p1", "s1"));
|
||||
remote.details.put("p1", minimalDetail("p1", "s1"));
|
||||
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
|
||||
coordinator.syncInitial();
|
||||
FullPlaylistDto orphan = minimalDetail("orphan", "so");
|
||||
PlaylistMappers.PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(orphan);
|
||||
db.playlistDao().replacePlaylistContent(
|
||||
PlaylistMappers.toPlaylistEntity(orphan),
|
||||
rows.images,
|
||||
rows.tracks,
|
||||
rows.playlistTracks
|
||||
);
|
||||
coordinator.syncDelta(true);
|
||||
List<String> ids = snapshotIds();
|
||||
Assert.assertTrue(ids.contains("orphan"));
|
||||
Assert.assertTrue(ids.contains("p1"));
|
||||
}
|
||||
|
||||
@Test(expected = LockstepApiException.class)
|
||||
public void listOkFalse_throws() throws Exception {
|
||||
FakeRemote remote = new FakeRemote();
|
||||
remote.failListWithMessage = "nope";
|
||||
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
|
||||
coordinator.syncInitial();
|
||||
}
|
||||
|
||||
private List<String> snapshotIds() {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (at.lockstep.jukebox.db.PlaylistSnapshotRow row : db.playlistDao().getPlaylistSnapshots()) {
|
||||
out.add(row.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static SimplifiedPlaylistDto simplified(String id, String snapshot) {
|
||||
SimplifiedPlaylistDto d = new SimplifiedPlaylistDto();
|
||||
d.id = id;
|
||||
d.name = id;
|
||||
d.snapshotId = snapshot;
|
||||
return d;
|
||||
}
|
||||
|
||||
private static FullPlaylistDto detailWithTrack(String id, String snap, String trackId, String trackName) {
|
||||
FullPlaylistDto d = new FullPlaylistDto();
|
||||
d.id = id;
|
||||
d.name = id;
|
||||
d.snapshotId = snap;
|
||||
d.images = List.of(new ImageDto());
|
||||
d.images.get(0).url = "https://x.example/a.png";
|
||||
TracksPageDto page = new TracksPageDto();
|
||||
PlaylistTrackItemDto item = new PlaylistTrackItemDto();
|
||||
TrackDto t = new TrackDto();
|
||||
t.id = trackId;
|
||||
t.name = trackName;
|
||||
t.durationMs = 1000;
|
||||
item.track = t;
|
||||
page.items = new ArrayList<>();
|
||||
page.items.add(item);
|
||||
d.tracks = page;
|
||||
return d;
|
||||
}
|
||||
|
||||
private static FullPlaylistDto minimalDetail(String id, String snapshot) {
|
||||
return detailWithTrack(id, snapshot, "t" + id, "Song");
|
||||
}
|
||||
|
||||
private static final class FakeRemote implements LockstepPlaylistClient {
|
||||
final List<String> detailCallIds = new ArrayList<>();
|
||||
final List<SimplifiedPlaylistDto> listItems = new ArrayList<>();
|
||||
final Map<String, FullPlaylistDto> details = new HashMap<>();
|
||||
String failListWithMessage;
|
||||
|
||||
@Override
|
||||
public List<SimplifiedPlaylistDto> fetchPlaylistSummaries()
|
||||
throws IOException, LockstepApiException {
|
||||
if (failListWithMessage != null) {
|
||||
throw new LockstepApiException(failListWithMessage);
|
||||
}
|
||||
return new ArrayList<>(listItems);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FullPlaylistDto fetchPlaylistDetail(String id) throws IOException, LockstepApiException {
|
||||
detailCallIds.add(id);
|
||||
FullPlaylistDto d = details.get(id);
|
||||
if (d == null) {
|
||||
throw new IOException("no detail for " + id);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package at.lockstep.jukebox.sync
|
||||
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import at.lockstep.jukebox.api.LockstepApiException
|
||||
import at.lockstep.jukebox.api.PlaylistRemoteClient
|
||||
import at.lockstep.jukebox.api.PlaylistRetrofitApi
|
||||
import at.lockstep.jukebox.api.dto.FullPlaylistDto
|
||||
import at.lockstep.jukebox.api.dto.ImageDto
|
||||
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse
|
||||
import at.lockstep.jukebox.api.dto.PlaylistListResponse
|
||||
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto
|
||||
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
|
||||
import at.lockstep.jukebox.api.dto.TrackDto
|
||||
import at.lockstep.jukebox.api.dto.TracksPageDto
|
||||
import at.lockstep.jukebox.db.JukeboxDatabase
|
||||
import at.lockstep.jukebox.map.toPlaylistEntity
|
||||
import at.lockstep.jukebox.map.toPlaylistStorageRows
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class SyncCoordinatorTest {
|
||||
|
||||
private lateinit var db: JukeboxDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, JukeboxDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncDelta_sameSnapshot_skipsDetailFetch() = runBlocking {
|
||||
val remote = FakeRemote()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "snap1"),
|
||||
)
|
||||
remote.detailById["p1"] = detailWithTrack("p1", "snap1", "t1", "Song")
|
||||
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
|
||||
coordinator.syncInitial()
|
||||
remote.detailCallIds.clear()
|
||||
coordinator.syncDelta(retainRemovedPlaylists = true)
|
||||
assertTrue(remote.detailCallIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncDelta_changedSnapshot_refetchesDetail() = runBlocking {
|
||||
val remote = FakeRemote()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
|
||||
)
|
||||
remote.detailById["p1"] = detailWithTrack("p1", "s1", "t1", "Old")
|
||||
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
|
||||
coordinator.syncInitial()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s2"),
|
||||
)
|
||||
remote.detailById["p1"] = detailWithTrack("p1", "s2", "t2", "New")
|
||||
remote.detailCallIds.clear()
|
||||
coordinator.syncDelta(retainRemovedPlaylists = true)
|
||||
assertEquals(listOf("p1"), remote.detailCallIds)
|
||||
val tracks = db.playlistDao().getTracksForPlaylist("p1")
|
||||
assertEquals("t2", tracks.single().trackId)
|
||||
assertEquals("New", tracks.single().trackName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncDelta_removesPlaylistWhenNotRetained() = runBlocking {
|
||||
val remote = FakeRemote()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
|
||||
SimplifiedPlaylistDto(id = "gone", name = "B", snapshot_id = "sg"),
|
||||
)
|
||||
remote.detailById["p1"] = minimalDetail("p1", "s1")
|
||||
remote.detailById["gone"] = minimalDetail("gone", "sg")
|
||||
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
|
||||
coordinator.syncInitial()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
|
||||
)
|
||||
coordinator.syncDelta(retainRemovedPlaylists = false)
|
||||
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
|
||||
assertEquals(setOf("p1"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncDelta_retainsRemovedPlaylist() = runBlocking {
|
||||
val remote = FakeRemote()
|
||||
remote.listItems = listOf(
|
||||
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
|
||||
)
|
||||
remote.detailById["p1"] = minimalDetail("p1", "s1")
|
||||
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
|
||||
coordinator.syncInitial()
|
||||
val orphan = minimalDetail("orphan", "so")
|
||||
val (im, tr, pt) = orphan.toPlaylistStorageRows()
|
||||
db.playlistDao().replacePlaylistContent(orphan.toPlaylistEntity(), im, tr, pt)
|
||||
coordinator.syncDelta(retainRemovedPlaylists = true)
|
||||
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
|
||||
assertTrue(ids.contains("orphan"))
|
||||
assertTrue(ids.contains("p1"))
|
||||
}
|
||||
|
||||
@Test(expected = LockstepApiException::class)
|
||||
fun listOkFalse_throws() = runBlocking {
|
||||
val remote = FakeRemote()
|
||||
remote.listResponseOverride = PlaylistListResponse(ok = false, error = "nope")
|
||||
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
|
||||
coordinator.syncInitial()
|
||||
}
|
||||
|
||||
private fun detailWithTrack(
|
||||
id: String,
|
||||
snapshot: String,
|
||||
trackId: String,
|
||||
trackName: String,
|
||||
) = FullPlaylistDto(
|
||||
id = id,
|
||||
name = id,
|
||||
snapshot_id = snapshot,
|
||||
images = listOf(ImageDto(url = "https://x.example/a.png")),
|
||||
tracks = TracksPageDto(
|
||||
items = listOf(
|
||||
PlaylistTrackItemDto(
|
||||
track = TrackDto(id = trackId, name = trackName, duration_ms = 1000),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun minimalDetail(id: String, snapshot: String) = detailWithTrack(id, snapshot, "t$id", "Song")
|
||||
|
||||
private class FakeRemote {
|
||||
val detailCallIds = mutableListOf<String>()
|
||||
var listItems: List<SimplifiedPlaylistDto> = emptyList()
|
||||
val detailById = mutableMapOf<String, FullPlaylistDto>()
|
||||
var listResponseOverride: PlaylistListResponse? = null
|
||||
|
||||
val api: PlaylistRetrofitApi = object : PlaylistRetrofitApi {
|
||||
override suspend fun getPlaylists(): PlaylistListResponse =
|
||||
listResponseOverride ?: PlaylistListResponse(
|
||||
ok = true,
|
||||
total = listItems.size,
|
||||
items = listItems,
|
||||
)
|
||||
|
||||
override suspend fun getPlaylist(id: String): PlaylistDetailResponse {
|
||||
detailCallIds.add(id)
|
||||
val pl = detailById[id] ?: error("no detail for $id")
|
||||
return PlaylistDetailResponse(ok = true, playlist = pl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user