chore: port to Java using AI
This commit is contained in:
@@ -1,6 +1,3 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.library") version "8.7.2" apply false
|
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 {
|
plugins {
|
||||||
id("com.android.library")
|
id("com.android.library")
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
|
||||||
id("com.google.devtools.ksp")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -19,30 +16,24 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isIncludeAndroidResources = true
|
unitTests.isIncludeAndroidResources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
|
||||||
|
|
||||||
val room = "2.6.1"
|
val room = "2.6.1"
|
||||||
implementation("androidx.room:room-runtime:$room")
|
implementation("androidx.room:room-runtime:$room")
|
||||||
implementation("androidx.room:room-ktx:$room")
|
implementation("androidx.lifecycle:lifecycle-livedata:2.8.7")
|
||||||
ksp("androidx.room:room-compiler:$room")
|
annotationProcessor("androidx.room:room-compiler:$room")
|
||||||
testImplementation("androidx.room:room-testing:$room")
|
testImplementation("androidx.room:room-testing:$room")
|
||||||
|
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||||
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
|
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("com.google.code.gson:gson:2.11.0")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
|
||||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||||
testImplementation("androidx.test:core:1.6.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