Compare commits

...

5 Commits

17 changed files with 402 additions and 177 deletions

View File

@@ -1,3 +1,3 @@
plugins { plugins {
id 'com.android.library' version '8.7.2' apply false id 'com.android.library' version '8.6.0' apply false
} }

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.Transformations;
import at.lockstep.jukebox.api.LockstepApiException; import at.lockstep.jukebox.api.LockstepApiException;
import at.lockstep.jukebox.api.PlaylistRemoteClient; import at.lockstep.jukebox.api.PlaylistRemoteClient;
import at.lockstep.jukebox.api.PlaylistRetrofitApi; import at.lockstep.jukebox.api.PlaylistRetrofitApi;
import at.lockstep.jukebox.api.RetryOnRateLimitInterceptor;
import at.lockstep.jukebox.db.JukeboxDatabase; import at.lockstep.jukebox.db.JukeboxDatabase;
import at.lockstep.jukebox.db.PlaylistDao; import at.lockstep.jukebox.db.PlaylistDao;
import at.lockstep.jukebox.db.PlaylistImageEntity; import at.lockstep.jukebox.db.PlaylistImageEntity;
@@ -65,6 +66,11 @@ public final class DefaultPlaylistRepository implements PlaylistRepository {
syncCoordinator.syncDelta(retainRemovedPlaylists); syncCoordinator.syncDelta(retainRemovedPlaylists);
} }
@Override
public void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException {
syncCoordinator.syncPlaylistDetail(playlistId);
}
@Override @Override
@NonNull @NonNull
public LiveData<List<PlaylistSummary>> observePlaylists() { public LiveData<List<PlaylistSummary>> observePlaylists() {
@@ -124,7 +130,7 @@ public final class DefaultPlaylistRepository implements PlaylistRepository {
row.getPlaylist().getDescription(), row.getPlaylist().getDescription(),
row.getPlaylist().getPrimaryColor(), row.getPlaylist().getPrimaryColor(),
row.getPlaylist().getSnapshotId(), row.getPlaylist().getSnapshotId(),
row.getPlaylist().getTracksTotal(), row.getPlaylist().getItemsTotal(),
urls urls
); );
} }
@@ -161,6 +167,7 @@ public final class DefaultPlaylistRepository implements PlaylistRepository {
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create(); .create();
okhttp3.OkHttpClient okHttp = new okhttp3.OkHttpClient.Builder() okhttp3.OkHttpClient okHttp = new okhttp3.OkHttpClient.Builder()
.addInterceptor(new RetryOnRateLimitInterceptor())
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.build(); .build();
Retrofit retrofit = new Retrofit.Builder() Retrofit retrofit = new Retrofit.Builder()

View File

@@ -16,6 +16,11 @@ public interface PlaylistRepository {
void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException; void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException;
/**
* Fetches full playlist JSON for one id and replaces cached rows for that playlist only.
*/
void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException;
@NonNull @NonNull
LiveData<List<PlaylistSummary>> observePlaylists(); LiveData<List<PlaylistSummary>> observePlaylists();

View File

@@ -2,8 +2,6 @@ package at.lockstep.jukebox;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.Collections;
import java.util.List; import java.util.List;
public final class PlaylistSummary { public final class PlaylistSummary {
@@ -18,8 +16,9 @@ public final class PlaylistSummary {
public final String primaryColor; public final String primaryColor;
@NonNull @NonNull
public final String snapshotId; public final String snapshotId;
/** Spotify simplified {@code tracks.total}, or full playlist {@code items.total} when synced. */
@Nullable @Nullable
public final Integer tracksTotal; public final Integer itemsTotal;
@NonNull @NonNull
public final List<String> imageUrls; public final List<String> imageUrls;
@@ -29,7 +28,7 @@ public final class PlaylistSummary {
@Nullable String description, @Nullable String description,
@Nullable String primaryColor, @Nullable String primaryColor,
@NonNull String snapshotId, @NonNull String snapshotId,
@Nullable Integer tracksTotal, @Nullable Integer itemsTotal,
@NonNull List<String> imageUrls @NonNull List<String> imageUrls
) { ) {
this.id = id; this.id = id;
@@ -37,7 +36,7 @@ public final class PlaylistSummary {
this.description = description; this.description = description;
this.primaryColor = primaryColor; this.primaryColor = primaryColor;
this.snapshotId = snapshotId; this.snapshotId = snapshotId;
this.tracksTotal = tracksTotal; this.itemsTotal = itemsTotal;
this.imageUrls = imageUrls; this.imageUrls = imageUrls;
} }

View File

@@ -0,0 +1,99 @@
package at.lockstep.jukebox.api;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
/**
* Retries idempotent GET requests on HTTP 429 and 503 with exponential backoff, optional jitter,
* and {@code Retry-After} (seconds) when the server sends it. Helps when Lockstep proxies Spotify
* and inherits rate limits.
*/
public final class RetryOnRateLimitInterceptor implements Interceptor {
private final int maxRetries;
private final long initialBackoffMs;
private final double backoffMultiplier;
private final long maxBackoffMs;
public RetryOnRateLimitInterceptor() {
// Spotify / Lockstep can stay hot after bulk sync; extra attempts + longer cap help pairing-on-demand.
this(8, 1000L, 2.0, 120_000L);
}
public RetryOnRateLimitInterceptor(
int maxRetries,
long initialBackoffMs,
double backoffMultiplier,
long maxBackoffMs
) {
this.maxRetries = maxRetries;
this.initialBackoffMs = initialBackoffMs;
this.backoffMultiplier = backoffMultiplier;
this.maxBackoffMs = maxBackoffMs;
}
@NonNull
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Request request = chain.request();
if (!"GET".equalsIgnoreCase(request.method())) {
return chain.proceed(request);
}
long backoffMs = initialBackoffMs;
Response response = chain.proceed(request);
for (int retry = 0; retry <= maxRetries; retry++) {
if (response.code() != 429 && response.code() != 503) {
return response;
}
if (retry == maxRetries) {
return response;
}
long retryAfterMs = parseRetryAfterMs(response.header("Retry-After"));
long waitMs = Math.max(backoffMs, retryAfterMs);
response.close();
sleepWithJitter(waitMs);
backoffMs = Math.min((long) (backoffMs * backoffMultiplier), maxBackoffMs);
response = chain.proceed(request);
}
return response;
}
private void sleepWithJitter(long baseMs) throws IOException {
if (baseMs <= 0) {
return;
}
long jitter = baseMs > 4 ? ThreadLocalRandom.current().nextLong(0, baseMs / 4) : 0L;
try {
Thread.sleep(baseMs + jitter);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted during rate-limit backoff", e);
}
}
/**
* Parses {@code Retry-After} as a delay in seconds (Spotify / many proxies use this form).
* HTTP-date form is not parsed; returns 0 so exponential backoff applies alone.
*/
private static long parseRetryAfterMs(@Nullable String header) {
if (header == null || header.isEmpty()) {
return 0L;
}
try {
long seconds = Long.parseLong(header.trim());
if (seconds > 0) {
return Math.min(seconds * 1000L, 300_000L);
}
} catch (NumberFormatException ignored) {
}
return 0L;
}
}

View File

@@ -10,7 +10,8 @@ public class FullPlaylistDto {
public List<ImageDto> images; public List<ImageDto> images;
public String primaryColor; public String primaryColor;
public String snapshotId; public String snapshotId;
public TracksPageDto tracks; /** Paging object for playlist entries; Spotify key {@code items}. */
public PlaylistItemsPageDto items;
public List<ImageDto> imagesOrEmpty() { public List<ImageDto> imagesOrEmpty() {
return images != null ? images : Collections.emptyList(); return images != null ? images : Collections.emptyList();

View File

@@ -0,0 +1,10 @@
package at.lockstep.jukebox.api.dto;
/**
* One element of {@link PlaylistItemsPageDto#items}. Spotify nests the track under {@code item}
* (not {@code track}).
*/
public class PlaylistItemDto {
/** Nested playable (track) from Spotify; {@code null} when removed or unsupported. */
public TrackDto item;
}

View File

@@ -0,0 +1,19 @@
package at.lockstep.jukebox.api.dto;
import java.util.Collections;
import java.util.List;
/**
* Spotify full-playlist paging object keyed {@code items} at the playlist root:
* {@code playlist.items.href}, {@code playlist.items.total}, {@code playlist.items.items[]}.
*/
public class PlaylistItemsPageDto {
public String href;
public Integer total;
/** Playlist entries ({@link PlaylistItemDto}) in API order. */
public List<PlaylistItemDto> items;
public List<PlaylistItemDto> itemsOrEmpty() {
return items != null ? items : Collections.emptyList();
}
}

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import androidx.room.RoomDatabase;
TrackEntity.class, TrackEntity.class,
PlaylistTrackEntity.class PlaylistTrackEntity.class
}, },
version = 1, version = 2,
exportSchema = false exportSchema = false
) )
public abstract class JukeboxDatabase extends RoomDatabase { public abstract class JukeboxDatabase extends RoomDatabase {

View File

@@ -29,7 +29,7 @@ public abstract class PlaylistDao {
public void replacePlaylistContent( public void replacePlaylistContent(
@NonNull PlaylistEntity playlist, @NonNull PlaylistEntity playlist,
@NonNull List<PlaylistImageEntity> images, @NonNull List<PlaylistImageEntity> images,
@NonNull List<TrackEntity> tracks, @NonNull List<TrackEntity> trackEntities,
@NonNull List<PlaylistTrackEntity> playlistTracks @NonNull List<PlaylistTrackEntity> playlistTracks
) { ) {
upsertPlaylist(playlist); upsertPlaylist(playlist);
@@ -38,8 +38,8 @@ public abstract class PlaylistDao {
insertImages(images); insertImages(images);
} }
deletePlaylistTracksForPlaylist(playlist.getId()); deletePlaylistTracksForPlaylist(playlist.getId());
if (!tracks.isEmpty()) { if (!trackEntities.isEmpty()) {
upsertTracks(tracks); upsertTracks(trackEntities);
} }
if (!playlistTracks.isEmpty()) { if (!playlistTracks.isEmpty()) {
insertPlaylistTracks(playlistTracks); insertPlaylistTracks(playlistTracks);

View File

@@ -27,13 +27,15 @@ public class PlaylistEntity {
@ColumnInfo(name = "snapshot_id") @ColumnInfo(name = "snapshot_id")
private String snapshotId; private String snapshotId;
/** From Spotify paging {@code playlist.items.href} (full playlist) or simplified {@code tracks.href}. */
@Nullable @Nullable
@ColumnInfo(name = "tracks_href") @ColumnInfo(name = "items_href")
private String tracksHref; private String itemsHref;
/** Total count from paging object {@code playlist.items.total} or simplified {@code tracks.total}. */
@Nullable @Nullable
@ColumnInfo(name = "tracks_total") @ColumnInfo(name = "items_total")
private Integer tracksTotal; private Integer itemsTotal;
public PlaylistEntity( public PlaylistEntity(
@NonNull String id, @NonNull String id,
@@ -41,16 +43,16 @@ public class PlaylistEntity {
@NonNull String name, @NonNull String name,
@Nullable String primaryColor, @Nullable String primaryColor,
@NonNull String snapshotId, @NonNull String snapshotId,
@Nullable String tracksHref, @Nullable String itemsHref,
@Nullable Integer tracksTotal @Nullable Integer itemsTotal
) { ) {
this.id = id; this.id = id;
this.description = description; this.description = description;
this.name = name; this.name = name;
this.primaryColor = primaryColor; this.primaryColor = primaryColor;
this.snapshotId = snapshotId; this.snapshotId = snapshotId;
this.tracksHref = tracksHref; this.itemsHref = itemsHref;
this.tracksTotal = tracksTotal; this.itemsTotal = itemsTotal;
} }
@NonNull @NonNull
@@ -99,20 +101,20 @@ public class PlaylistEntity {
} }
@Nullable @Nullable
public String getTracksHref() { public String getItemsHref() {
return tracksHref; return itemsHref;
} }
public void setTracksHref(@Nullable String tracksHref) { public void setItemsHref(@Nullable String itemsHref) {
this.tracksHref = tracksHref; this.itemsHref = itemsHref;
} }
@Nullable @Nullable
public Integer getTracksTotal() { public Integer getItemsTotal() {
return tracksTotal; return itemsTotal;
} }
public void setTracksTotal(@Nullable Integer tracksTotal) { public void setItemsTotal(@Nullable Integer itemsTotal) {
this.tracksTotal = tracksTotal; this.itemsTotal = itemsTotal;
} }
} }

View File

@@ -1,11 +1,14 @@
package at.lockstep.jukebox.map; package at.lockstep.jukebox.map;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import at.lockstep.jukebox.api.dto.ArtistDto; import at.lockstep.jukebox.api.dto.ArtistDto;
import at.lockstep.jukebox.api.dto.FullPlaylistDto; import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.ImageDto; import at.lockstep.jukebox.api.dto.ImageDto;
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto; import at.lockstep.jukebox.api.dto.PlaylistItemDto;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import at.lockstep.jukebox.api.dto.TrackDto; import at.lockstep.jukebox.api.dto.TrackDto;
import at.lockstep.jukebox.db.PlaylistEntity; import at.lockstep.jukebox.db.PlaylistEntity;
import at.lockstep.jukebox.db.PlaylistImageEntity; import at.lockstep.jukebox.db.PlaylistImageEntity;
@@ -24,6 +27,8 @@ import java.util.Map;
*/ */
public final class PlaylistMappers { public final class PlaylistMappers {
private static final String TAG = "JukeboxPlaylist";
private PlaylistMappers() { private PlaylistMappers() {
} }
@@ -59,20 +64,57 @@ public final class PlaylistMappers {
} }
/** /**
* Maps the playlist metadata from a full playlist response to a {@link PlaylistEntity} row. * Maps a playlist list entry to a {@link PlaylistEntity} without playlist items loaded (shell row only).
* Uses simplified {@code tracks} stub ({@code href}, {@code total}).
*/ */
@NonNull @NonNull
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) { public static PlaylistEntity toPlaylistEntity(@NonNull SimplifiedPlaylistDto dto) {
String tracksHref = dto.tracks != null ? dto.tracks.href : null; if (dto.id == null || dto.id.isEmpty()) {
Integer tracksTotal = dto.tracks != null ? dto.tracks.total : null; throw new IllegalArgumentException("Simplified playlist missing id");
}
String itemsHref = dto.tracks != null ? dto.tracks.href : null;
Integer itemsTotal = dto.tracks != null ? dto.tracks.total : null;
String rawName = dto.name;
boolean unnamed = rawName == null || rawName.trim().isEmpty();
String displayName = unnamed ? "Untitled playlist" : rawName;
if (unnamed) {
Log.i(TAG, "Playlist has empty name, id=" + dto.id);
}
String snapshotId = dto.snapshotId != null ? dto.snapshotId : "";
return new PlaylistEntity( return new PlaylistEntity(
dto.id, dto.id,
dto.description, dto.description,
dto.name, displayName,
dto.primaryColor, dto.primaryColor,
dto.snapshotId, snapshotId,
tracksHref, itemsHref,
tracksTotal itemsTotal
);
}
/**
* Maps the playlist metadata from a full playlist response to a {@link PlaylistEntity} row.
* Uses root {@code items} paging object ({@code href}, {@code total}).
* Empty or whitespace-only {@code name} becomes {@code "Untitled playlist"} and is logged for debugging.
*/
@NonNull
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) {
String itemsHref = dto.items != null ? dto.items.href : null;
Integer itemsTotal = dto.items != null ? dto.items.total : null;
String rawName = dto.name;
boolean unnamed = rawName == null || rawName.trim().isEmpty();
String displayName = unnamed ? "Untitled playlist" : rawName;
if (unnamed) {
Log.i(TAG, "Playlist has empty name, id=" + dto.id);
}
return new PlaylistEntity(
dto.id,
dto.description,
displayName,
dto.primaryColor,
dto.snapshotId != null ? dto.snapshotId : "",
itemsHref,
itemsTotal
); );
} }
@@ -90,12 +132,12 @@ public final class PlaylistMappers {
for (int i = 0; i < imageDtos.size(); i++) { for (int i = 0; i < imageDtos.size(); i++) {
images.add(toImageEntity(imageDtos.get(i), dto.id, i)); images.add(toImageEntity(imageDtos.get(i), dto.id, i));
} }
List<PlaylistTrackItemDto> items = dto.tracks != null ? dto.tracks.itemsOrEmpty() : new ArrayList<>(); List<PlaylistItemDto> entries = dto.items != null ? dto.items.itemsOrEmpty() : new ArrayList<>();
Map<String, TrackEntity> trackById = new LinkedHashMap<>(); Map<String, TrackEntity> trackById = new LinkedHashMap<>();
for (PlaylistTrackItemDto wrapper : items) { for (PlaylistItemDto wrapper : entries) {
if (wrapper.track != null) { if (wrapper.item != null) {
TrackDto t = wrapper.track; TrackDto t = wrapper.item;
TrackEntity entity = new TrackEntity( TrackEntity entity = new TrackEntity(
t.id, t.id,
t.name, t.name,
@@ -108,9 +150,9 @@ public final class PlaylistMappers {
List<TrackEntity> trackEntities = new ArrayList<>(trackById.values()); List<TrackEntity> trackEntities = new ArrayList<>(trackById.values());
List<PlaylistTrackEntity> playlistTrackEntities = new ArrayList<>(); List<PlaylistTrackEntity> playlistTrackEntities = new ArrayList<>();
for (int i = 0; i < items.size(); i++) { for (int i = 0; i < entries.size(); i++) {
PlaylistTrackItemDto wrapper = items.get(i); PlaylistItemDto wrapper = entries.get(i);
String tid = wrapper.track != null ? wrapper.track.id : null; String tid = wrapper.item != null ? wrapper.item.id : null;
playlistTrackEntities.add(new PlaylistTrackEntity(dto.id, i, tid)); playlistTrackEntities.add(new PlaylistTrackEntity(dto.id, i, tid));
} }
return new PlaylistStorageRows(images, trackEntities, playlistTrackEntities); return new PlaylistStorageRows(images, trackEntities, playlistTrackEntities);
@@ -123,17 +165,17 @@ public final class PlaylistMappers {
@NonNull @NonNull
public final List<PlaylistImageEntity> images; public final List<PlaylistImageEntity> images;
@NonNull @NonNull
public final List<TrackEntity> tracks; public final List<TrackEntity> trackEntities;
@NonNull @NonNull
public final List<PlaylistTrackEntity> playlistTracks; public final List<PlaylistTrackEntity> playlistTracks;
public PlaylistStorageRows( public PlaylistStorageRows(
@NonNull List<PlaylistImageEntity> images, @NonNull List<PlaylistImageEntity> images,
@NonNull List<TrackEntity> tracks, @NonNull List<TrackEntity> trackEntities,
@NonNull List<PlaylistTrackEntity> playlistTracks @NonNull List<PlaylistTrackEntity> playlistTracks
) { ) {
this.images = images; this.images = images;
this.tracks = tracks; this.trackEntities = trackEntities;
this.playlistTracks = playlistTracks; this.playlistTracks = playlistTracks;
} }
} }

View File

@@ -1,87 +1,79 @@
package at.lockstep.jukebox.sync; package at.lockstep.jukebox.sync;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import at.lockstep.jukebox.api.LockstepApiException; import at.lockstep.jukebox.api.LockstepApiException;
import at.lockstep.jukebox.api.LockstepPlaylistClient; import at.lockstep.jukebox.api.LockstepPlaylistClient;
import at.lockstep.jukebox.api.dto.FullPlaylistDto; import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.ImageDto;
import at.lockstep.jukebox.api.dto.PlaylistItemDto;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto; import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import at.lockstep.jukebox.api.dto.TrackDto;
import at.lockstep.jukebox.db.PlaylistDao; import at.lockstep.jukebox.db.PlaylistDao;
import at.lockstep.jukebox.db.PlaylistEntity;
import at.lockstep.jukebox.db.PlaylistImageEntity;
import at.lockstep.jukebox.db.PlaylistSnapshotRow; import at.lockstep.jukebox.db.PlaylistSnapshotRow;
import at.lockstep.jukebox.map.PlaylistMappers; import at.lockstep.jukebox.map.PlaylistMappers;
import at.lockstep.jukebox.map.PlaylistMappers.PlaylistStorageRows;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; 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 * Orchestrates loading playlist metadata from {@link LockstepPlaylistClient} and writing it into
* {@link PlaylistDao}: full initial import (clear + fetch all details) and incremental sync using * {@link PlaylistDao}.
* {@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> * <p>
* Network detail fetches run in parallel up to the pool size passed to * Normal refreshes use {@link #syncDelta(boolean)}: only the playlist <em>list</em> endpoint (names, images,
* {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)}; methods block until finished and * snapshot ids). When a playlist's snapshot changes, its cached track rows are cleared until the user opens
* must not be called on the Android main thread. * it again. Full track lists load lazily via {@link #syncPlaylistDetail(String)}.
* <p>
* {@link #syncInitial()} performs a full reset ({@link PlaylistDao#clearAllTables()}) then persists list
* metadata only—use rarely; prefer {@link #syncDelta(boolean)} so lazily loaded tracks survive app restarts.
* <p>
* Methods block until finished and must not be called on the Android main thread.
*/ */
public final class SyncCoordinator { public final class SyncCoordinator {
private static final String TAG = "SyncCoordinator";
/** Cap per-item title/artist lines so huge playlists do not overwhelm logcat. */
private static final int SYNC_DETAIL_TITLE_LOG_LIMIT = 50;
private static final int SYNC_DETAIL_NULL_TRACK_LOG_LIMIT = 15;
private final PlaylistDao dao; private final PlaylistDao dao;
private final LockstepPlaylistClient remote; private final LockstepPlaylistClient remote;
private final int detailParallelism;
/** public SyncCoordinator(@NonNull PlaylistDao dao, @NonNull LockstepPlaylistClient remote) {
* @param detailParallelism maximum concurrent calls to {@link LockstepPlaylistClient#fetchPlaylistDetail(String)}
*/
public SyncCoordinator(
@NonNull PlaylistDao dao,
@NonNull LockstepPlaylistClient remote,
int detailParallelism
) {
this.dao = dao; this.dao = dao;
this.remote = remote; this.remote = remote;
this.detailParallelism = detailParallelism;
} }
/** /**
* Same as {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)} with parallel detail * Fetches playlist summaries, clears local cache, and stores list metadata and cover images only
* concurrency of {@code 6}. * (no per-playlist track fetch). Network I/O is list-only so opening the app does not fan out
*/ * {@code GET /playlists/{id}} for every playlist.
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 { public void syncInitial() throws IOException, LockstepApiException {
dao.clearAllTables();
List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries(); List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries();
List<String> ids = new ArrayList<>(summaries.size()); dao.clearAllTables();
for (SimplifiedPlaylistDto s : summaries) { for (SimplifiedPlaylistDto s : summaries) {
ids.add(s.id); persistPlaylistShellFromSummary(s);
}
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 * Fetches the playlist list, updates local shells when a playlist is new or its {@code snapshot_id}
* full detail only for new or changed playlists. Playlists missing from the remote list are deleted * changed, and optionally drops removed playlists. Does not call the detail endpoint; when a snapshot
* locally (including cascading images and track links) only when {@code retainRemovedPlaylists} is * changes, cached track rows for that playlist are cleared until the user opens it again.
* {@code false}, in which case orphan {@link at.lockstep.jukebox.db.TrackEntity} rows are pruned.
*/ */
public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException { public void syncDelta(boolean retainRemovedPlaylists) throws IOException, LockstepApiException {
List<SimplifiedPlaylistDto> remoteSummaries = remote.fetchPlaylistSummaries(); List<SimplifiedPlaylistDto> remoteSummaries = remote.fetchPlaylistSummaries();
@@ -89,21 +81,22 @@ public final class SyncCoordinator {
for (PlaylistSnapshotRow row : dao.getPlaylistSnapshots()) { for (PlaylistSnapshotRow row : dao.getPlaylistSnapshots()) {
localSnapshots.put(row.id, row.snapshotId); localSnapshots.put(row.id, row.snapshotId);
} }
List<String> idsToRefresh = new ArrayList<>();
for (SimplifiedPlaylistDto summary : remoteSummaries) { for (SimplifiedPlaylistDto summary : remoteSummaries) {
String local = localSnapshots.get(summary.id); if (summary.id == null || summary.id.isEmpty()) {
if (local == null || !local.equals(summary.snapshotId)) { continue;
idsToRefresh.add(summary.id); }
String remoteSnap = summary.snapshotId != null ? summary.snapshotId : "";
String local = localSnapshots.get(summary.id);
if (local == null || !local.equals(remoteSnap)) {
persistPlaylistShellFromSummary(summary);
} }
}
List<FullPlaylistDto> details = fetchDetailsParallel(idsToRefresh);
for (FullPlaylistDto detail : details) {
persistFullPlaylist(detail);
} }
if (!retainRemovedPlaylists) { if (!retainRemovedPlaylists) {
Set<String> remoteIds = new HashSet<>(); Set<String> remoteIds = new HashSet<>();
for (SimplifiedPlaylistDto s : remoteSummaries) { for (SimplifiedPlaylistDto s : remoteSummaries) {
remoteIds.add(s.id); if (s.id != null && !s.id.isEmpty()) {
remoteIds.add(s.id);
}
} }
List<String> removedLocally = new ArrayList<>(); List<String> removedLocally = new ArrayList<>();
for (String localId : localSnapshots.keySet()) { for (String localId : localSnapshots.keySet()) {
@@ -113,60 +106,119 @@ public final class SyncCoordinator {
} }
if (!removedLocally.isEmpty()) { if (!removedLocally.isEmpty()) {
dao.deletePlaylistsByIds(removedLocally); dao.deletePlaylistsByIds(removedLocally);
dao.deleteOrphanTracks();
} }
} }
dao.deleteOrphanTracks();
}
private void persistPlaylistShellFromSummary(@NonNull SimplifiedPlaylistDto s) {
if (s.id == null || s.id.isEmpty()) {
return;
}
PlaylistEntity playlist = PlaylistMappers.toPlaylistEntity(s);
List<PlaylistImageEntity> images = new ArrayList<>();
List<ImageDto> imageDtos = s.imagesOrEmpty();
for (int i = 0; i < imageDtos.size(); i++) {
images.add(PlaylistMappers.toImageEntity(imageDtos.get(i), s.id, i));
}
dao.replacePlaylistContent(
playlist,
images,
Collections.emptyList(),
Collections.emptyList()
);
} }
/** /**
* Loads full playlist JSON for each id using a bounded thread pool; preserves input order in the result. * Fetches {@code GET /playlists/{playlistId}} and replaces cached images, tracks, and memberships for
* that playlist only.
*/ */
@NonNull public void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException {
private List<FullPlaylistDto> fetchDetailsParallel(@NonNull List<String> ids) Log.d(TAG, "syncPlaylistDetail start requestedId=" + playlistId);
throws IOException, LockstepApiException { FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId);
if (ids.isEmpty()) { String resolvedId = detail.id;
return new ArrayList<>(); Log.d(
} TAG,
ExecutorService pool = Executors.newFixedThreadPool(detailParallelism); "syncPlaylistDetail fetched resolvedId="
try { + resolvedId
List<Future<FullPlaylistDto>> futures = new ArrayList<>(ids.size()); + " requestedMatch="
for (String id : ids) { + Objects.equals(playlistId, resolvedId)
futures.add(pool.submit(() -> remote.fetchPlaylistDetail(id))); + " name="
+ detail.name
+ " snapshotId="
+ detail.snapshotId
);
if (detail.items == null) {
Log.w(TAG, "syncPlaylistDetail: detail.items is null (no playlist items page in response)");
} else {
List<PlaylistItemDto> entries = detail.items.itemsOrEmpty();
int withItem = 0;
int withoutItem = 0;
int titlesLogged = 0;
int nullItemLogged = 0;
for (int i = 0; i < entries.size(); i++) {
PlaylistItemDto w = entries.get(i);
if (w.item != null) {
withItem++;
if (titlesLogged < SYNC_DETAIL_TITLE_LOG_LIMIT) {
TrackDto t = w.item;
Log.d(
TAG,
"syncPlaylistDetail items[" + i + "] id=" + t.id
+ " title=" + t.name
+ " artist="
+ PlaylistMappers.artistDisplayName(t.artistsOrEmpty()));
titlesLogged++;
}
} else {
withoutItem++;
if (nullItemLogged < SYNC_DETAIL_NULL_TRACK_LOG_LIMIT) {
Log.d(
TAG,
"syncPlaylistDetail items[" + i + "] (null item; e.g. removed or unsupported type)");
nullItemLogged++;
}
}
} }
List<FullPlaylistDto> out = new ArrayList<>(ids.size()); Integer total = detail.items.total;
try { Log.d(
for (Future<FullPlaylistDto> f : futures) { TAG,
out.add(f.get()); "syncPlaylistDetail items page: entryCount="
} + entries.size()
} catch (InterruptedException e) { + " entriesWithItem="
Thread.currentThread().interrupt(); + withItem
throw new IOException(e); + " entriesWithNullItem="
} catch (ExecutionException e) { + withoutItem
Throwable c = e.getCause(); + " paging.total="
if (c instanceof IOException) { + total
throw (IOException) c; + " titleArtistLinesLogged="
} + titlesLogged
if (c instanceof LockstepApiException) { + (withItem > titlesLogged
throw (LockstepApiException) c; ? " (cap=" + SYNC_DETAIL_TITLE_LOG_LIMIT + ", see SyncCoordinator constants)"
} : "")
throw new IOException(c != null ? c : e); );
}
return out;
} finally {
pool.shutdown();
} }
PlaylistMappers.PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail);
Log.d(
TAG,
"syncPlaylistDetail after map: dedupedTrackEntities="
+ rows.trackEntities.size()
+ " playlistTrackRows="
+ rows.playlistTracks.size()
);
persistFullPlaylist(detail, rows);
} }
/** private void persistFullPlaylist(
* Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist. @NonNull FullPlaylistDto detail,
*/ @NonNull PlaylistMappers.PlaylistStorageRows rows
private void persistFullPlaylist(@NonNull FullPlaylistDto detail) { ) {
PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail);
dao.replacePlaylistContent( dao.replacePlaylistContent(
PlaylistMappers.toPlaylistEntity(detail), PlaylistMappers.toPlaylistEntity(detail),
rows.images, rows.images,
rows.tracks, rows.trackEntities,
rows.playlistTracks rows.playlistTracks
); );
Log.d(TAG, "syncPlaylistDetail persisted replacePlaylistContent for id=" + detail.id);
} }
} }

View File

@@ -17,10 +17,10 @@ import at.lockstep.jukebox.api.LockstepApiException;
import at.lockstep.jukebox.api.LockstepPlaylistClient; import at.lockstep.jukebox.api.LockstepPlaylistClient;
import at.lockstep.jukebox.api.dto.FullPlaylistDto; import at.lockstep.jukebox.api.dto.FullPlaylistDto;
import at.lockstep.jukebox.api.dto.ImageDto; import at.lockstep.jukebox.api.dto.ImageDto;
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto; import at.lockstep.jukebox.api.dto.PlaylistItemsPageDto;
import at.lockstep.jukebox.api.dto.PlaylistItemDto;
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto; import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
import at.lockstep.jukebox.api.dto.TrackDto; 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.JukeboxDatabase;
import at.lockstep.jukebox.db.TrackRow; import at.lockstep.jukebox.db.TrackRow;
import at.lockstep.jukebox.map.PlaylistMappers; import at.lockstep.jukebox.map.PlaylistMappers;
@@ -53,34 +53,45 @@ public class SyncCoordinatorTest {
} }
@Test @Test
public void syncDelta_sameSnapshot_skipsDetailFetch() throws Exception { public void syncInitial_listOnly_noDetailCalls() throws Exception {
FakeRemote remote = new FakeRemote(); FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "snap1")); remote.listItems.add(simplified("p1", "snap1"));
remote.details.put("p1", detailWithTrack("p1", "snap1", "t1", "Song")); remote.details.put("p1", detailWithTrack("p1", "snap1", "t1", "Song"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote); SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial(); coordinator.syncInitial();
Assert.assertTrue(remote.detailCallIds.isEmpty());
Assert.assertTrue(db.playlistDao().getTracksForPlaylist("p1").isEmpty());
}
@Test
public void syncDelta_sameSnapshot_skipsShellRewrite() throws Exception {
FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "snap1"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial();
remote.detailCallIds.clear(); remote.detailCallIds.clear();
coordinator.syncDelta(true); coordinator.syncDelta(true);
Assert.assertTrue(remote.detailCallIds.isEmpty()); Assert.assertTrue(remote.detailCallIds.isEmpty());
} }
@Test @Test
public void syncDelta_changedSnapshot_refetchesDetail() throws Exception { public void syncDelta_changedSnapshot_clearsTracksWithoutDetailFetch() throws Exception {
FakeRemote remote = new FakeRemote(); FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1")); remote.listItems.add(simplified("p1", "s1"));
remote.details.put("p1", detailWithTrack("p1", "s1", "t1", "Old")); remote.details.put("p1", detailWithTrack("p1", "s1", "t1", "Old"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote); SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial(); coordinator.syncInitial();
coordinator.syncPlaylistDetail("p1");
Assert.assertEquals(1, db.playlistDao().getTracksForPlaylist("p1").size());
remote.listItems.clear(); remote.listItems.clear();
remote.listItems.add(simplified("p1", "s2")); remote.listItems.add(simplified("p1", "s2"));
remote.details.put("p1", detailWithTrack("p1", "s2", "t2", "New")); remote.details.put("p1", detailWithTrack("p1", "s2", "t2", "New"));
remote.detailCallIds.clear(); remote.detailCallIds.clear();
coordinator.syncDelta(true); coordinator.syncDelta(true);
Assert.assertEquals(Collections.singletonList("p1"), remote.detailCallIds); Assert.assertTrue(remote.detailCallIds.isEmpty());
List<TrackRow> tracks = db.playlistDao().getTracksForPlaylist("p1"); List<TrackRow> tracks = db.playlistDao().getTracksForPlaylist("p1");
Assert.assertEquals(1, tracks.size()); Assert.assertEquals(0, tracks.size());
Assert.assertEquals("t2", tracks.get(0).trackId);
Assert.assertEquals("New", tracks.get(0).trackName);
} }
@Test @Test
@@ -88,8 +99,6 @@ public class SyncCoordinatorTest {
FakeRemote remote = new FakeRemote(); FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1")); remote.listItems.add(simplified("p1", "s1"));
remote.listItems.add(simplified("gone", "sg")); 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); SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial(); coordinator.syncInitial();
remote.listItems.clear(); remote.listItems.clear();
@@ -103,7 +112,6 @@ public class SyncCoordinatorTest {
public void syncDelta_retainsRemovedPlaylist() throws Exception { public void syncDelta_retainsRemovedPlaylist() throws Exception {
FakeRemote remote = new FakeRemote(); FakeRemote remote = new FakeRemote();
remote.listItems.add(simplified("p1", "s1")); remote.listItems.add(simplified("p1", "s1"));
remote.details.put("p1", minimalDetail("p1", "s1"));
SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote); SyncCoordinator coordinator = new SyncCoordinator(db.playlistDao(), remote);
coordinator.syncInitial(); coordinator.syncInitial();
FullPlaylistDto orphan = minimalDetail("orphan", "so"); FullPlaylistDto orphan = minimalDetail("orphan", "so");
@@ -111,7 +119,7 @@ public class SyncCoordinatorTest {
db.playlistDao().replacePlaylistContent( db.playlistDao().replacePlaylistContent(
PlaylistMappers.toPlaylistEntity(orphan), PlaylistMappers.toPlaylistEntity(orphan),
rows.images, rows.images,
rows.tracks, rows.trackEntities,
rows.playlistTracks rows.playlistTracks
); );
coordinator.syncDelta(true); coordinator.syncDelta(true);
@@ -151,16 +159,16 @@ public class SyncCoordinatorTest {
d.snapshotId = snap; d.snapshotId = snap;
d.images = List.of(new ImageDto()); d.images = List.of(new ImageDto());
d.images.get(0).url = "https://x.example/a.png"; d.images.get(0).url = "https://x.example/a.png";
TracksPageDto page = new TracksPageDto(); PlaylistItemsPageDto page = new PlaylistItemsPageDto();
PlaylistTrackItemDto item = new PlaylistTrackItemDto(); PlaylistItemDto item = new PlaylistItemDto();
TrackDto t = new TrackDto(); TrackDto t = new TrackDto();
t.id = trackId; t.id = trackId;
t.name = trackName; t.name = trackName;
t.durationMs = 1000; t.durationMs = 1000;
item.track = t; item.item = t;
page.items = new ArrayList<>(); page.items = new ArrayList<>();
page.items.add(item); page.items.add(item);
d.tracks = page; d.items = page;
return d; return d;
} }

View File

@@ -11,8 +11,8 @@ CREATE TABLE playlists (
name TEXT NOT NULL, name TEXT NOT NULL,
primary_color TEXT, primary_color TEXT,
snapshot_id TEXT NOT NULL, snapshot_id TEXT NOT NULL,
tracks_href TEXT, items_href TEXT,
tracks_total INTEGER items_total INTEGER
); );
CREATE TABLE playlist_images ( CREATE TABLE playlist_images (
@@ -33,8 +33,8 @@ CREATE TABLE tracks (
duration_ms INTEGER NOT NULL duration_ms INTEGER NOT NULL
); );
-- Order of tracks in a playlist (matches playlist.tracks.items[] order). -- Order of tracks in a playlist (matches playlist.items.items[] order).
-- track_id NULL when the API returns a removed track (wrapper with track: null). -- track_id NULL when the API returns a removed entry (wrapper with item: null).
CREATE TABLE playlist_tracks ( CREATE TABLE playlist_tracks (
playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
position INTEGER NOT NULL, position INTEGER NOT NULL,