Compare commits
3 Commits
e139e810a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a0bb35a9 | |||
| d2d6500e23 | |||
| 8a7265194c |
@@ -130,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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package at.lockstep.jukebox.api.dto;
|
|
||||||
|
|
||||||
public class PlaylistTrackItemDto {
|
|
||||||
public TrackDto track;
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ 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;
|
||||||
@@ -62,14 +63,44 @@ public final class PlaylistMappers {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
public static PlaylistEntity toPlaylistEntity(@NonNull SimplifiedPlaylistDto dto) {
|
||||||
|
if (dto.id == null || dto.id.isEmpty()) {
|
||||||
|
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(
|
||||||
|
dto.id,
|
||||||
|
dto.description,
|
||||||
|
displayName,
|
||||||
|
dto.primaryColor,
|
||||||
|
snapshotId,
|
||||||
|
itemsHref,
|
||||||
|
itemsTotal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps the playlist metadata from a full playlist response to a {@link PlaylistEntity} row.
|
* 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.
|
* Empty or whitespace-only {@code name} becomes {@code "Untitled playlist"} and is logged for debugging.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
@NonNull
|
||||||
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) {
|
public static PlaylistEntity toPlaylistEntity(@NonNull FullPlaylistDto dto) {
|
||||||
String tracksHref = dto.tracks != null ? dto.tracks.href : null;
|
String itemsHref = dto.items != null ? dto.items.href : null;
|
||||||
Integer tracksTotal = dto.tracks != null ? dto.tracks.total : null;
|
Integer itemsTotal = dto.items != null ? dto.items.total : null;
|
||||||
String rawName = dto.name;
|
String rawName = dto.name;
|
||||||
boolean unnamed = rawName == null || rawName.trim().isEmpty();
|
boolean unnamed = rawName == null || rawName.trim().isEmpty();
|
||||||
String displayName = unnamed ? "Untitled playlist" : rawName;
|
String displayName = unnamed ? "Untitled playlist" : rawName;
|
||||||
@@ -81,9 +112,9 @@ public final class PlaylistMappers {
|
|||||||
dto.description,
|
dto.description,
|
||||||
displayName,
|
displayName,
|
||||||
dto.primaryColor,
|
dto.primaryColor,
|
||||||
dto.snapshotId,
|
dto.snapshotId != null ? dto.snapshotId : "",
|
||||||
tracksHref,
|
itemsHref,
|
||||||
tracksTotal
|
itemsTotal
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,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,
|
||||||
@@ -119,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);
|
||||||
@@ -134,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +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 (fetch all details, then clear + replace) and incremental
|
* {@link PlaylistDao}.
|
||||||
* 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>
|
* <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 3} to reduce burst traffic against Spotify via the Lockstep API.
|
* (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, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches every playlist id from the list endpoint, loads each full playlist, then clears local
|
|
||||||
* cache tables and persists the new snapshot. Network I/O happens before {@link PlaylistDao#clearAllTables()}
|
|
||||||
* so a failed import (e.g. HTTP 429 mid-way) does not leave the UI with an empty cache.
|
|
||||||
*/
|
*/
|
||||||
public void syncInitial() throws IOException, LockstepApiException {
|
public void syncInitial() throws IOException, LockstepApiException {
|
||||||
List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries();
|
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);
|
|
||||||
dao.clearAllTables();
|
dao.clearAllTables();
|
||||||
for (FullPlaylistDto detail : details) {
|
for (SimplifiedPlaylistDto s : summaries) {
|
||||||
persistFullPlaylist(detail);
|
persistPlaylistShellFromSummary(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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();
|
||||||
@@ -90,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()) {
|
||||||
@@ -114,69 +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) {
|
||||||
* Loads full playlist JSON for each id using a bounded thread pool; preserves input order in the result.
|
if (s.id == null || s.id.isEmpty()) {
|
||||||
*/
|
return;
|
||||||
@NonNull
|
|
||||||
private List<FullPlaylistDto> fetchDetailsParallel(@NonNull List<String> ids)
|
|
||||||
throws IOException, LockstepApiException {
|
|
||||||
if (ids.isEmpty()) {
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
ExecutorService pool = Executors.newFixedThreadPool(detailParallelism);
|
PlaylistEntity playlist = PlaylistMappers.toPlaylistEntity(s);
|
||||||
try {
|
List<PlaylistImageEntity> images = new ArrayList<>();
|
||||||
List<Future<FullPlaylistDto>> futures = new ArrayList<>(ids.size());
|
List<ImageDto> imageDtos = s.imagesOrEmpty();
|
||||||
for (String id : ids) {
|
for (int i = 0; i < imageDtos.size(); i++) {
|
||||||
futures.add(pool.submit(() -> remote.fetchPlaylistDetail(id)));
|
images.add(PlaylistMappers.toImageEntity(imageDtos.get(i), s.id, i));
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
dao.replacePlaylistContent(
|
||||||
|
playlist,
|
||||||
|
images,
|
||||||
|
Collections.emptyList(),
|
||||||
|
Collections.emptyList()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches {@code GET /playlists/{playlistId}} and replaces cached images, tracks, and memberships for
|
* Fetches {@code GET /playlists/{playlistId}} and replaces cached images, tracks, and memberships for
|
||||||
* that playlist only. Use when the playlist header exists locally but track rows are missing.
|
* that playlist only.
|
||||||
*/
|
*/
|
||||||
public void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException {
|
public void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException {
|
||||||
|
Log.d(TAG, "syncPlaylistDetail start requestedId=" + playlistId);
|
||||||
FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId);
|
FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId);
|
||||||
persistFullPlaylist(detail);
|
String resolvedId = detail.id;
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"syncPlaylistDetail fetched resolvedId="
|
||||||
|
+ resolvedId
|
||||||
|
+ " requestedMatch="
|
||||||
|
+ Objects.equals(playlistId, resolvedId)
|
||||||
|
+ " 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Integer total = detail.items.total;
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"syncPlaylistDetail items page: entryCount="
|
||||||
|
+ entries.size()
|
||||||
|
+ " entriesWithItem="
|
||||||
|
+ withItem
|
||||||
|
+ " entriesWithNullItem="
|
||||||
|
+ withoutItem
|
||||||
|
+ " paging.total="
|
||||||
|
+ total
|
||||||
|
+ " titleArtistLinesLogged="
|
||||||
|
+ titlesLogged
|
||||||
|
+ (withItem > titlesLogged
|
||||||
|
? " (cap=" + SYNC_DETAIL_TITLE_LOG_LIMIT + ", see SyncCoordinator constants)"
|
||||||
|
: "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user