feat: backoff, sync 1 playlist, keep half-gotten playlists

This commit is contained in:
2026-05-14 02:42:39 +02:00
parent f0df38c98b
commit e139e810a3
5 changed files with 140 additions and 8 deletions

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() {
@@ -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

@@ -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

@@ -1,5 +1,7 @@
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;
@@ -24,6 +26,8 @@ import java.util.Map;
*/ */
public final class PlaylistMappers { public final class PlaylistMappers {
private static final String TAG = "JukeboxPlaylist";
private PlaylistMappers() { private PlaylistMappers() {
} }
@@ -60,15 +64,22 @@ public final class PlaylistMappers {
/** /**
* 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.
*/ */
@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 tracksHref = dto.tracks != null ? dto.tracks.href : null;
Integer tracksTotal = dto.tracks != null ? dto.tracks.total : 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);
}
return new PlaylistEntity( return new PlaylistEntity(
dto.id, dto.id,
dto.description, dto.description,
dto.name, displayName,
dto.primaryColor, dto.primaryColor,
dto.snapshotId, dto.snapshotId,
tracksHref, tracksHref,

View File

@@ -25,8 +25,8 @@ 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}: full initial import (fetch all details, then clear + replace) and incremental
* {@code snapshot_id} from the playlist list endpoint. Individual playlists are always replaced * 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. * 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 * Network detail fetches run in parallel up to the pool size passed to
@@ -54,24 +54,25 @@ public final class SyncCoordinator {
/** /**
* Same as {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)} with parallel detail * Same as {@link #SyncCoordinator(PlaylistDao, LockstepPlaylistClient, int)} with parallel detail
* concurrency of {@code 6}. * concurrency of {@code 3} to reduce burst traffic against Spotify via the Lockstep API.
*/ */
public SyncCoordinator(@NonNull PlaylistDao dao, @NonNull LockstepPlaylistClient remote) { public SyncCoordinator(@NonNull PlaylistDao dao, @NonNull LockstepPlaylistClient remote) {
this(dao, remote, 6); this(dao, remote, 3);
} }
/** /**
* Clears all local cache tables, fetches every playlist id from the list endpoint, loads each full * Fetches every playlist id from the list endpoint, loads each full playlist, then clears local
* playlist, and persists them. Use for first-time or “reset” sync. * 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 {
dao.clearAllTables();
List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries(); List<SimplifiedPlaylistDto> summaries = remote.fetchPlaylistSummaries();
List<String> ids = new ArrayList<>(summaries.size()); List<String> ids = new ArrayList<>(summaries.size());
for (SimplifiedPlaylistDto s : summaries) { for (SimplifiedPlaylistDto s : summaries) {
ids.add(s.id); ids.add(s.id);
} }
List<FullPlaylistDto> details = fetchDetailsParallel(ids); List<FullPlaylistDto> details = fetchDetailsParallel(ids);
dao.clearAllTables();
for (FullPlaylistDto detail : details) { for (FullPlaylistDto detail : details) {
persistFullPlaylist(detail); persistFullPlaylist(detail);
} }
@@ -157,6 +158,15 @@ public final class SyncCoordinator {
} }
} }
/**
* 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.
*/
public void syncPlaylistDetail(@NonNull String playlistId) throws IOException, LockstepApiException {
FullPlaylistDto detail = remote.fetchPlaylistDetail(playlistId);
persistFullPlaylist(detail);
}
/** /**
* Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist. * Maps {@code detail} to entities and runs {@link PlaylistDao#replacePlaylistContent} for that playlist.
*/ */