Compare commits

..

1 Commits

Author SHA1 Message Date
8a7265194c feat: fetching playlists without their tracks (untested) 2026-05-14 03:02:13 +02:00
3 changed files with 96 additions and 103 deletions

View File

@@ -8,6 +8,7 @@ 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.PlaylistTrackItemDto;
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,6 +63,34 @@ public final class PlaylistMappers {
); );
} }
/**
* Maps a playlist list entry to a {@link PlaylistEntity} without track items (shell row only).
*/
@NonNull
public static PlaylistEntity toPlaylistEntity(@NonNull SimplifiedPlaylistDto dto) {
if (dto.id == null || dto.id.isEmpty()) {
throw new IllegalArgumentException("Simplified playlist missing id");
}
String tracksHref = dto.tracks != null ? dto.tracks.href : null;
Integer tracksTotal = 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,
tracksHref,
tracksTotal
);
}
/** /**
* 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.
* 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.
@@ -81,7 +110,7 @@ public final class PlaylistMappers {
dto.description, dto.description,
displayName, displayName,
dto.primaryColor, dto.primaryColor,
dto.snapshotId, dto.snapshotId != null ? dto.snapshotId : "",
tracksHref, tracksHref,
tracksTotal tracksTotal
); );

View File

@@ -5,84 +5,63 @@ 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.SimplifiedPlaylistDto; import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto;
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.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 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 +69,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,64 +94,40 @@ 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 {
FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId); FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId);
persistFullPlaylist(detail); persistFullPlaylist(detail);
} }
/**
* Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist.
*/
private void persistFullPlaylist(@NonNull FullPlaylistDto detail) { private void persistFullPlaylist(@NonNull FullPlaylistDto detail) {
PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail); PlaylistMappers.PlaylistStorageRows rows = PlaylistMappers.toPlaylistStorageRows(detail);
dao.replacePlaylistContent( dao.replacePlaylistContent(
PlaylistMappers.toPlaylistEntity(detail), PlaylistMappers.toPlaylistEntity(detail),
rows.images, rows.images,

View File

@@ -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");