Skip to content
This repository has been archived by the owner on Mar 30, 2024. It is now read-only.

Commit

Permalink
«Your Library»: working write + initial paging (without deltas for no…
Browse files Browse the repository at this point in the history
…w), with RoomDB cache + Artist/Album full support

Signed-off-by: iTaysonLab <[email protected]>
  • Loading branch information
iTaysonLab committed May 8, 2022
1 parent 63017d3 commit f387e56
Show file tree
Hide file tree
Showing 20 changed files with 813 additions and 20 deletions.
6 changes: 6 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ dependencies {

implementation "io.coil-kt:coil-compose:2.0.0-rc03"
implementation 'com.google.accompanist:accompanist-systemuicontroller:0.24.6-alpha'
implementation 'com.google.accompanist:accompanist-pager:0.24.6-alpha'

implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
Expand All @@ -123,6 +124,11 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
implementation "com.squareup.retrofit2:converter-protobuf:2.9.0"

implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("androidx.room:room-paging:2.5.0-alpha01")
kapt "androidx.room:room-compiler:$roomVersion"
}

protobuf {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ object DeviceIdProvider {
Build.MODEL
}
}

fun getRandomString(length: Int) : String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..length).map { allowedChars.random() }.joinToString("")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ package bruhcollective.itaysonlab.jetispot.core.api

import com.spotify.collection2.v2.proto.Collection2V2
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST

