initial: AI-generated jukebox metadata lib (Cursor's Composer 2)

This commit is contained in:
2026-05-13 16:50:34 +02:00
commit 2b778b4583
27 changed files with 1408 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Gradle
.gradle/
build/
# Android
local.properties
**/build/
*.iml
.DS_Store

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
plugins {
id("com.android.library") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.25" apply false
}

104
docs/playlists.md Normal file
View File

@@ -0,0 +1,104 @@
# Playlist endpoints
All routes require an authenticated session (`spotify_user_id` after Spotify login). Responses are JSON (`application/json`).
---
## `GET /playlists`
Returns every playlist for the current user by following Spotifys paginated [`GET /v1/me/playlists`](https://developer.spotify.com/documentation/web-api/reference/get-a-list-of-current-users-playlists) until all pages are loaded.
### Success (200)
| Field | Type | Description |
| --- | --- | --- |
| `ok` | `boolean` | Always `true` on success. |
| `total` | `number` | Count of playlists in `items`. |
| `items` | `array` | Each element is a **simplified playlist object** from Spotify |
**Typical fields on each element of `items`** (Spotify `SimplifiedPlaylistObject`):
| Field | Type |
| --- | --- |
| `description` | `string` \| `null` |
| `id` | `string` |
| `images` | `array` of `{ "url": string, "height": number \| null, "width": number \| null }` |
| `name` | `string` |
| `primary_color` | `string` \| `null` |
| `snapshot_id` | `string` |
| `tracks` | `object` — e.g. `{ "href": string, "total": number }` (track list stub, not full tracks) |
### Errors
`{ "ok": false, "error": string, ... }`
---
## `GET /playlists/<playlist_id>`
`<playlist_id>` is the Spotify playlist ID (the same id as in playlist URLs / `items[].id`).
Fetches [`GET /v1/playlists/{playlist_id}`](https://developer.spotify.com/documentation/web-api/reference/get-playlist) following pagination.
### Success (200)
| Field | Type | Description |
| --- | --- | --- |
| `ok` | `boolean` | Always `true` on success. |
| `playlist` | `object` | **Full playlist object** from Spotify, with `tracks` possibly expanded to every track as described above. |
**Typical fields on `playlist`** (Spotify `PlaylistObject`):
| Field | Type |
| --- | --- |
| `description` | `string` \| `null` |
| `id` | `string` |
| `images` | `array` (image objects, as above) |
| `name` | `string` |
| `primary_color` | `string` \| `null` |
| `snapshot_id` | `string` |
| `tracks` | `{"items": [Track, ...], ...}` |
**Typical fields on each element of `playlist.tracks.items`** (Spotify playlist track wrapper):
| Field | Type |
| --- | --- |
| `track` | `object` \| `null` — full or linked track; `null` if removed |
Nested objects use Spotifys **Track**, **Artist** (simplified), and **Album** (simplified) shapes below (field availability can vary by market or API version; see Spotifys reference).
#### `track` — Spotify `TrackObject`
Returned as the non-`null` value of `playlist.tracks.items[].track` (playlist context usually includes a **full** track with **simplified** `album` and `artists` entries).
| Field | Type |
| --- | --- |
| `album` | `object`**SimplifiedAlbumObject** (see below) |
| `artists` | `array` of **SimplifiedArtistObject** (see below) |
| `duration_ms` | `number` |
| `id` | `string` |
| `name` | `string` |
#### `track.artists[]` — Spotify **SimplifiedArtistObject**
| Field | Type |
| --- | --- |
| `id` | `string` |
| `name` | `string` |
#### `track.album` — Spotify **SimplifiedAlbumObject**
| Field | Type |
| --- | --- |
| `artists` | `array` of **SimplifiedArtistObject** (album-level credits) |
| `id` | `string` |
| `images` | `array` of `{ "url": string, "height": number \| null, "width": number \| null }` |
| `name` | `string` |
### Errors
Same as `/playlists`: **401** when not logged in; otherwise Spotify errors and network errors per the global error handlers.
---
For authoritative field lists and edge cases, see [Spotify Web API reference](https://developer.spotify.com/documentation/web-api).

4
gradle.properties Normal file
View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Normal file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

48
jukebox/build.gradle.kts Normal file
View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "at.lockstep.jukebox"
compileSdk = 35
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
val room = "2.6.1"
implementation("androidx.room:room-runtime:$room")
implementation("androidx.room:room-ktx:$room")
ksp("androidx.room:room-compiler:$room")
testImplementation("androidx.room:room-testing:$room")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("org.robolectric:robolectric:4.14.1")
testImplementation("androidx.test:core:1.6.1")
}

View File

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,88 @@
package at.lockstep.jukebox
import android.content.Context
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.PlaylistRetrofitApi
import at.lockstep.jukebox.db.JukeboxDatabase
import at.lockstep.jukebox.db.PlaylistDao
import at.lockstep.jukebox.sync.SyncCoordinator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlin.coroutines.CoroutineContext
class DefaultPlaylistRepository internal constructor(
private val dao: PlaylistDao,
private val syncCoordinator: SyncCoordinator,
private val ioDispatcher: CoroutineContext,
) : PlaylistRepository {
override suspend fun syncInitial() {
syncCoordinator.syncInitial()
}
override suspend fun syncDelta(retainRemovedPlaylists: Boolean) {
syncCoordinator.syncDelta(retainRemovedPlaylists)
}
override fun observePlaylists(): Flow<List<PlaylistSummary>> =
dao.observePlaylistsWithImages().map { rows -> rows.map { it.toSummary() } }
override suspend fun getPlaylists(): List<PlaylistSummary> =
withContext(ioDispatcher) {
dao.getPlaylistsWithImages().map { it.toSummary() }
}
override fun observeTracks(playlistId: String): Flow<List<TrackRow>> =
dao.observeTracksForPlaylist(playlistId)
override suspend fun getTracks(playlistId: String): List<TrackRow> =
withContext(ioDispatcher) {
dao.getTracksForPlaylist(playlistId)
}
companion object {
fun create(
context: Context,
authInterceptor: Interceptor,
baseUrl: String = "https://api.lockstep.at/",
ioDispatcher: CoroutineContext = Dispatchers.IO,
): DefaultPlaylistRepository {
val normalizedBase = baseUrl.trim().trimEnd('/') + "/"
val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
val okHttp = okhttp3.OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(normalizedBase)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
val retrofitApi = retrofit.create(PlaylistRetrofitApi::class.java)
val remote = PlaylistRemoteClient(retrofitApi)
val db = JukeboxDatabase.create(context.applicationContext)
val sync = SyncCoordinator(db.playlistDao(), remote, ioDispatcher)
return DefaultPlaylistRepository(db.playlistDao(), sync, ioDispatcher)
}
}
}
object Jukebox {
fun playlistRepository(
context: Context,
authInterceptor: Interceptor,
baseUrl: String = "https://api.lockstep.at/",
ioDispatcher: CoroutineContext = Dispatchers.IO,
): PlaylistRepository =
DefaultPlaylistRepository.create(context, authInterceptor, baseUrl, ioDispatcher)
}

View File

@@ -0,0 +1,43 @@
package at.lockstep.jukebox
import at.lockstep.jukebox.db.PlaylistWithImages
import kotlinx.coroutines.flow.Flow
data class PlaylistSummary(
val id: String,
val name: String,
val description: String?,
val primaryColor: String?,
val snapshotId: String,
val tracksTotal: Int?,
val imageUrls: List<String>,
)
typealias TrackRow = at.lockstep.jukebox.db.TrackRow
interface PlaylistRepository {
suspend fun syncInitial()
suspend fun syncDelta(retainRemovedPlaylists: Boolean)
fun observePlaylists(): Flow<List<PlaylistSummary>>
suspend fun getPlaylists(): List<PlaylistSummary>
fun observeTracks(playlistId: String): Flow<List<TrackRow>>
suspend fun getTracks(playlistId: String): List<TrackRow>
}
internal fun PlaylistWithImages.toSummary(): PlaylistSummary {
val sortedImages = images.sortedBy { it.image_index }
return PlaylistSummary(
id = playlist.id,
name = playlist.name,
description = playlist.description,
primaryColor = playlist.primary_color,
snapshotId = playlist.snapshot_id,
tracksTotal = playlist.tracks_total,
imageUrls = sortedImages.map { it.url },
)
}

View File

@@ -0,0 +1,4 @@
package at.lockstep.jukebox.api
/** Thrown when the Lockstep API returns `ok: false` or an unexpected payload. */
class LockstepApiException(message: String) : Exception(message)

View File

@@ -0,0 +1,24 @@
package at.lockstep.jukebox.api
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
internal class PlaylistRemoteClient(
private val api: PlaylistRetrofitApi,
) {
suspend fun fetchPlaylistSummaries(): List<SimplifiedPlaylistDto> {
val body = api.getPlaylists()
if (!body.ok) {
throw LockstepApiException(body.error ?: "playlists request failed")
}
return body.items.orEmpty()
}
suspend fun fetchPlaylistDetail(id: String): FullPlaylistDto {
val body = api.getPlaylist(id)
if (!body.ok) {
throw LockstepApiException(body.error ?: "playlist detail failed for $id")
}
return body.playlist ?: throw LockstepApiException("playlist missing in response for $id")
}
}

View File

@@ -0,0 +1,14 @@
package at.lockstep.jukebox.api
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse
import at.lockstep.jukebox.api.dto.PlaylistListResponse
import retrofit2.http.GET
import retrofit2.http.Path
internal interface PlaylistRetrofitApi {
@GET("playlists")
suspend fun getPlaylists(): PlaylistListResponse
@GET("playlists/{id}")
suspend fun getPlaylist(@Path("id") id: String): PlaylistDetailResponse
}

View File

@@ -0,0 +1,79 @@
package at.lockstep.jukebox.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistListResponse(
val ok: Boolean,
val error: String? = null,
val total: Int? = null,
val items: List<SimplifiedPlaylistDto>? = null,
)
@Serializable
data class PlaylistDetailResponse(
val ok: Boolean,
val error: String? = null,
val playlist: FullPlaylistDto? = null,
)
@Serializable
data class ImageDto(
val url: String,
val height: Int? = null,
val width: Int? = null,
)
@Serializable
data class ArtistDto(
val id: String? = null,
val name: String? = null,
)
@Serializable
data class TrackDto(
val id: String,
val name: String,
val duration_ms: Int,
val artists: List<ArtistDto> = emptyList(),
)
@Serializable
data class PlaylistTrackItemDto(
val track: TrackDto? = null,
)
@Serializable
data class TracksPageDto(
val href: String? = null,
val total: Int? = null,
val items: List<PlaylistTrackItemDto> = emptyList(),
)
@Serializable
data class TracksStubDto(
val href: String? = null,
val total: Int? = null,
)
@Serializable
data class SimplifiedPlaylistDto(
val id: String,
val name: String,
val description: String? = null,
val images: List<ImageDto> = emptyList(),
val primary_color: String? = null,
val snapshot_id: String,
val tracks: TracksStubDto? = null,
)
@Serializable
data class FullPlaylistDto(
val id: String,
val name: String,
val description: String? = null,
val images: List<ImageDto> = emptyList(),
val primary_color: String? = null,
val snapshot_id: String,
val tracks: TracksPageDto? = null,
)

View File

@@ -0,0 +1,27 @@
package at.lockstep.jukebox.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
PlaylistEntity::class,
PlaylistImageEntity::class,
TrackEntity::class,
PlaylistTrackEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class JukeboxDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao
companion object {
fun create(context: Context, name: String = "jukebox.db"): JukeboxDatabase =
Room.databaseBuilder(context.applicationContext, JukeboxDatabase::class.java, name)
.fallbackToDestructiveMigration()
.build()
}
}

View File

@@ -0,0 +1,147 @@
package at.lockstep.jukebox.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface PlaylistDao {
@Upsert
suspend fun upsertPlaylists(playlists: List<PlaylistEntity>)
@Upsert
suspend fun upsertPlaylist(playlist: PlaylistEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertImages(images: List<PlaylistImageEntity>)
@Query("DELETE FROM playlist_images WHERE playlist_id = :playlistId")
suspend fun deleteImagesForPlaylist(playlistId: String)
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId")
suspend fun deletePlaylistTracksForPlaylist(playlistId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPlaylistTracks(rows: List<PlaylistTrackEntity>)
@Upsert
suspend fun upsertTracks(tracks: List<TrackEntity>)
@Transaction
@Query(
"""
SELECT * FROM playlists
ORDER BY name COLLATE NOCASE ASC
""",
)
fun observePlaylistsWithImages(): Flow<List<PlaylistWithImages>>
@Transaction
@Query(
"""
SELECT * FROM playlists
ORDER BY name COLLATE NOCASE ASC
""",
)
suspend fun getPlaylistsWithImages(): List<PlaylistWithImages>
@Query(
"""
SELECT pt.position AS position, pt.track_id AS trackId,
t.track_name AS trackName, t.artist_name AS artistName,
t.duration_ms AS durationMs
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
WHERE pt.playlist_id = :playlistId
ORDER BY pt.position ASC
""",
)
fun observeTracksForPlaylist(playlistId: String): Flow<List<TrackRow>>
@Query(
"""
SELECT pt.position AS position, pt.track_id AS trackId,
t.track_name AS trackName, t.artist_name AS artistName,
t.duration_ms AS durationMs
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
WHERE pt.playlist_id = :playlistId
ORDER BY pt.position ASC
""",
)
suspend fun getTracksForPlaylist(playlistId: String): List<TrackRow>
@Query("SELECT id, snapshot_id FROM playlists")
suspend fun getPlaylistSnapshots(): List<PlaylistSnapshotRow>
@Query("DELETE FROM playlists WHERE id IN (:ids)")
suspend fun deletePlaylistsByIds(ids: List<String>)
/**
* Removes [TrackEntity] rows that are not referenced by any [PlaylistTrackEntity].track_id.
* Run after removing playlists when you want a compact cache; skipped when retaining
* playlists removed on Spotify so those rows can keep referencing tracks.
*/
@Query(
"""
DELETE FROM tracks WHERE id NOT IN (
SELECT DISTINCT track_id FROM playlist_tracks WHERE track_id IS NOT NULL
)
""",
)
suspend fun deleteOrphanTracks()
@Query("DELETE FROM playlist_tracks")
suspend fun clearPlaylistTracks()
@Query("DELETE FROM playlist_images")
suspend fun clearImages()
@Query("DELETE FROM tracks")
suspend fun clearTracks()
@Query("DELETE FROM playlists")
suspend fun clearPlaylists()
@Transaction
suspend fun clearAllTables() {
clearPlaylistTracks()
clearImages()
clearTracks()
clearPlaylists()
}
/**
* Replaces images, track membership rows, and upserts tracks for one playlist in one transaction.
*/
@Transaction
suspend fun replacePlaylistContent(
playlist: PlaylistEntity,
images: List<PlaylistImageEntity>,
tracks: List<TrackEntity>,
playlistTracks: List<PlaylistTrackEntity>,
) {
upsertPlaylist(playlist)
deleteImagesForPlaylist(playlist.id)
if (images.isNotEmpty()) {
insertImages(images)
}
deletePlaylistTracksForPlaylist(playlist.id)
if (tracks.isNotEmpty()) {
upsertTracks(tracks)
}
if (playlistTracks.isNotEmpty()) {
insertPlaylistTracks(playlistTracks)
}
}
}
data class PlaylistSnapshotRow(
val id: String,
val snapshot_id: String,
)

View File

@@ -0,0 +1,71 @@
package at.lockstep.jukebox.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "playlists")
data class PlaylistEntity(
@PrimaryKey val id: String,
val description: String?,
val name: String,
val primary_color: String?,
val snapshot_id: String,
val tracks_href: String?,
val tracks_total: Int?,
)
@Entity(
tableName = "playlist_images",
primaryKeys = ["playlist_id", "image_index"],
foreignKeys = [
ForeignKey(
entity = PlaylistEntity::class,
parentColumns = ["id"],
childColumns = ["playlist_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("playlist_id")],
)
data class PlaylistImageEntity(
val playlist_id: String,
val image_index: Int,
val url: String,
val height: Int?,
val width: Int?,
)
@Entity(tableName = "tracks")
data class TrackEntity(
@PrimaryKey val id: String,
val track_name: String,
val artist_name: String,
val duration_ms: Int,
)
@Entity(
tableName = "playlist_tracks",
primaryKeys = ["playlist_id", "position"],
foreignKeys = [
ForeignKey(
entity = PlaylistEntity::class,
parentColumns = ["id"],
childColumns = ["playlist_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TrackEntity::class,
parentColumns = ["id"],
childColumns = ["track_id"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [Index("track_id")],
)
data class PlaylistTrackEntity(
val playlist_id: String,
val position: Int,
val track_id: String?,
)

View File

@@ -0,0 +1,14 @@
package at.lockstep.jukebox.db
import androidx.room.Embedded
import androidx.room.Relation
data class PlaylistWithImages(
@Embedded val playlist: PlaylistEntity,
@Relation(
parentColumn = "id",
entityColumn = "playlist_id",
entity = PlaylistImageEntity::class,
)
val images: List<PlaylistImageEntity>,
)

View File

@@ -0,0 +1,10 @@
package at.lockstep.jukebox.db
/** One row in an ordered playlist listing (join of [PlaylistTrackEntity] and [TrackEntity]). */
data class TrackRow(
val position: Int,
val trackId: String?,
val trackName: String?,
val artistName: String?,
val durationMs: Int?,
)

View File

@@ -0,0 +1,56 @@
package at.lockstep.jukebox.map
import at.lockstep.jukebox.api.dto.ArtistDto
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.ImageDto
import at.lockstep.jukebox.db.PlaylistEntity
import at.lockstep.jukebox.db.PlaylistImageEntity
import at.lockstep.jukebox.db.PlaylistTrackEntity
import at.lockstep.jukebox.db.TrackEntity
internal fun List<ArtistDto>.toArtistDisplayName(): String {
val names = mapNotNull { dto -> dto.name?.takeIf { it.isNotBlank() } }
return if (names.isEmpty()) "Unknown Artist" else names.joinToString(", ")
}
internal fun ImageDto.toEntity(playlistId: String, index: Int): PlaylistImageEntity =
PlaylistImageEntity(
playlist_id = playlistId,
image_index = index,
url = url,
height = height,
width = width,
)
internal fun FullPlaylistDto.toPlaylistEntity(): PlaylistEntity =
PlaylistEntity(
id = id,
description = description,
name = name,
primary_color = primary_color,
snapshot_id = snapshot_id,
tracks_href = tracks?.href,
tracks_total = tracks?.total,
)
/** Maps a full playlist into rows for SQLite (playlist row was upserted separately if needed). */
internal fun FullPlaylistDto.toPlaylistStorageRows(): Triple<List<PlaylistImageEntity>, List<TrackEntity>, List<PlaylistTrackEntity>> {
val images = images.mapIndexed { index, dto -> dto.toEntity(id, index) }
val items = tracks?.items.orEmpty()
val trackEntities = items.mapNotNull { it.track }.map { dto ->
TrackEntity(
id = dto.id,
track_name = dto.name,
artist_name = dto.artists.toArtistDisplayName(),
duration_ms = dto.duration_ms,
)
}.distinctBy { it.id }
val playlistTrackEntities = items.mapIndexed { index, wrapper ->
PlaylistTrackEntity(
playlist_id = id,
position = index,
track_id = wrapper.track?.id,
)
}
return Triple(images, trackEntities, playlistTrackEntities)
}

View File

@@ -0,0 +1,70 @@
package at.lockstep.jukebox.sync
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.db.PlaylistDao
import at.lockstep.jukebox.map.toPlaylistEntity
import at.lockstep.jukebox.map.toPlaylistStorageRows
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
internal class SyncCoordinator(
private val dao: PlaylistDao,
private val remote: PlaylistRemoteClient,
private val ioDispatcher: CoroutineContext,
private val detailParallelism: Int = 6,
) {
suspend fun syncInitial(): Unit = withContext(ioDispatcher) {
dao.clearAllTables()
val summaries = remote.fetchPlaylistSummaries()
val details = fetchDetailsParallel(summaries.map { it.id })
for (detail in details) {
persistFullPlaylist(detail)
}
}
suspend fun syncDelta(retainRemovedPlaylists: Boolean): Unit = withContext(ioDispatcher) {
val remoteSummaries = remote.fetchPlaylistSummaries()
val localSnapshots = dao.getPlaylistSnapshots().associate { it.id to it.snapshot_id }
val idsToRefresh = remoteSummaries
.filter { summary ->
localSnapshots[summary.id] != summary.snapshot_id
}
.map { it.id }
val details = fetchDetailsParallel(idsToRefresh)
for (detail in details) {
persistFullPlaylist(detail)
}
if (!retainRemovedPlaylists) {
val remoteIds = remoteSummaries.map { it.id }.toSet()
val removedLocally = localSnapshots.keys.filter { it !in remoteIds }
if (removedLocally.isNotEmpty()) {
dao.deletePlaylistsByIds(removedLocally)
dao.deleteOrphanTracks()
}
}
}
private suspend fun fetchDetailsParallel(ids: List<String>): List<FullPlaylistDto> {
if (ids.isEmpty()) return emptyList()
return coroutineScope {
val semaphore = Semaphore(detailParallelism)
ids.map { id ->
async {
semaphore.withPermit { remote.fetchPlaylistDetail(id) }
}
}.awaitAll()
}
}
private suspend fun persistFullPlaylist(detail: FullPlaylistDto) {
val playlist = detail.toPlaylistEntity()
val (images, tracks, playlistTracks) = detail.toPlaylistStorageRows()
dao.replacePlaylistContent(playlist, images, tracks, playlistTracks)
}
}

View File

@@ -0,0 +1,172 @@
package at.lockstep.jukebox.sync
import android.app.Application
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import at.lockstep.jukebox.api.LockstepApiException
import at.lockstep.jukebox.api.PlaylistRemoteClient
import at.lockstep.jukebox.api.PlaylistRetrofitApi
import at.lockstep.jukebox.api.dto.FullPlaylistDto
import at.lockstep.jukebox.api.dto.ImageDto
import at.lockstep.jukebox.api.dto.PlaylistDetailResponse
import at.lockstep.jukebox.api.dto.PlaylistListResponse
import at.lockstep.jukebox.api.dto.PlaylistTrackItemDto
import at.lockstep.jukebox.api.dto.SimplifiedPlaylistDto
import at.lockstep.jukebox.api.dto.TrackDto
import at.lockstep.jukebox.api.dto.TracksPageDto
import at.lockstep.jukebox.db.JukeboxDatabase
import at.lockstep.jukebox.map.toPlaylistEntity
import at.lockstep.jukebox.map.toPlaylistStorageRows
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class SyncCoordinatorTest {
private lateinit var db: JukeboxDatabase
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Application>()
db = Room.inMemoryDatabaseBuilder(context, JukeboxDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun syncDelta_sameSnapshot_skipsDetailFetch() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "snap1"),
)
remote.detailById["p1"] = detailWithTrack("p1", "snap1", "t1", "Song")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.detailCallIds.clear()
coordinator.syncDelta(retainRemovedPlaylists = true)
assertTrue(remote.detailCallIds.isEmpty())
}
@Test
fun syncDelta_changedSnapshot_refetchesDetail() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
remote.detailById["p1"] = detailWithTrack("p1", "s1", "t1", "Old")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s2"),
)
remote.detailById["p1"] = detailWithTrack("p1", "s2", "t2", "New")
remote.detailCallIds.clear()
coordinator.syncDelta(retainRemovedPlaylists = true)
assertEquals(listOf("p1"), remote.detailCallIds)
val tracks = db.playlistDao().getTracksForPlaylist("p1")
assertEquals("t2", tracks.single().trackId)
assertEquals("New", tracks.single().trackName)
}
@Test
fun syncDelta_removesPlaylistWhenNotRetained() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
SimplifiedPlaylistDto(id = "gone", name = "B", snapshot_id = "sg"),
)
remote.detailById["p1"] = minimalDetail("p1", "s1")
remote.detailById["gone"] = minimalDetail("gone", "sg")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
coordinator.syncDelta(retainRemovedPlaylists = false)
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
assertEquals(setOf("p1"), ids)
}
@Test
fun syncDelta_retainsRemovedPlaylist() = runBlocking {
val remote = FakeRemote()
remote.listItems = listOf(
SimplifiedPlaylistDto(id = "p1", name = "A", snapshot_id = "s1"),
)
remote.detailById["p1"] = minimalDetail("p1", "s1")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
val orphan = minimalDetail("orphan", "so")
val (im, tr, pt) = orphan.toPlaylistStorageRows()
db.playlistDao().replacePlaylistContent(orphan.toPlaylistEntity(), im, tr, pt)
coordinator.syncDelta(retainRemovedPlaylists = true)
val ids = db.playlistDao().getPlaylistSnapshots().map { it.id }.toSet()
assertTrue(ids.contains("orphan"))
assertTrue(ids.contains("p1"))
}
@Test(expected = LockstepApiException::class)
fun listOkFalse_throws() = runBlocking {
val remote = FakeRemote()
remote.listResponseOverride = PlaylistListResponse(ok = false, error = "nope")
val coordinator = SyncCoordinator(db.playlistDao(), PlaylistRemoteClient(remote.api), Dispatchers.IO)
coordinator.syncInitial()
}
private fun detailWithTrack(
id: String,
snapshot: String,
trackId: String,
trackName: String,
) = FullPlaylistDto(
id = id,
name = id,
snapshot_id = snapshot,
images = listOf(ImageDto(url = "https://x.example/a.png")),
tracks = TracksPageDto(
items = listOf(
PlaylistTrackItemDto(
track = TrackDto(id = trackId, name = trackName, duration_ms = 1000),
),
),
),
)
private fun minimalDetail(id: String, snapshot: String) = detailWithTrack(id, snapshot, "t$id", "Song")
private class FakeRemote {
val detailCallIds = mutableListOf<String>()
var listItems: List<SimplifiedPlaylistDto> = emptyList()
val detailById = mutableMapOf<String, FullPlaylistDto>()
var listResponseOverride: PlaylistListResponse? = null
val api: PlaylistRetrofitApi = object : PlaylistRetrofitApi {
override suspend fun getPlaylists(): PlaylistListResponse =
listResponseOverride ?: PlaylistListResponse(
ok = true,
total = listItems.size,
items = listItems,
)
override suspend fun getPlaylist(id: String): PlaylistDetailResponse {
detailCallIds.add(id)
val pl = detailById[id] ?: error("no detail for $id")
return PlaylistDetailResponse(ok = true, playlist = pl)
}
}
}
}

45
schema/playlist_cache.sql Normal file
View File

@@ -0,0 +1,45 @@
-- Schema derived from docs/playlists.md only.
-- Stores playlists, track list membership, and tracks (artist display name on track row).
-- Does not persist album objects or album.artists.
PRAGMA foreign_keys = ON;
-- SimplifiedPlaylistObject / PlaylistObject
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
description TEXT,
name TEXT NOT NULL,
primary_color TEXT,
snapshot_id TEXT NOT NULL,
tracks_href TEXT,
tracks_total INTEGER
);
CREATE TABLE playlist_images (
playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
image_index INTEGER NOT NULL,
url TEXT NOT NULL,
height INTEGER,
width INTEGER,
PRIMARY KEY (playlist_id, image_index)
);
-- TrackObject + display of track.artists[].name (single column; if the API lists
-- multiple artists, join their names e.g. "A, B" in documented order).
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
duration_ms INTEGER NOT NULL
);
-- Order of tracks in a playlist (matches playlist.tracks.items[] order).
-- track_id NULL when the API returns a removed track (wrapper with track: null).
CREATE TABLE playlist_tracks (
playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
position INTEGER NOT NULL,
track_id TEXT REFERENCES tracks (id) ON DELETE SET NULL,
PRIMARY KEY (playlist_id, position)
);
CREATE INDEX idx_playlist_tracks_track ON playlist_tracks (track_id);

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "lockstep-jukebox"
include(":jukebox")