interface SpCollectionApi {
@POST("write")
@Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto")
suspend fun write(@Body data: Collection2V2.WriteRequest)

@POST("delta")
@Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto")
suspend fun delta(@Body data: Collection2V2.DeltaRequest): Collection2V2.DeltaResponse

@POST("paging")
@Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto")
suspend fun paging(@Body data: Collection2V2.PageRequest): Collection2V2.PageResponse
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,276 @@
package bruhcollective.itaysonlab.jetispot.core.collection

import android.util.Log
import bruhcollective.itaysonlab.jetispot.core.DeviceIdProvider
import bruhcollective.itaysonlab.jetispot.core.SpSessionManager
import bruhcollective.itaysonlab.jetispot.core.api.SpCollectionApi
import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionRepository
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack
import com.google.protobuf.ByteString
import com.spotify.collection2.v2.proto.Collection2V2
import com.spotify.extendedmetadata.ExtendedMetadata
import com.spotify.extendedmetadata.ExtensionKindOuterClass
import com.spotify.metadata.Metadata
import kotlinx.coroutines.*
import xyz.gianlu.librespot.common.Utils
import xyz.gianlu.librespot.metadata.*
import java.util.concurrent.Executors
import javax.inject.Inject

class SpCollectionManager @Inject constructor(
private val spSessionManager: SpSessionManager,
private val collectionApi: SpCollectionApi
): CoroutineScope by CoroutineScope(Dispatchers.IO + SupervisorJob()) {
private val collectionApi: SpCollectionApi,
private val dbRepository: LocalCollectionRepository
) {
// it's important to use queuing here
private val scopeDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val scope = CoroutineScope(scopeDispatcher + SupervisorJob())

private val pendingWriteOperations = mutableListOf<CollectionWriteOp>()

sealed class CollectionWriteOp {
class Add(val spId: String): CollectionWriteOp() { val additionDate = System.currentTimeMillis() / 1000L }
class Remove(val spId: String): CollectionWriteOp()
sealed class CollectionWriteOp(val toSet: String) {
class Add(val spId: String, toSet: String) : CollectionWriteOp(toSet) {
val additionDate = (System.currentTimeMillis() / 1000L).toInt()
}

class Remove(val spId: String, toSet: String) : CollectionWriteOp(toSet)
}

private suspend fun processPendingWrites() {
if (pendingWriteOperations.isEmpty()) return

collectionApi.write(Collection2V2.WriteRequest.newBuilder().apply {
username = spSessionManager.session.username()
set = "collection"
clientUpdateId = DeviceIdProvider.getRandomString(16)
addAllItems(pendingWriteOperations.map {
when (it) {
is CollectionWriteOp.Add -> Collection2V2.CollectionItem.newBuilder().setUri(it.spId)
.setAddedAt(it.additionDate).build()
is CollectionWriteOp.Remove -> Collection2V2.CollectionItem.newBuilder().setUri(it.spId)
.setIsRemoved(true).build()
}
})
}.build())
}

// scans network collection for tracks, albums, pins and artists
suspend fun scan() = withContext(scopeDispatcher) {
performScan("collection")
performScan("artist")
// performScan("ylpin")
}

suspend fun artists() = withContext(scopeDispatcher) {
performScanIfEmpty("artist")
dbRepository.getArtists()
}

suspend fun albums() = withContext(scopeDispatcher) {
performScanIfEmpty("collection")
dbRepository.getAlbums()
}

private suspend fun performScanIfEmpty(of: String) {
dbRepository.getCollection(of) ?: performScan(of)
}

/**
* scan&add all info needed
*
* for "collection"
* - fetch data (ID's: albums and tracks)
* - fetch metadata (albums&tracks - title/artist/picture, also fetch artists to get genre information)
* - save to DB
*
* for "artist" + "ylpin"
* - fetch data, metadata and save to DB
*/
private suspend fun performScan(of: String) {
Log.d("SpColManager", "Performing scan of $of")

val data = performPagingScan(of)

Log.d("SpColManager", "Scan of $of completed [total = ${data.first.size}]")
}

init {
launch {
while (true) {
processPendingWrites()
delay(5000L)
private fun spotifyIdToKind(id: String) = when (id.split(":")[1]) {
"track" -> ExtensionKindOuterClass.ExtensionKind.TRACK_V4
"album" -> ExtensionKindOuterClass.ExtensionKind.ALBUM_V4
"artist" -> ExtensionKindOuterClass.ExtensionKind.ARTIST_V4
else -> ExtensionKindOuterClass.ExtensionKind.UNKNOWN_EXTENSION
}

private fun bytesToPicUrl(bytes: ByteString) = ImageId.fromHex(Utils.bytesToHex(bytes)).hexId()

private suspend fun performPagingScan(of: String): Pair<List<Collection2V2.CollectionItem>, String> {
val items = mutableListOf<Collection2V2.CollectionItem>()

val syncToken: String
var pToken = ""

while (true) {
Log.d("SpColManager", "Performing page request of $of [pToken = $pToken]")

val page = collectionApi.paging(Collection2V2.PageRequest.newBuilder().apply {
username = spSessionManager.session.username()
set = of
paginationToken = pToken
limit = 300
}.build())

items.addAll(page.itemsList)

Log.d(
"SpColManager",
"Performing page metadata request of $of [count = ${page.itemsList.size}]"
)

val requests = mutableListOf<ExtendedMetadata.EntityRequest>()

page.itemsList.forEach { ci ->
val kind = spotifyIdToKind(ci.uri)

if (kind != ExtensionKindOuterClass.ExtensionKind.UNKNOWN_EXTENSION) {
requests.add(
ExtendedMetadata.EntityRequest.newBuilder().setEntityUri(ci.uri)
.addQuery(ExtendedMetadata.ExtensionQuery.newBuilder().setExtensionKind(kind).build())
.build()
)
}
}

val metadata = UnpackedMetadataResponse(
spSessionManager.session.api().getExtendedMetadata(
ExtendedMetadata.BatchedEntityRequest.newBuilder().addAllEntityRequest(requests).build()
).extendedMetadataList
)

if (metadata.tracks.isNotEmpty()) {
// also request artists to get genre data

val mappedArtists = UnpackedMetadataResponse(spSessionManager.session.api()
.getExtendedMetadata(
ExtendedMetadata.BatchedEntityRequest.newBuilder().addAllEntityRequest(
metadata.tracks.map {
ExtendedMetadata.EntityRequest.newBuilder().setEntityUri(ArtistId.fromHex(Utils.bytesToHex(it.value.artistList.first().gid)).toSpotifyUri()).addQuery(
ExtendedMetadata.ExtensionQuery.newBuilder()
.setExtensionKind(ExtensionKindOuterClass.ExtensionKind.ARTIST_V4).build()
).build()
}.distinctBy { it.entityUri }
).build()
)
.extendedMetadataList
).artists.map {
CollectionArtistMetadata(
Utils.bytesToHex(it.value.gid),
it.value.genreList.joinToString("|")
)
}.toTypedArray()

dbRepository.insertMetaArtists(*mappedArtists)
}

val mappedRequest = page.itemsList.associate { Pair(it.uri, it.addedAt) }

dbRepository.insertTracks(*metadata.tracks.values.map { track ->
CollectionTrack(
id = TrackId.fromHex(Utils.bytesToHex(track.gid)).hexId(),
uri = TrackId.fromHex(Utils.bytesToHex(track.gid)).toSpotifyUri(),
name = track.name,
albumId = Utils.bytesToHex(track.album.gid),
albumName = track.album.name,
mainArtistId = Utils.bytesToHex(track.artistList.first().gid),
rawArtistsData = track.artistList.joinToString("|") { artist -> "${Utils.bytesToHex(artist.gid)}=${artist.name}" },
hasLyrics = track.hasLyrics,
isExplicit = track.explicit,
duration = track.duration,
picture = bytesToPicUrl(track.album.coverGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId),
addedAt = mappedRequest[TrackId.fromHex(Utils.bytesToHex(track.gid)).toSpotifyUri()]!!
)
}.toTypedArray())

dbRepository.insertAlbums(*metadata.albums.values.map { album ->
CollectionAlbum(
id = AlbumId.fromHex(Utils.bytesToHex(album.gid)).hexId(),
uri = AlbumId.fromHex(Utils.bytesToHex(album.gid)).toSpotifyUri(),
rawArtistsData = album.artistList.joinToString("|") { artist -> "${Utils.bytesToHex(artist.gid)}=${artist.name}" },
name = album.name,
picture = bytesToPicUrl(album.coverGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId),
addedAt = mappedRequest[AlbumId.fromHex(Utils.bytesToHex(album.gid)).toSpotifyUri()]!!
)
}.toTypedArray())

dbRepository.insertArtists(*metadata.artists.values.map { artist ->
CollectionArtist(
id = ArtistId.fromHex(Utils.bytesToHex(artist.gid)).hexId(),
uri = ArtistId.fromHex(Utils.bytesToHex(artist.gid)).toSpotifyUri(),
name = artist.name,
picture = bytesToPicUrl(artist.portraitGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId),
addedAt = mappedRequest[ArtistId.fromHex(Utils.bytesToHex(artist.gid)).toSpotifyUri()]!!
)
}.toTypedArray())

if (!page.nextPageToken.isNullOrEmpty()) {
// next page
pToken = page.nextPageToken
} else {
// no more pages remain
syncToken = page.syncToken
break
}
}

dbRepository.insertOrUpdateCollection(of, syncToken)
return Pair(items, syncToken)
}

private suspend fun processPendingWrites() {
if (pendingWriteOperations.isEmpty()) return
class UnpackedMetadataResponse(
dataArray: List<ExtendedMetadata.EntityExtensionDataArray>
) {
var tracks: Map<String, Metadata.Track> = mapOf()
private set

/*collectionApi.write(Collection2V2.WriteRequest.newBuilder().apply {
setUsername("")
setSet("")
addAllItems()
setClientUpdateId(0)
}.build())*/
var artists: Map<String, Metadata.Artist> = mapOf()
private set

var albums: Map<String, Metadata.Album> = mapOf()
private set

init {
dataArray.forEach { arr ->
when (arr.extensionKind) {
ExtensionKindOuterClass.ExtensionKind.TRACK_V4 -> {
tracks = arr.extensionDataList.associate {
Pair(
it.entityUri,
Metadata.Track.parseFrom(it.extensionData.value)
)
}
}

ExtensionKindOuterClass.ExtensionKind.ALBUM_V4 -> {
albums = arr.extensionDataList.associate {
Pair(
it.entityUri,
Metadata.Album.parseFrom(it.extensionData.value)
)
}
}

ExtensionKindOuterClass.ExtensionKind.ARTIST_V4 -> {
artists = arr.extensionDataList.associate {
Pair(
it.entityUri,
Metadata.Artist.parseFrom(it.extensionData.value)
)
}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package bruhcollective.itaysonlab.jetispot.core.collection.db

import androidx.room.*
import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata
import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack

@Dao
interface LocalCollectionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateCollectionCategory(item: LocalCollectionCategory)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addTracks(vararg items: CollectionTrack)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addArtists(vararg items: CollectionArtist)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addMetaArtists(vararg items: CollectionArtistMetadata)

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addAlbums(vararg items: CollectionAlbum)

@Query("DELETE FROM lcTracks WHERE id IN (:ids)")
suspend fun deleteTracks(vararg ids: String)

@Query("DELETE FROM lcArtists WHERE id IN (:ids)")
suspend fun deleteArtists(vararg ids: String)

@Query("DELETE FROM lcMetaArtists WHERE id IN (:ids)")
suspend fun deleteMetaArtists(vararg ids: String)

@Query("DELETE FROM lcAlbums WHERE id IN (:ids)")
suspend fun deleteAlbums(vararg ids: String)

@Query("SELECT * from lcArtists ORDER BY addedAt DESC")
suspend fun getArtists(): List<CollectionArtist>

@Query("SELECT * from lcAlbums ORDER BY addedAt DESC")
suspend fun getAlbums(): List<CollectionAlbum>

@Query("SELECT * from lcTypes WHERE type = :of")
suspend fun getCollection(of: String): LocalCollectionCategory?
}
Loading

0 comments on commit f387e56

Please sign in to comment.