mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
refactor: remove MusicBrainz ListenBrainz plugin and related configurations
This commit is contained in:
parent
0c426689cb
commit
28ff68dcc9
@ -22,7 +22,6 @@
|
||||
- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
|
||||
|
||||
## Codegen and plugin packaging
|
||||
- OpenAPI client generated from `composeApp/specs/listenbrainz-openapi.yaml` via KMPGen tasks (`kmpgenPrepare`, `kmpgenGenerateAll`) in `:composeApp`.
|
||||
- JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`.
|
||||
- Zipline plugin entrypoint: `mainFunction = "dev.krtirtho.js_plugin_example.main"` in `js_plugin_example/build.gradle.kts`. Plugin metadata from `js_plugin_example/plugin.json`.
|
||||
|
||||
|
||||
@ -29,6 +29,5 @@ plugins {
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
|
||||
alias(libs.plugins.zipline.gradle.plugin) apply false
|
||||
alias(libs.plugins.spotubeGradle) apply false
|
||||
alias(libs.plugins.kmpgen) apply false
|
||||
alias(libs.plugins.vlcjBundler) apply false
|
||||
}
|
||||
@ -29,7 +29,6 @@ plugins {
|
||||
alias(libs.plugins.composeHotReload)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.zipline.gradle.plugin)
|
||||
alias(libs.plugins.kmpgen)
|
||||
alias(libs.plugins.vlcjBundler)
|
||||
}
|
||||
|
||||
@ -38,14 +37,6 @@ vlcjBundler {
|
||||
objectName = "VLCBundleLoaderGenerated" // or rename the object
|
||||
}
|
||||
|
||||
kmpgen {
|
||||
spec(
|
||||
packageName = "dev.krtirtho.spotube.listenbrainz"
|
||||
) {
|
||||
specFile = file("./specs/listenbrainz-openapi.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
|
||||
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
|
||||
@ -103,6 +94,8 @@ kotlin {
|
||||
// ktor
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.cio)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -21,27 +21,20 @@ import app.cash.zipline.ZiplineService
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.common.RealCoreAPI
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.createMusicbrainzListenbrainzPluginAPIs
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN
|
||||
import dev.krtirtho.spotube.modules.plugin.MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN
|
||||
import dev.krtirtho.spotube.modules.plugin.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class BuiltInPluginService(
|
||||
@ -52,10 +45,10 @@ class BuiltInPluginService(
|
||||
override val loggedInFlow = loggedInStateFlow.asStateFlow()
|
||||
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
val webViewController: WebViewController by inject()
|
||||
// val webViewController: WebViewController by inject()
|
||||
// val persistedStorage by lazy { RealPersistedStorageAPI(pluginInfo) }
|
||||
|
||||
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val persistedStorage by lazy { RealPersistedStorageAPI(pluginInfo) }
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = servicesRegistry[CoreAPI::class] as CoreAPI?
|
||||
@ -72,17 +65,6 @@ class BuiltInPluginService(
|
||||
servicesRegistry[AudioAPI::class] = RealNewPipeAudioAPI()
|
||||
}
|
||||
|
||||
MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN -> {
|
||||
servicesRegistry.putAll(
|
||||
createMusicbrainzListenbrainzPluginAPIs(
|
||||
scope = scope,
|
||||
httpClient = HttpClient(),
|
||||
webViewController = webViewController,
|
||||
persistedStorage = persistedStorage
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LRCLIB_BUILT_IN_PLUGIN -> {
|
||||
servicesRegistry[CoreAPI::class] = RealCoreAPI()
|
||||
servicesRegistry[LyricsAPI::class] = RealLRCLibLyricsAPI()
|
||||
|
||||
@ -1,469 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
import dev.krtirtho.spotube.listenbrainz.Api
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbPlaylistsApi
|
||||
import dev.krtirtho.spotube.listenbrainz.models.CreatePlaylistRequest
|
||||
import dev.krtirtho.spotube.listenbrainz.models.ItemDeleteRequest
|
||||
import dev.krtirtho.spotube.listenbrainz.models.Playlist
|
||||
import dev.krtirtho.spotube.listenbrainz.models.PlaylistExtension
|
||||
import dev.krtirtho.spotube.listenbrainz.models.PlaylistExtensionPayload
|
||||
import dev.krtirtho.spotube.listenbrainz.models.PlaylistTrackInner
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class EmulatedAlbumArtist(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI,
|
||||
) {
|
||||
private val playlistCache = mutableMapOf<String, List<PlaylistTrackInner>>()
|
||||
private var cachedUsername: String? = null
|
||||
|
||||
suspend fun savedAlbums(
|
||||
pagination: PaginationStrategy?,
|
||||
filterIds: List<String>
|
||||
): PaginationResult<MetadataAlbum.Detailed> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username)
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
val albums = tracks.toAlbums()
|
||||
|
||||
val filtered = if (filterIds.isNotEmpty()) {
|
||||
albums.filter { filterIds.contains(it.id) }
|
||||
} else albums
|
||||
|
||||
val slice = filtered.drop(paging.offset).take(paging.limit)
|
||||
val nextOffset = if (paging.offset + paging.limit < filtered.size) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
cacheSavedAlbumIdsFromAlbumsDetailed(albums)
|
||||
|
||||
return PaginationResult(
|
||||
items = slice,
|
||||
totalCount = filtered.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun isSavedAlbums(ids: List<String>): List<Boolean> {
|
||||
val cached = persistedStorage.getString("saved_album_ids")?.takeIf { it.isNotBlank() }
|
||||
?.split(",")
|
||||
?.filter { it.isNotBlank() }
|
||||
val savedIds = cached ?: loadSavedAlbumIds()
|
||||
return ids.map { savedIds.contains(it) }
|
||||
}
|
||||
|
||||
suspend fun saveAlbums(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
val alreadySaved = isSavedAlbums(ids)
|
||||
if (alreadySaved.any { it }) {
|
||||
throw IllegalStateException("Some albums are already saved")
|
||||
}
|
||||
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username)
|
||||
|
||||
val recordingIds = ids.mapNotNull { albumId ->
|
||||
musicbrainzRepository.searchRecordings(query = "rgid:$albumId", limit = 1, offset = 0)
|
||||
.recordings
|
||||
.firstOrNull()?.id
|
||||
}
|
||||
|
||||
if (recordingIds.isEmpty()) return
|
||||
|
||||
val body = Playlist(
|
||||
track = recordingIds.map { recId ->
|
||||
PlaylistTrackInner(
|
||||
identifier = listOf("https://musicbrainz.org/recording/$recId")
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val tracks = getPlaylistTracks(playlistId, false)
|
||||
val offset = tracks.size.toLong()
|
||||
|
||||
LbPlaylistsApi.appendRecordings(Uuid.parse(playlistId), offset, body)
|
||||
|
||||
playlistCache.remove(playlistId)
|
||||
cacheSavedAlbumIds(loadSavedAlbumIds().plus(ids).distinct())
|
||||
}
|
||||
|
||||
suspend fun removeSavedAlbums(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username)
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
|
||||
val releaseIds = tracks.mapNotNull { it.extractReleaseId() }
|
||||
val releaseGroups = musicbrainzRepository.searchReleases(
|
||||
query = releaseIds.joinToString(" OR ") { "reid:$it" },
|
||||
limit = releaseIds.size,
|
||||
offset = 0
|
||||
).releases
|
||||
|
||||
val releaseIdToGroup = releaseGroups.associate { release ->
|
||||
val releaseId = release.id
|
||||
val groupId = release.releaseGroup?.id ?: release.id
|
||||
releaseId to groupId
|
||||
}
|
||||
|
||||
val indexes = tracks.mapIndexedNotNull { idx, track ->
|
||||
val releaseId = track.extractReleaseId()
|
||||
val groupId = releaseIdToGroup[releaseId]
|
||||
if (groupId != null && ids.contains(groupId)) idx else null
|
||||
}
|
||||
|
||||
if (indexes.isEmpty()) return
|
||||
|
||||
indexes.forEach { index ->
|
||||
LbPlaylistsApi.itemDelete(
|
||||
Uuid.parse(playlistId),
|
||||
ItemDeleteRequest(
|
||||
index = index.toLong(),
|
||||
count = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
playlistCache.remove(playlistId)
|
||||
cacheSavedAlbumIds(loadSavedAlbumIds().filterNot { ids.contains(it) })
|
||||
}
|
||||
|
||||
private suspend fun loadSavedAlbumIds(): List<String> {
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username)
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
val releaseIds = tracks.mapNotNull { it.extractReleaseId() }
|
||||
if (releaseIds.isEmpty()) return emptyList()
|
||||
|
||||
val releases = musicbrainzRepository.searchReleases(
|
||||
query = releaseIds.joinToString(" OR ") { "reid:$it" },
|
||||
limit = releaseIds.size,
|
||||
offset = 0
|
||||
).releases
|
||||
|
||||
val albumIds = releases.map { it.releaseGroup?.id ?: it.id }
|
||||
cacheSavedAlbumIds(albumIds)
|
||||
return albumIds
|
||||
}
|
||||
|
||||
private suspend fun getOrCreatePlaylistId(username: String, type: String = "album"): String {
|
||||
val key = "saved_${type}_playlist_id"
|
||||
persistedStorage.getString(key)?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
|
||||
val playlistName = "$username saved ${type}s by Spotube"
|
||||
val existing = searchPlaylist(username, playlistName)
|
||||
val playlistId = existing ?: createPlaylist(playlistName, type)
|
||||
persistedStorage.putString(key, playlistId)
|
||||
return playlistId
|
||||
}
|
||||
|
||||
private suspend fun searchPlaylist(username: String, name: String): String? {
|
||||
val response = LbCoreApi.searchPlaylistForUser(
|
||||
playlistUserName = username,
|
||||
query = name,
|
||||
count = 1,
|
||||
offset = 0
|
||||
)
|
||||
|
||||
val playlists = response.getOrNull()?.data?.playlists
|
||||
val match = playlists?.firstOrNull { element ->
|
||||
val playlist = element.playlist
|
||||
val creator = playlist?.creator
|
||||
val title = playlist?.title
|
||||
creator == username && title == name
|
||||
}
|
||||
|
||||
return match?.playlist?.identifier?.substringAfterLast('/')
|
||||
}
|
||||
|
||||
private suspend fun createPlaylist(name: String, type: String): String {
|
||||
val body = CreatePlaylistRequest(
|
||||
playlist = Playlist(
|
||||
title = name,
|
||||
annotation = "This playlist contains all ${type}s saved by Spotube. Autogenerated. Do not edit.",
|
||||
extension = PlaylistExtension(
|
||||
httpsMusicbrainzOrgDocJspfPlaylist = PlaylistExtensionPayload(
|
||||
collaborators = emptyList(),
|
||||
public = false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = LbPlaylistsApi.createPlaylist(body)
|
||||
|
||||
val bodyJson = response.getOrNull()?.data
|
||||
return bodyJson?.playlistMbid?.toString()
|
||||
?: throw IllegalStateException("Unable to create playlist")
|
||||
}
|
||||
|
||||
private suspend fun getPlaylistTracks(
|
||||
playlistId: String,
|
||||
fetchMetadata: Boolean
|
||||
): List<PlaylistTrackInner> {
|
||||
playlistCache[playlistId]?.let { return it }
|
||||
|
||||
val response = LbPlaylistsApi.fetchPlaylist(
|
||||
playlistMbid = Uuid.parse(playlistId),
|
||||
fetchMetadata = fetchMetadata
|
||||
)
|
||||
|
||||
val tracks = response.getOrNull()?.data?.playlist?.track
|
||||
?: emptyList()
|
||||
|
||||
playlistCache[playlistId] = tracks
|
||||
return tracks
|
||||
}
|
||||
|
||||
private suspend fun requireUsername(): String {
|
||||
cachedUsername?.let { return it }
|
||||
|
||||
// Setup auth provider globally
|
||||
val auth = Auth.ApiKeyAuth {
|
||||
val token =
|
||||
persistedStorage.getString("listenbrainz_auth_token") ?: return@ApiKeyAuth null
|
||||
if (token.startsWith("Token ", ignoreCase = true)) token else "Token $token"
|
||||
}
|
||||
Api.setAuthProvider(auth)
|
||||
|
||||
val username = when (val res = LbCoreApi.validateToken()) {
|
||||
is Either.Left -> throw IllegalStateException("Unable to resolve ListenBrainz username: ${res.value}")
|
||||
is Either.Right -> res.value.data.userName
|
||||
}
|
||||
cachedUsername = username!!
|
||||
return username
|
||||
}
|
||||
|
||||
private fun PlaylistTrackInner.extractReleaseId(): String? {
|
||||
val extension = this.extension?.httpsMusicbrainzOrgDocJspfTrack
|
||||
?: return null
|
||||
val additional = extension.additionalMetadata ?: return null
|
||||
return additional.caaReleaseMbid?.toString()
|
||||
}
|
||||
|
||||
private suspend fun List<PlaylistTrackInner>.toAlbums(): List<MetadataAlbum.Detailed> {
|
||||
val releaseIds = mapNotNull { it.extractReleaseId() }
|
||||
if (releaseIds.isEmpty()) return emptyList()
|
||||
|
||||
val releases = try {
|
||||
musicbrainzRepository.searchReleases(
|
||||
query = releaseIds.joinToString(" OR ") { "reid:$it" },
|
||||
limit = releaseIds.size,
|
||||
offset = 0
|
||||
).releases
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return releases
|
||||
.groupBy { it.releaseGroup?.id ?: it.id }
|
||||
.values
|
||||
.mapNotNull { group ->
|
||||
val release = group.firstOrNull() ?: return@mapNotNull null
|
||||
val releaseGroupId = release.releaseGroup?.id ?: release.id
|
||||
release.toMetadataAlbumDetailed(releaseGroupId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cacheSavedAlbumIdsFromAlbumsDetailed(albums: List<MetadataAlbum.Detailed>) {
|
||||
cacheSavedAlbumIds(albums.map { it.id })
|
||||
}
|
||||
|
||||
private suspend fun cacheSavedAlbumIds(ids: List<String>) {
|
||||
persistedStorage.putString("saved_album_ids", ids.distinct().joinToString(","))
|
||||
}
|
||||
|
||||
suspend fun savedArtists(
|
||||
pagination: PaginationStrategy,
|
||||
filterIds: List<String>
|
||||
): PaginationResult<MetadataArtist.Detailed> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username, "artist")
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
val artists = tracks.toArtists()
|
||||
|
||||
val filtered = if (filterIds.isNotEmpty()) {
|
||||
artists.filter { filterIds.contains(it.id) }
|
||||
} else artists
|
||||
|
||||
val slice = filtered.drop(paging.offset).take(paging.limit)
|
||||
val nextOffset = if (paging.offset + paging.limit < filtered.size) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
cacheSavedArtistIdsFromArtistsDetailed(artists)
|
||||
|
||||
return PaginationResult(
|
||||
items = slice,
|
||||
totalCount = filtered.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun isSavedArtists(ids: List<String>): List<Boolean> {
|
||||
val cached = persistedStorage.getString("saved_artist_ids")?.takeIf { it.isNotBlank() }
|
||||
?.split(",")
|
||||
?.filter { it.isNotBlank() }
|
||||
val savedIds = cached ?: loadSavedArtistIds()
|
||||
return ids.map { savedIds.contains(it) }
|
||||
}
|
||||
|
||||
suspend fun saveArtists(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
val alreadySaved = isSavedArtists(ids)
|
||||
if (alreadySaved.any { it }) {
|
||||
throw IllegalStateException("Some artists are already saved")
|
||||
}
|
||||
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username, "artist")
|
||||
|
||||
val recordingIds = ids.mapNotNull { artistId ->
|
||||
musicbrainzRepository.searchRecordings(query = "arid:$artistId", limit = 1, offset = 0)
|
||||
.recordings
|
||||
.firstOrNull()?.id
|
||||
}
|
||||
|
||||
if (recordingIds.isEmpty()) return
|
||||
|
||||
val body = Playlist(
|
||||
track = recordingIds.map { recId ->
|
||||
PlaylistTrackInner(
|
||||
identifier = listOf("https://musicbrainz.org/recording/$recId")
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val tracks = getPlaylistTracks(playlistId, false)
|
||||
val offset = tracks.size.toLong()
|
||||
|
||||
LbPlaylistsApi.appendRecordings(Uuid.parse(playlistId), offset, body)
|
||||
|
||||
playlistCache.remove(playlistId)
|
||||
cacheSavedArtistIds(loadSavedArtistIds().plus(ids).distinct())
|
||||
}
|
||||
|
||||
suspend fun removeSavedArtists(ids: List<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username, "artist")
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
|
||||
val trackArtistPairs = tracks.mapIndexedNotNull { index, track ->
|
||||
track.extractArtistId(musicbrainzRepository)?.let { artistId ->
|
||||
index to artistId
|
||||
}
|
||||
}
|
||||
|
||||
val indexes = trackArtistPairs.filter { (_, artistId) -> ids.contains(artistId) }
|
||||
.map { it.first }
|
||||
|
||||
if (indexes.isEmpty()) return
|
||||
|
||||
indexes.forEach { index ->
|
||||
LbPlaylistsApi.itemDelete(
|
||||
Uuid.parse(playlistId),
|
||||
ItemDeleteRequest(
|
||||
index = index.toLong(),
|
||||
count = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
playlistCache.remove(playlistId)
|
||||
cacheSavedArtistIds(loadSavedArtistIds().filterNot { ids.contains(it) })
|
||||
}
|
||||
|
||||
private suspend fun loadSavedArtistIds(): List<String> {
|
||||
val username = requireUsername()
|
||||
val playlistId = getOrCreatePlaylistId(username, "artist")
|
||||
val tracks = getPlaylistTracks(playlistId, fetchMetadata = true)
|
||||
|
||||
val artists = tracks.toArtists()
|
||||
val ids = artists.map { it.id }
|
||||
cacheSavedArtistIds(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
private suspend fun cacheSavedArtistIdsFromArtistsDetailed(artists: List<MetadataArtist.Detailed>) {
|
||||
cacheSavedArtistIds(artists.map { it.id })
|
||||
}
|
||||
|
||||
private suspend fun cacheSavedArtistIds(ids: List<String>) {
|
||||
persistedStorage.putString("saved_artist_ids", ids.distinct().joinToString(","))
|
||||
}
|
||||
|
||||
private suspend fun PlaylistTrackInner.extractArtistId(repository: MusicbrainzRepository): String? {
|
||||
val idUrl = identifier?.firstOrNull() ?: return null
|
||||
val recordingId = idUrl.substringAfterLast("/")
|
||||
if (recordingId.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val recording =
|
||||
repository.getRecordingByMbid(recordingId, includes = listOf("artist-credits"))
|
||||
recording.artistCredit.firstOrNull()?.artist?.id
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun List<PlaylistTrackInner>.toArtists(): List<MetadataArtist.Detailed> {
|
||||
val recordingIds = mapNotNull {
|
||||
val idUrl = it.identifier?.firstOrNull() ?: return@mapNotNull null
|
||||
idUrl.substringAfterLast("/").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
if (recordingIds.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = recordingIds.chunked(20)
|
||||
val artists = mutableListOf<MetadataArtist.Detailed>()
|
||||
|
||||
chunks.forEach { chunk ->
|
||||
try {
|
||||
val query = chunk.joinToString(" OR ") { "rid:$it" }
|
||||
val response =
|
||||
musicbrainzRepository.searchRecordings(query, limit = chunk.size, offset = 0)
|
||||
|
||||
response.recordings.forEach { rec ->
|
||||
val artist = rec.artistCredit.firstOrNull()?.artist
|
||||
if (artist != null) {
|
||||
artists.add(artist.toMetadataArtistDetailed())
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return artists
|
||||
}
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzArtist
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRecording
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRelease
|
||||
|
||||
fun MusicbrainzRelease.toMetadataAlbum(groupId: String): MetadataAlbum.Detailed {
|
||||
val releaseGroupId = releaseGroup?.id ?: groupId
|
||||
val type = releaseGroup?.primaryType?.lowercase()?.let {
|
||||
when (it) {
|
||||
"single" -> MetadataAlbumType.Single
|
||||
"album" -> MetadataAlbumType.Album
|
||||
else -> MetadataAlbumType.Collection
|
||||
}
|
||||
} ?: MetadataAlbumType.Collection
|
||||
|
||||
return MetadataAlbum.Detailed(
|
||||
releaseDate = date,
|
||||
genres = emptyList(),
|
||||
trackCount = trackCount ?: 0,
|
||||
id = releaseGroupId,
|
||||
title = releaseGroup?.title ?: title,
|
||||
description = null,
|
||||
thumbnails = listOf(
|
||||
Thumbnail(
|
||||
url = "https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg",
|
||||
width = 250,
|
||||
height = 250
|
||||
),
|
||||
Thumbnail(
|
||||
url = "https://coverartarchive.org/release-group/${releaseGroupId}/front-500.jpg",
|
||||
width = 500,
|
||||
height = 500
|
||||
),
|
||||
),
|
||||
albumType = type,
|
||||
artists = artistCredit.mapNotNull { credit ->
|
||||
credit.artist?.let {
|
||||
MetadataArtist.Basic(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/${it.id}"
|
||||
)
|
||||
}
|
||||
},
|
||||
externalUri = "https://musicbrainz.org/release-group/${releaseGroupId}"
|
||||
)
|
||||
}
|
||||
|
||||
fun List<MusicbrainzRelease>.pickOfficialRelease(): MusicbrainzRelease? {
|
||||
return firstOrNull { release ->
|
||||
release.status == "Official" && release.country == "US" &&
|
||||
release.artistCredit.none { it.artist?.name == "Various Artists" }
|
||||
}
|
||||
}
|
||||
|
||||
fun MusicbrainzRecording.toMetadataTrack(album: MetadataAlbum.Detailed): MetadataTrack {
|
||||
val explicit = disambiguation?.lowercase() == "explicit"
|
||||
return MetadataTrack(
|
||||
id = id,
|
||||
title = title,
|
||||
durationMs = (length ?: 0).toLong(),
|
||||
trackNumber = null,
|
||||
discNumber = null,
|
||||
artists = artistCredit.mapNotNull { credit ->
|
||||
credit.artist?.let {
|
||||
MetadataArtist.Basic(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/${it.id}"
|
||||
)
|
||||
}
|
||||
},
|
||||
album = album,
|
||||
explicit = explicit,
|
||||
popularity = null,
|
||||
isrcCode = isrcs.firstOrNull(),
|
||||
externalUri = "https://musicbrainz.org/recording/${id}",
|
||||
thumbnails = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun MusicbrainzRelease.toMetadataAlbumDetailed(groupId: String): MetadataAlbum.Detailed {
|
||||
val releaseGroupId = releaseGroup?.id ?: groupId
|
||||
val type = releaseGroup?.primaryType?.lowercase()?.let {
|
||||
when (it) {
|
||||
"single" -> MetadataAlbumType.Single
|
||||
"album" -> MetadataAlbumType.Album
|
||||
else -> MetadataAlbumType.Collection
|
||||
}
|
||||
} ?: MetadataAlbumType.Collection
|
||||
|
||||
return MetadataAlbum.Detailed(
|
||||
releaseDate = date,
|
||||
genres = emptyList(),
|
||||
trackCount = trackCount ?: 0,
|
||||
id = releaseGroupId,
|
||||
title = releaseGroup?.title ?: title,
|
||||
description = null,
|
||||
thumbnails = listOf(
|
||||
Thumbnail(
|
||||
url = "https://coverartarchive.org/release-group/${releaseGroupId}/front-250.jpg",
|
||||
width = 250,
|
||||
height = 250
|
||||
),
|
||||
Thumbnail(
|
||||
url = "https://coverartarchive.org/release-group/${releaseGroupId}/front-500.jpg",
|
||||
width = 500,
|
||||
height = 500
|
||||
),
|
||||
),
|
||||
albumType = type,
|
||||
artists = artistCredit.mapNotNull { credit ->
|
||||
credit.artist?.let {
|
||||
MetadataArtist.Basic(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/${it.id}"
|
||||
)
|
||||
}
|
||||
},
|
||||
externalUri = "https://musicbrainz.org/release-group/${releaseGroupId}"
|
||||
)
|
||||
}
|
||||
|
||||
fun MusicbrainzArtist.toMetadataArtistDetailed(): MetadataArtist.Detailed {
|
||||
return MetadataArtist.Detailed(
|
||||
id = id,
|
||||
name = name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/$id",
|
||||
genres = tags.map { it.name },
|
||||
biography = disambiguation,
|
||||
followersCount = null
|
||||
)
|
||||
}
|
||||
|
||||
fun MusicbrainzArtist.toMetadataArtistBasic(): MetadataArtist.Basic {
|
||||
return MetadataArtist.Basic(
|
||||
id = id,
|
||||
name = name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/$id"
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import app.cash.zipline.ZiplineService
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.KtorMusicbrainzRepository
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzArtistEnricher
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.wikidata.WikidataRepository
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun createMusicbrainzListenbrainzPluginAPIs(
|
||||
scope: CoroutineScope,
|
||||
httpClient: HttpClient,
|
||||
webViewController: WebViewController,
|
||||
persistedStorage: PersistedStorageAPI
|
||||
): Map<KClass<*>, ZiplineService> {
|
||||
val repository = KtorMusicbrainzRepository(httpClient)
|
||||
val wikidataRepository = WikidataRepository(httpClient)
|
||||
val artistEnricher = MusicbrainzArtistEnricher(repository, wikidataRepository)
|
||||
|
||||
return mapOf(
|
||||
CoreAPI::class to RealMusicbrainzListenbrainzCoreAPI(scope, webViewController, persistedStorage),
|
||||
MetadataBrowseAPI::class to RealMusicbrainzListenbrainzMetadataBrowseAPI(persistedStorage),
|
||||
MetadataPlaylistAPI::class to RealMusicbrainzListenbrainzMetadataPlaylistAPI(repository, persistedStorage),
|
||||
MetadataTrackAPI::class to RealMusicbrainzListenbrainzMetadataTrackAPI(repository, persistedStorage),
|
||||
MetadataAlbumAPI::class to RealMusicbrainzListenbrainzMetadataAlbumAPI(repository, persistedStorage),
|
||||
MetadataArtistAPI::class to RealMusicbrainzListenbrainzMetadataArtistAPI(repository, persistedStorage),
|
||||
MetadataSearchAPI::class to RealMusicbrainzListenbrainzMetadataSearchAPI(repository, artistEnricher, httpClient),
|
||||
MetadataUserAPI::class to RealMusicbrainsListenbrainzMetadataUserAPI(repository, persistedStorage)
|
||||
)
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import com.kroegerama.openapi.kmp.gen.companion.appendSerializedQueryParameter
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
|
||||
class RealMusicbrainsListenbrainzMetadataUserAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : MetadataUserAPI {
|
||||
override suspend fun getUser(id: String): MetadataUser? {
|
||||
val token = persistedStorage.getString("listenbrainz_auth_token") ?: return null
|
||||
if(token.isEmpty()) return null
|
||||
when (val res = LbCoreApi.validateToken {
|
||||
appendSerializedQueryParameter("token", token)
|
||||
}) {
|
||||
is Either.Left -> {
|
||||
println("Error validating token: ${res.value}")
|
||||
return null
|
||||
}
|
||||
|
||||
is Either.Right -> {
|
||||
val user = res.value.data
|
||||
return MetadataUser(
|
||||
id = user.userName as String,
|
||||
username = user.userName,
|
||||
displayName = user.userName,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://listenbrainz.org/user/${user.userName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import com.kroegerama.openapi.kmp.gen.companion.appendSerializedQueryParameter
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.listenbrainz.Api
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import net.swiftzer.semver.SemVer
|
||||
|
||||
class RealMusicbrainzListenbrainzCoreAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : CoreAPI {
|
||||
init {
|
||||
scope.launch {
|
||||
val token = persistedStorage.getString("listenbrainz_auth_token")
|
||||
if (token != null) {
|
||||
auth = Auth.ApiKeyAuth { token }
|
||||
Api.setAuthProvider(auth!!)
|
||||
loggedInState.value = true
|
||||
} else {
|
||||
loggedInState.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun checkPluginUpdates(currentVersion: SemVer): PluginUpdateInfo? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun supportMarkdownText(currentVersion: SemVer): String {
|
||||
return "Keep supporting Spotube!"
|
||||
}
|
||||
|
||||
override val requiresAuthentication = true
|
||||
|
||||
private val loggedInState = MutableStateFlow(false)
|
||||
override val loggedInFlow = loggedInState.asStateFlow()
|
||||
|
||||
private var auth: Auth? = null
|
||||
|
||||
override suspend fun login() {
|
||||
webViewController.navigateToHTML(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login to Listenbrainz</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Login to Listenbrainz</h1>
|
||||
<form id="loginForm">
|
||||
<input type="password" id="password" placeholder="API Token" />
|
||||
<button id="loginButton" type="submit">Login</button>
|
||||
</form>
|
||||
<span id="mirror">Mirror: </span>
|
||||
</body>
|
||||
</html>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const input = document.getElementById("password");
|
||||
const mirror = document.getElementById("mirror");
|
||||
input.addEventListener("input", function() {
|
||||
mirror.textContent = "Mirror: " + input.value;
|
||||
});
|
||||
|
||||
const form = document.getElementById("loginForm");
|
||||
const initBridge = () => form.addEventListener("submit", function(event) {
|
||||
try {
|
||||
event.preventDefault();
|
||||
const passwordInput = document.getElementById("password");
|
||||
const token = passwordInput.value.trim();
|
||||
if (token) {
|
||||
sendMessage(token);
|
||||
} else {
|
||||
mirror.textContent = "Error: API token cannot be empty.";
|
||||
console.error("API token cannot be empty.");
|
||||
}
|
||||
} catch (error) {
|
||||
mirror.textContent = "Error: " + error.message;
|
||||
console.error("Error during form submission:", error);
|
||||
}
|
||||
});
|
||||
|
||||
if(window.bridgeReady) {
|
||||
initBridge();
|
||||
} else {
|
||||
window.addEventListener('onBridgeReady', initBridge);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
println("[RealMusicbrainzListenbrainzCoreAPI.login] Waiting for postMessagesFlow message")
|
||||
val actualCreds = webViewController.postMessagesFlow
|
||||
.onEach {
|
||||
println("[postMessagesFlow.onEach] Received token from WebView: $it")
|
||||
}
|
||||
.filter { it.isNotBlank() }
|
||||
.first()
|
||||
println("Received token from WebView: $actualCreds")
|
||||
auth = Auth.ApiKeyAuth {
|
||||
actualCreds
|
||||
}
|
||||
Api.setAuthProvider(auth = auth!!)
|
||||
when (val res = LbCoreApi.validateToken {
|
||||
appendSerializedQueryParameter("token", actualCreds)
|
||||
}) {
|
||||
is Either.Left -> {
|
||||
webViewController.closeWebview()
|
||||
throw res.value
|
||||
}
|
||||
|
||||
is Either.Right -> {
|
||||
if (!res.value.data.valid) {
|
||||
webViewController.closeWebview()
|
||||
throw Exception("Invalid token")
|
||||
}
|
||||
persistedStorage.putString("listenbrainz_auth_token", actualCreds)
|
||||
loggedInState.value = true
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
if (auth == null) return
|
||||
Api.clearAuthProvider(auth!!)
|
||||
auth = null
|
||||
persistedStorage.remove("listenbrainz_auth_token")
|
||||
loggedInState.value = false
|
||||
}
|
||||
}
|
||||
@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataAlbumAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : MetadataAlbumAPI {
|
||||
private val emulator by lazy {
|
||||
EmulatedAlbumArtist(musicbrainzRepository, persistedStorage)
|
||||
}
|
||||
|
||||
override suspend fun getAlbum(id: String): MetadataAlbum.Detailed {
|
||||
val release = musicbrainzRepository
|
||||
.searchReleases(query = "rgid:$id", limit = 1, offset = 0)
|
||||
.releases
|
||||
.firstOrNull()
|
||||
?: throw IllegalArgumentException("Album $id not found")
|
||||
|
||||
return release.toMetadataAlbumDetailed(id)
|
||||
}
|
||||
|
||||
override suspend fun getTrackAlbum(track: MetadataTrack): MetadataAlbum.Detailed {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getAlbumTracks(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataTrack> {
|
||||
val paging: PaginationStrategy.Offset =
|
||||
pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
|
||||
val releases = musicbrainzRepository.searchReleases(
|
||||
query = "rgid:$id",
|
||||
limit = 10,
|
||||
offset = 0
|
||||
).releases
|
||||
|
||||
val officialRelease = releases.pickOfficialRelease()
|
||||
?: releases.firstOrNull()
|
||||
?: throw IllegalArgumentException("Album $id not found")
|
||||
|
||||
val recordings = musicbrainzRepository.searchRecordings(
|
||||
query = "reid:${officialRelease.id}",
|
||||
limit = paging.limit,
|
||||
offset = paging.offset,
|
||||
)
|
||||
|
||||
val album = officialRelease.toMetadataAlbumDetailed(id)
|
||||
val items = recordings.recordings.map { it.toMetadataTrack(album) }
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < recordings.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = recordings.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun savedAlbums(
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataAlbum.Detailed> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
return emulator.savedAlbums(paging, emptyList())
|
||||
}
|
||||
|
||||
override suspend fun isSavedAlbums(ids: List<String>): List<Boolean> {
|
||||
return emulator.isSavedAlbums(ids)
|
||||
}
|
||||
|
||||
override suspend fun saveAlbums(ids: List<String>) {
|
||||
emulator.saveAlbums(ids)
|
||||
}
|
||||
|
||||
override suspend fun removeSavedAlbums(ids: List<String>) {
|
||||
emulator.removeSavedAlbums(ids)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataArtistAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : MetadataArtistAPI {
|
||||
|
||||
private val emulator by lazy {
|
||||
EmulatedAlbumArtist(musicbrainzRepository, persistedStorage)
|
||||
}
|
||||
|
||||
override suspend fun getArtist(id: String): MetadataArtist.Detailed {
|
||||
val artist = musicbrainzRepository.getArtistByMbid(id, includes = listOf("url-rels"))
|
||||
return artist.toMetadataArtistDetailed()
|
||||
}
|
||||
|
||||
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
|
||||
val recordings = musicbrainzRepository.searchRecordings(
|
||||
query = "arid:$id",
|
||||
limit = 10,
|
||||
offset = 0
|
||||
).recordings
|
||||
|
||||
return recordings.mapNotNull { recording ->
|
||||
val release = recording.releases.pickOfficialRelease()
|
||||
?: recording.releases.firstOrNull()
|
||||
|
||||
release?.let {
|
||||
val groupId = it.releaseGroup?.id ?: it.id
|
||||
val album = it.toMetadataAlbumDetailed(groupId)
|
||||
recording.toMetadataTrack(album)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getArtistAlbums(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataAlbum.Detailed> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
val releases = musicbrainzRepository.searchReleases(
|
||||
query = "arid:$id",
|
||||
limit = paging.limit,
|
||||
offset = paging.offset
|
||||
)
|
||||
|
||||
val items = releases.releases.map { release ->
|
||||
val groupId = release.releaseGroup?.id ?: release.id
|
||||
release.toMetadataAlbumDetailed(groupId)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < releases.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = releases.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun savedArtists(
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataArtist.Detailed> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
return emulator.savedArtists(paging, emptyList())
|
||||
}
|
||||
|
||||
override suspend fun isSavedArtists(ids: List<String>): List<Boolean> {
|
||||
return emulator.isSavedArtists(ids)
|
||||
}
|
||||
|
||||
override suspend fun saveArtists(ids: List<String>) {
|
||||
emulator.saveArtists(ids)
|
||||
}
|
||||
|
||||
override suspend fun removeSavedArtists(ids: List<String>) {
|
||||
emulator.removeSavedArtists(ids)
|
||||
}
|
||||
}
|
||||
@ -1,566 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import com.kroegerama.openapi.kmp.gen.companion.AuthPlugin.Plugin.authKeys
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.listenbrainz.Api
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbMiscApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbPlaylistsApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbStatsApi
|
||||
import dev.krtirtho.spotube.listenbrainz.models.AllowedStatisticsRange
|
||||
import dev.krtirtho.spotube.listenbrainz.models.Mode
|
||||
import dev.krtirtho.spotube.listenbrainz.models.Playlist
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataBrowseAPI(
|
||||
private val persistedStorage: PersistedStorageAPI,
|
||||
) : MetadataBrowseAPI, KoinComponent {
|
||||
private val logger by injectLogger<RealMusicbrainzListenbrainzMetadataBrowseAPI>()
|
||||
private var cachedUsername: String? = null
|
||||
|
||||
@Serializable
|
||||
private data class LbRadioPlaylistCacheEntry(
|
||||
val cachedAtEpochMs: Long,
|
||||
val playlist: MetadataPlaylist,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class BrowseSectionsCacheEntry(
|
||||
val cachedAtEpochMs: Long,
|
||||
val sections: List<BrowseSectionData>,
|
||||
)
|
||||
|
||||
private val cacheJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class BrowseSectionData(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val moreLink: String? = null,
|
||||
val items: List<MetadataBrowseItem>,
|
||||
)
|
||||
|
||||
private data class MoodSeed(
|
||||
val key: String,
|
||||
val tag: String,
|
||||
val title: String,
|
||||
val annotation: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SECTION_TOP_ARTIST_RADIOS = "top-artist-radios"
|
||||
private const val SECTION_MOOD_PLAYLISTS = "mood-playlists"
|
||||
private const val SECTION_CREATED_FOR = "created-for-playlists"
|
||||
private const val LB_RADIO_CACHE_TTL_MS = 3L * 24 * 60 * 60 * 1000
|
||||
private const val LB_RADIO_CACHE_KEY_PREFIX = "lb_radio_playlist_cache"
|
||||
private const val BROWSE_SECTIONS_CACHE_KEY_PREFIX = "lb_browse_sections_cache"
|
||||
|
||||
private val moodSeeds = listOf(
|
||||
MoodSeed("chill", "ambient", "Chill playlist", "Yo chill my friend!"),
|
||||
MoodSeed("energetic", "energetic", "Pump it up!", "Get ready to move!"),
|
||||
MoodSeed("happy", "upbeat", "Happy Vibes", "Feel good tunes to brighten your day!"),
|
||||
MoodSeed("sad", "melancholy", "Melancholy Moments", "For those reflective times."),
|
||||
MoodSeed("focus", "instrumental", "Focus Beats", "Concentration is key."),
|
||||
MoodSeed("workout", "electronic", "Workout Jams", "Get pumped with these beats!"),
|
||||
MoodSeed("party", "dance", "Party Anthems", "Let's get this party started!"),
|
||||
MoodSeed("romantic", "romantic", "Romantic Evenings", "For those special moments."),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun featured(): List<MetadataBrowseItem> {
|
||||
logger.i("featured(): Starting to build featured items")
|
||||
try {
|
||||
val sections = buildSections()
|
||||
logger.d("featured(): Built ${sections.size} sections")
|
||||
val result = sections.flatMap { it.items }.take(12)
|
||||
logger.i("featured(): Returning ${result.size} featured items")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "featured(): Error building featured items" }
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun list(pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
logger.i("list(): Starting with offset=${paging.offset}, pageSize=${paging.limit}")
|
||||
try {
|
||||
val sections = buildSections()
|
||||
logger.d("list(): Built ${sections.size} total sections")
|
||||
val items = sections
|
||||
.drop(paging.offset)
|
||||
.take(paging.limit)
|
||||
.map {
|
||||
MetadataBrowseSection(
|
||||
title = it.title,
|
||||
description = it.description,
|
||||
items = it.items,
|
||||
moreLink = it.moreLink,
|
||||
)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + items.size < sections.size) {
|
||||
paging.offset + paging.limit
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
logger.d("list(): Returning ${items.size} items, nextOffset=$nextOffset")
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = sections.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "list(): Error fetching paginated sections" }
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = 0,
|
||||
nextPagination = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sublist(
|
||||
sectionId: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataBrowseItem> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
logger.i("sublist(): Fetching section=$sectionId with offset=${paging.offset}, pageSize=${paging.limit}")
|
||||
try {
|
||||
val allSections = buildSections()
|
||||
logger.d("sublist(): Built ${allSections.size} total sections")
|
||||
val sectionItems = allSections.firstOrNull { it.id == sectionId }?.items ?: emptyList()
|
||||
logger.d("sublist(): Found ${sectionItems.size} items in section $sectionId")
|
||||
|
||||
val items = sectionItems.drop(paging.offset).take(paging.limit)
|
||||
val nextOffset = if (paging.offset + items.size < sectionItems.size) {
|
||||
paging.offset + paging.limit
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
logger.d("sublist(): Returning ${items.size} paginated items, nextOffset=$nextOffset")
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = sectionItems.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "sublist(): Error fetching sublist for section=$sectionId" }
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = 0,
|
||||
nextPagination = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildSections(): List<BrowseSectionData> {
|
||||
logger.d("buildSections(): Starting section building process")
|
||||
val username = try {
|
||||
val user = requireUsername()
|
||||
logger.d("buildSections(): Successfully resolved username: $user")
|
||||
user
|
||||
} catch (e: Exception) {
|
||||
logger.w(e) { "buildSections(): Failed to retrieve username, returning empty sections" }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val nowEpochMs = Clock.System.now().toEpochMilliseconds()
|
||||
val sectionsCacheKey = buildBrowseSectionsCacheKey(username)
|
||||
val cachedSections = readCachedBrowseSections(sectionsCacheKey)
|
||||
if (cachedSections != null) {
|
||||
val ageMs = nowEpochMs - cachedSections.cachedAtEpochMs
|
||||
if (ageMs <= LB_RADIO_CACHE_TTL_MS) {
|
||||
logger.d("buildSections(): Returning cached sections for key=$sectionsCacheKey (ageMs=$ageMs)")
|
||||
return cachedSections.sections
|
||||
}
|
||||
logger.d("buildSections(): Cached sections stale for key=$sectionsCacheKey (ageMs=$ageMs), rebuilding")
|
||||
}
|
||||
|
||||
return try {
|
||||
logger.d("buildSections(): Building Top Artist Radios section...")
|
||||
val topArtistRadios = buildTopArtistRadiosSection(username)
|
||||
logger.d("buildSections(): Built Top Artist Radios with ${topArtistRadios.items.size} items")
|
||||
|
||||
logger.d("buildSections(): Building Mood Playlists section...")
|
||||
val moodPlaylists = buildMoodPlaylistsSection(username)
|
||||
logger.d("buildSections(): Built Mood Playlists with ${moodPlaylists.items.size} items")
|
||||
|
||||
logger.d("buildSections(): Building Created For section...")
|
||||
val createdFor = buildCreatedForSection(username)
|
||||
logger.d("buildSections(): Built Created For with ${createdFor.items.size} items")
|
||||
|
||||
val sections = listOf(topArtistRadios, moodPlaylists, createdFor)
|
||||
writeCachedBrowseSections(
|
||||
key = sectionsCacheKey,
|
||||
entry = BrowseSectionsCacheEntry(
|
||||
cachedAtEpochMs = nowEpochMs,
|
||||
sections = sections,
|
||||
)
|
||||
)
|
||||
logger.i("buildSections(): Successfully built ${sections.size} sections with ${sections.sumOf { it.items.size }} total items")
|
||||
sections
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "buildSections(): Failed to build fresh sections" }
|
||||
if (cachedSections != null) {
|
||||
logger.w("buildSections(): Returning stale cached sections for key=$sectionsCacheKey")
|
||||
cachedSections.sections
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildTopArtistRadiosSection(username: String): BrowseSectionData {
|
||||
logger.d("buildTopArtistRadiosSection(): Fetching top artists for user: $username")
|
||||
try {
|
||||
val artistsResult = LbStatsApi.topArtistsForUser(
|
||||
userName = username,
|
||||
count = 10,
|
||||
offset = 0,
|
||||
range = AllowedStatisticsRange.QUARTER,
|
||||
)
|
||||
val artists = when (artistsResult) {
|
||||
is Either.Left -> {
|
||||
val error = artistsResult.value
|
||||
logger.e("buildTopArtistRadiosSection(): Failed to fetch top artists: $error")
|
||||
throw error
|
||||
}
|
||||
|
||||
is Either.Right -> artistsResult.value.data.payload.artists
|
||||
}
|
||||
logger.d("buildTopArtistRadiosSection(): Retrieved ${artists.size} top artists")
|
||||
|
||||
val playlists = artists.mapNotNull { artist ->
|
||||
val mbid = artist.artistMbid?.toString() ?: return@mapNotNull null
|
||||
val name = artist.artistName ?: return@mapNotNull null
|
||||
logger.d("buildTopArtistRadiosSection(): Generating radio playlist for artist: $name (mbid: $mbid)")
|
||||
|
||||
generateLbRadioPlaylist(
|
||||
prompt = "artist:($mbid)",
|
||||
mode = Mode.EASY,
|
||||
title = "$name Radio",
|
||||
annotation = "A radio playlist based on $name's music",
|
||||
imageUrl = null,
|
||||
)
|
||||
}
|
||||
logger.d("buildTopArtistRadiosSection(): Generated ${playlists.size} radio playlists")
|
||||
|
||||
return BrowseSectionData(
|
||||
id = SECTION_TOP_ARTIST_RADIOS,
|
||||
title = "Top Artist Radios",
|
||||
moreLink = "https://listenbrainz.org/explore/lb-radio",
|
||||
items = playlists.map { MetadataBrowseItem.Playlist(it) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "buildTopArtistRadiosSection(): Error building top artist radios section" }
|
||||
return BrowseSectionData(
|
||||
id = SECTION_TOP_ARTIST_RADIOS,
|
||||
title = "Top Artist Radios",
|
||||
items = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildMoodPlaylistsSection(username: String): BrowseSectionData {
|
||||
logger.d("buildMoodPlaylistsSection(): Starting to build mood playlists (count: ${moodSeeds.size})")
|
||||
try {
|
||||
val playlists = moodSeeds.mapNotNull { mood ->
|
||||
logger.d("buildMoodPlaylistsSection(): Generating playlist for mood: ${mood.title} (tag: ${mood.tag})")
|
||||
generateLbRadioPlaylist(
|
||||
prompt = "tag:(${mood.tag}) stats:$username::all_time",
|
||||
mode = Mode.HARD,
|
||||
title = mood.title,
|
||||
annotation = mood.annotation,
|
||||
imageUrl = "https://res.cloudinary.com/dszpk1pk9/image/upload/t_media_lib_thumb/spotube-plugin-musicbrainz-listenbrainz/moods/${mood.key}.webp",
|
||||
)
|
||||
}
|
||||
logger.d("buildMoodPlaylistsSection(): Successfully generated ${playlists.size} mood playlists")
|
||||
|
||||
return BrowseSectionData(
|
||||
id = SECTION_MOOD_PLAYLISTS,
|
||||
title = "Based on your mood",
|
||||
moreLink = "https://listenbrainz.org/explore/lb-radio",
|
||||
items = playlists.map { MetadataBrowseItem.Playlist(it) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "buildMoodPlaylistsSection(): Error building mood playlists section" }
|
||||
return BrowseSectionData(
|
||||
id = SECTION_MOOD_PLAYLISTS,
|
||||
title = "Based on your mood",
|
||||
items = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildCreatedForSection(username: String): BrowseSectionData {
|
||||
logger.d("buildCreatedForSection(): Fetching playlists created for user: $username")
|
||||
try {
|
||||
val playlistsResult = LbPlaylistsApi.playlistsCreatedForUser(
|
||||
playlistUserName = username,
|
||||
count = 25,
|
||||
offset = 0,
|
||||
)
|
||||
val playlists = when (playlistsResult) {
|
||||
is Either.Left -> {
|
||||
val error = playlistsResult.value
|
||||
logger.e("buildCreatedForSection(): Failed to fetch created playlists: $error")
|
||||
throw error
|
||||
}
|
||||
|
||||
is Either.Right -> playlistsResult.value.data.playlists.orEmpty()
|
||||
}
|
||||
logger.d("buildCreatedForSection(): Retrieved ${playlists.size} playlists")
|
||||
|
||||
val items = playlists.mapNotNull { wrapper ->
|
||||
val playlist = wrapper.playlist ?: return@mapNotNull null
|
||||
val title = playlist.title.orEmpty()
|
||||
val imageName = if (title.contains("Weekly Exploration", ignoreCase = true)) {
|
||||
"weekly-exploration"
|
||||
} else {
|
||||
"weekly-jams"
|
||||
}
|
||||
logger.d("buildCreatedForSection(): Processing playlist: $title (imageName: $imageName)")
|
||||
val imageUrl =
|
||||
"https://res.cloudinary.com/dszpk1pk9/image/upload/t_media_lib_thumb/spotube-plugin-musicbrainz-listenbrainz/created_for/${imageName}.webp"
|
||||
playlist.toMetadataPlaylist(thumbnailOverride = imageUrl)
|
||||
}
|
||||
logger.d("buildCreatedForSection(): Successfully processed ${items.size} playlists")
|
||||
|
||||
return BrowseSectionData(
|
||||
id = SECTION_CREATED_FOR,
|
||||
title = "Created for you",
|
||||
moreLink = "https://listenbrainz.org/user/$username/recommendations/",
|
||||
items = items.map { MetadataBrowseItem.Playlist(it) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "buildCreatedForSection(): Error building created for section" }
|
||||
return BrowseSectionData(
|
||||
id = SECTION_CREATED_FOR,
|
||||
title = "Created for you",
|
||||
items = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun generateLbRadioPlaylist(
|
||||
prompt: String,
|
||||
mode: Mode,
|
||||
title: String,
|
||||
annotation: String?,
|
||||
imageUrl: String?,
|
||||
): MetadataPlaylist? {
|
||||
logger.d("generateLbRadioPlaylist(): Generating playlist with prompt='$prompt', mode=$mode, title='$title'")
|
||||
val nowEpochMs = Clock.System.now().toEpochMilliseconds()
|
||||
val cacheKey = buildLbRadioCacheKey(prompt = prompt, mode = mode)
|
||||
val cachedEntry = readCachedLbRadioPlaylist(cacheKey)
|
||||
if (cachedEntry != null) {
|
||||
val ageMs = nowEpochMs - cachedEntry.cachedAtEpochMs
|
||||
if (ageMs <= LB_RADIO_CACHE_TTL_MS) {
|
||||
logger.d("generateLbRadioPlaylist(): Cache hit for key=$cacheKey (ageMs=$ageMs)")
|
||||
return cachedEntry.playlist
|
||||
}
|
||||
logger.d("generateLbRadioPlaylist(): Cache stale for key=$cacheKey (ageMs=$ageMs), refreshing")
|
||||
}
|
||||
|
||||
try {
|
||||
val lbRadioResult = LbMiscApi.lbRadio(prompt = prompt, mode = mode) {
|
||||
authKeys(
|
||||
Auth.ApiKeyAuth.ID,
|
||||
)
|
||||
}
|
||||
val lbRadio = when (lbRadioResult) {
|
||||
is Either.Left -> {
|
||||
val error = lbRadioResult.value
|
||||
logger.e("generateLbRadioPlaylist(): API call failed with error: $error")
|
||||
throw error
|
||||
}
|
||||
|
||||
is Either.Right -> lbRadioResult.value.data
|
||||
}
|
||||
val playlist = lbRadio.payload.jspf.playlist ?: run {
|
||||
logger.w("generateLbRadioPlaylist(): No playlist data in response for prompt='$prompt'")
|
||||
return null
|
||||
}
|
||||
val modeId = mode.name.lowercase()
|
||||
val syntheticId = "lb-radio-playlist-$prompt-$modeId"
|
||||
|
||||
val normalizedPlaylist = playlist.copy(
|
||||
identifier = "https://listenbrainz.org/playlist/$syntheticId",
|
||||
title = title,
|
||||
annotation = annotation ?: playlist.annotation,
|
||||
)
|
||||
|
||||
val result = normalizedPlaylist.toMetadataPlaylist(thumbnailOverride = imageUrl)
|
||||
if (result != null) {
|
||||
writeCachedLbRadioPlaylist(
|
||||
key = cacheKey,
|
||||
entry = LbRadioPlaylistCacheEntry(
|
||||
cachedAtEpochMs = nowEpochMs,
|
||||
playlist = result,
|
||||
)
|
||||
)
|
||||
}
|
||||
logger.d("generateLbRadioPlaylist(): Successfully generated playlist: $title")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "generateLbRadioPlaylist(): Error generating playlist for prompt='$prompt'" }
|
||||
if (cachedEntry != null) {
|
||||
logger.w("generateLbRadioPlaylist(): Returning stale cache for key=$cacheKey due to API failure")
|
||||
return cachedEntry.playlist
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildLbRadioCacheKey(prompt: String, mode: Mode): String {
|
||||
val promptHash = prompt.hashCode().toUInt().toString(16)
|
||||
return "$LB_RADIO_CACHE_KEY_PREFIX:${mode.name.lowercase()}:$promptHash"
|
||||
}
|
||||
|
||||
private fun buildBrowseSectionsCacheKey(username: String): String {
|
||||
val usernameHash = username.lowercase().hashCode().toUInt().toString(16)
|
||||
return "$BROWSE_SECTIONS_CACHE_KEY_PREFIX:$usernameHash"
|
||||
}
|
||||
|
||||
private suspend fun readCachedLbRadioPlaylist(key: String): LbRadioPlaylistCacheEntry? {
|
||||
val raw = persistedStorage.getString(key) ?: return null
|
||||
return runCatching {
|
||||
cacheJson.decodeFromString<LbRadioPlaylistCacheEntry>(raw)
|
||||
}.onFailure { throwable ->
|
||||
logger.w(throwable) { "readCachedLbRadioPlaylist(): Corrupt cache entry for key=$key, removing" }
|
||||
persistedStorage.remove(key)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun writeCachedLbRadioPlaylist(key: String, entry: LbRadioPlaylistCacheEntry) {
|
||||
runCatching {
|
||||
persistedStorage.putString(key, cacheJson.encodeToString(entry))
|
||||
}.onFailure { throwable ->
|
||||
logger.w(throwable) { "writeCachedLbRadioPlaylist(): Failed to persist cache for key=$key" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readCachedBrowseSections(key: String): BrowseSectionsCacheEntry? {
|
||||
val raw = persistedStorage.getString(key) ?: return null
|
||||
return runCatching {
|
||||
cacheJson.decodeFromString<BrowseSectionsCacheEntry>(raw)
|
||||
}.onFailure { throwable ->
|
||||
logger.w(throwable) { "readCachedBrowseSections(): Corrupt cache entry for key=$key, removing" }
|
||||
persistedStorage.remove(key)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun writeCachedBrowseSections(key: String, entry: BrowseSectionsCacheEntry) {
|
||||
runCatching {
|
||||
persistedStorage.putString(key, cacheJson.encodeToString(entry))
|
||||
}.onFailure { throwable ->
|
||||
logger.w(throwable) { "writeCachedBrowseSections(): Failed to persist cache for key=$key" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun Playlist.toMetadataPlaylist(thumbnailOverride: String? = null): MetadataPlaylist? {
|
||||
val identifier = identifier ?: return null
|
||||
val id = identifier.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return null
|
||||
val creator = creator ?: "Unknown"
|
||||
|
||||
return MetadataPlaylist(
|
||||
id = id,
|
||||
title = title ?: "Untitled",
|
||||
description = annotation,
|
||||
thumbnails = listOf(
|
||||
Thumbnail(
|
||||
url = thumbnailOverride
|
||||
?: "https://ui-avatars.com/api/?name=${title ?: "Playlist"}&background=random",
|
||||
width = 300,
|
||||
height = 300
|
||||
)
|
||||
),
|
||||
trackCount = track?.size ?: 0,
|
||||
externalUri = identifier,
|
||||
owner = MetadataUser(
|
||||
id = creator,
|
||||
username = creator,
|
||||
displayName = creator,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://listenbrainz.org/user/$creator/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun requireUsername(): String {
|
||||
logger.d("requireUsername(): Checking for cached username")
|
||||
cachedUsername?.let {
|
||||
logger.d("requireUsername(): Using cached username: $it")
|
||||
return it
|
||||
}
|
||||
|
||||
logger.d("requireUsername(): Retrieving auth token from persistent storage")
|
||||
val auth = Auth.ApiKeyAuth {
|
||||
val token =
|
||||
persistedStorage.getString("listenbrainz_auth_token") ?: run {
|
||||
logger.w("requireUsername(): No auth token found in persistent storage")
|
||||
return@ApiKeyAuth null
|
||||
}
|
||||
logger.d("requireUsername(): Auth token retrieved, length: ${token.length}")
|
||||
if (token.startsWith("Token ", ignoreCase = true)) token else "Token $token"
|
||||
}
|
||||
Api.setAuthProvider(auth)
|
||||
|
||||
logger.i("requireUsername(): Validating token with ListenBrainz API")
|
||||
val username = when (val res = LbCoreApi.validateToken()) {
|
||||
is Either.Left -> {
|
||||
val error = res.value
|
||||
logger.e("requireUsername(): Token validation failed with error: $error")
|
||||
throw error
|
||||
}
|
||||
|
||||
is Either.Right -> {
|
||||
val user = res.value.data.userName
|
||||
logger.i("requireUsername(): Token validation successful, username: $user")
|
||||
user
|
||||
}
|
||||
}
|
||||
cachedUsername = username
|
||||
logger.d("requireUsername(): Caching username: $username")
|
||||
return username!!
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,414 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
import dev.krtirtho.spotube.listenbrainz.Api
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbPlaylistsApi
|
||||
import dev.krtirtho.spotube.listenbrainz.models.CreatePlaylistRequest
|
||||
import dev.krtirtho.spotube.listenbrainz.models.Playlist
|
||||
import dev.krtirtho.spotube.listenbrainz.models.PlaylistTrackInner
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataPlaylistAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : MetadataPlaylistAPI {
|
||||
|
||||
private var cachedUsername: String? = null
|
||||
|
||||
private suspend fun requireUsername(): String {
|
||||
cachedUsername?.let { return it }
|
||||
|
||||
// Setup auth provider globally
|
||||
val auth = Auth.ApiKeyAuth {
|
||||
val token =
|
||||
persistedStorage.getString("listenbrainz_auth_token") ?: return@ApiKeyAuth null
|
||||
if (token.startsWith("Token ", ignoreCase = true)) token else "Token $token"
|
||||
}
|
||||
Api.setAuthProvider(auth)
|
||||
|
||||
val username = when (val res = LbCoreApi.validateToken()) {
|
||||
is Either.Left -> throw IllegalStateException("Unable to resolve ListenBrainz username: ${res.value}")
|
||||
is Either.Right -> res.value.data.userName
|
||||
}
|
||||
cachedUsername = username!!
|
||||
return username
|
||||
}
|
||||
|
||||
override suspend fun getPlaylist(id: String): MetadataPlaylist {
|
||||
// Ensure auth is set up if possible
|
||||
try {
|
||||
requireUsername()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val response = LbPlaylistsApi.fetchPlaylist(
|
||||
playlistMbid = Uuid.parse(id),
|
||||
fetchMetadata = false
|
||||
)
|
||||
|
||||
val playlist = response.getOrNull()?.data?.playlist
|
||||
?: throw IllegalStateException("Playlist not found")
|
||||
|
||||
return playlistToMetadata(playlist, id)
|
||||
?: throw IllegalStateException("Invalid playlist data")
|
||||
}
|
||||
|
||||
override suspend fun getPlaylistTracks(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataTrack> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
|
||||
try {
|
||||
requireUsername()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val tracks = try {
|
||||
LbPlaylistsApi.fetchPlaylist(
|
||||
playlistMbid = Uuid.parse(id),
|
||||
fetchMetadata = false
|
||||
).getOrNull()?.data?.playlist?.track ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = 0,
|
||||
nextPagination = null
|
||||
)
|
||||
}
|
||||
|
||||
val slice = tracks.drop(paging.offset).take(paging.limit)
|
||||
|
||||
val recordingIds = slice.mapNotNull { track ->
|
||||
track.identifier?.firstOrNull()?.substringAfterLast("/")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val nextOffset = if (paging.offset + paging.limit < tracks.size) paging.offset + paging.limit else null
|
||||
if (recordingIds.isEmpty()) {
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = tracks.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
val query = recordingIds.joinToString(" OR ") { "rid:$it" }
|
||||
val mbResponse = musicbrainzRepository.searchRecordings(
|
||||
query = query,
|
||||
limit = recordingIds.size,
|
||||
offset = 0
|
||||
)
|
||||
|
||||
val recordingsMap = mbResponse.recordings.associateBy { it.id }
|
||||
|
||||
val metadataTracks = recordingIds.mapNotNull { rid ->
|
||||
val recording = recordingsMap[rid] ?: return@mapNotNull null
|
||||
// We pick the first release as the album.
|
||||
val release = recording.releases.firstOrNull() ?: return@mapNotNull null
|
||||
val releaseGroupId = release.releaseGroup?.id ?: release.id
|
||||
val album = release.toMetadataAlbumDetailed(releaseGroupId)
|
||||
|
||||
recording.toMetadataTrack(album)
|
||||
}
|
||||
|
||||
return PaginationResult(
|
||||
items = metadataTracks,
|
||||
totalCount = tracks.size,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun savedPlaylists(
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataPlaylist> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
// 1. Fetch User Playlists (Remote)
|
||||
// 2. Fetch Saved Playlists (Local - from ids)
|
||||
|
||||
var username: String? = null
|
||||
try {
|
||||
username = requireUsername()
|
||||
} catch (_: Exception) {
|
||||
// If no username, we can't fetch remote user playlists
|
||||
}
|
||||
|
||||
var remoteTotal = 0L
|
||||
var remoteItems: List<MetadataPlaylist> = emptyList()
|
||||
|
||||
if (username != null) {
|
||||
try {
|
||||
val countRes = LbPlaylistsApi.playlistsForUser(username, count = 1, offset = 0)
|
||||
remoteTotal = countRes.getOrNull()?.data?.playlistCount ?: 0L
|
||||
|
||||
if (paging.offset < remoteTotal) {
|
||||
val limit = (paging.limit).toLong()
|
||||
val res = LbPlaylistsApi.playlistsForUser(
|
||||
username,
|
||||
count = limit,
|
||||
offset = paging.offset.toLong()
|
||||
)
|
||||
res.getOrNull()?.data?.let { data ->
|
||||
// playlistCount might be updated
|
||||
remoteTotal = data.playlistCount ?: remoteTotal
|
||||
remoteItems = data.playlists?.mapNotNull { req ->
|
||||
req.playlist?.let {
|
||||
playlistToMetadata(
|
||||
it,
|
||||
req.playlist.identifier?.substringAfterLast("/")
|
||||
.takeIf { id -> id != req.playlist.identifier } ?: "")
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// e.printStackTrace() // Removed printStackTrace in KMP common code usually
|
||||
}
|
||||
}
|
||||
|
||||
val ids = persistedStorage.getString(SAVED_PLAYLISTS_KEY)
|
||||
?.split(",")
|
||||
?.filter { it.isNotBlank() }
|
||||
?: emptyList()
|
||||
val savedCount = ids.size
|
||||
val savedItems = mutableListOf<MetadataPlaylist>()
|
||||
|
||||
// Calculate how many slots in pageSize are left to fill from Saved items
|
||||
val remoteFetchedCount = remoteItems.size
|
||||
val neededFromSaved = paging.limit - remoteFetchedCount
|
||||
|
||||
if (neededFromSaved > 0) {
|
||||
// We need checks:
|
||||
// 1. Did we exhaust remote? (pagination.offset + remoteFetchedCount >= remoteTotal)
|
||||
// 2. Or is pagination.offset already starting inside Saved list? (pagination.offset >= remoteTotal)
|
||||
val startInSaved: Long = if (paging.offset >= remoteTotal) {
|
||||
paging.offset - remoteTotal
|
||||
} else {
|
||||
// We were fetching from remote, and maybe it finished, so we append from start of saved
|
||||
0
|
||||
}
|
||||
|
||||
if (startInSaved < savedCount) {
|
||||
val endInSaved = (startInSaved + neededFromSaved).coerceAtMost(savedCount.toLong())
|
||||
val pageIds = ids.subList(startInSaved.toInt(), endInSaved.toInt())
|
||||
|
||||
val fetchedSaved = pageIds.mapNotNull { id ->
|
||||
try {
|
||||
getPlaylist(id)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
savedItems.addAll(fetchedSaved)
|
||||
}
|
||||
}
|
||||
|
||||
val allItems = remoteItems + savedItems
|
||||
val totalCount = remoteTotal + savedCount
|
||||
|
||||
val nextOffset = if (paging.offset + allItems.size < totalCount) paging.offset + allItems.size else null
|
||||
|
||||
return PaginationResult(
|
||||
items = allItems,
|
||||
totalCount = totalCount.toInt(),
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun playlistToMetadata(playlist: Playlist, id: String): MetadataPlaylist? {
|
||||
// Identifier usually contains URL, we need to extract ID if not passed
|
||||
val mbid = id.ifBlank {
|
||||
playlist.identifier?.substringAfterLast("/")
|
||||
?.takeIf { it != playlist.identifier } ?: return null
|
||||
}
|
||||
|
||||
val creator = playlist.creator ?: "Unknown"
|
||||
|
||||
return MetadataPlaylist(
|
||||
id = mbid,
|
||||
title = playlist.title ?: "Untitled",
|
||||
description = playlist.annotation,
|
||||
thumbnails = listOf(
|
||||
Thumbnail(
|
||||
url = "https://ui-avatars.com/api/?name=${playlist.title}&background=random",
|
||||
width = 300,
|
||||
height = 300
|
||||
)
|
||||
),
|
||||
trackCount = playlist.track?.size ?: 0,
|
||||
externalUri = playlist.identifier ?: "https://listenbrainz.org/playlist/$mbid",
|
||||
owner = MetadataUser(
|
||||
id = creator,
|
||||
username = creator,
|
||||
displayName = creator,
|
||||
thumbnails = emptyList(), // no avatar easily available
|
||||
externalUri = "https://listenbrainz.org/user/$creator/"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun isSavedPlaylists(ids: List<String>): List<Boolean> {
|
||||
val savedIds = persistedStorage.getString(SAVED_PLAYLISTS_KEY)
|
||||
?.split(",")
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
return ids.map { savedIds.contains(it) }
|
||||
}
|
||||
|
||||
override suspend fun savePlaylists(ids: List<String>) {
|
||||
val savedIds = (persistedStorage.getString(SAVED_PLAYLISTS_KEY)
|
||||
?.split(",")
|
||||
?.filter { it.isNotBlank() }
|
||||
?.toMutableSet()
|
||||
?: mutableSetOf()).apply {
|
||||
addAll(ids)
|
||||
}
|
||||
persistedStorage.putString(SAVED_PLAYLISTS_KEY, savedIds.joinToString(","))
|
||||
}
|
||||
|
||||
override suspend fun removeSavedPlaylists(ids: List<String>) {
|
||||
val savedIds = (persistedStorage.getString(SAVED_PLAYLISTS_KEY)
|
||||
?.split(",")
|
||||
?.filter { it.isNotBlank() }
|
||||
?.toMutableSet()
|
||||
?: mutableSetOf()).apply {
|
||||
removeAll(ids.toSet())
|
||||
}
|
||||
persistedStorage.putString(SAVED_PLAYLISTS_KEY, savedIds.joinToString(","))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SAVED_PLAYLISTS_KEY = "saved_playlists"
|
||||
}
|
||||
|
||||
override suspend fun createPlaylist(
|
||||
name: String,
|
||||
description: String?,
|
||||
isPublic: Boolean,
|
||||
isCollaborating: Boolean,
|
||||
imageBase64: String,
|
||||
trackIds: List<String>
|
||||
): MetadataPlaylist {
|
||||
val username = requireUsername()
|
||||
|
||||
val body = CreatePlaylistRequest(
|
||||
playlist = Playlist(
|
||||
title = name,
|
||||
annotation = description,
|
||||
track = trackIds.map {
|
||||
PlaylistTrackInner(
|
||||
identifier = listOf("https://musicbrainz.org/recording/$it")
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val res = LbPlaylistsApi.createPlaylist(body)
|
||||
val created =
|
||||
res.getOrNull()?.data ?: throw IllegalStateException("Failed to create playlist")
|
||||
|
||||
// The create response usually contains the MBID
|
||||
val mbid =
|
||||
created.playlistMbid?.toString() ?: throw IllegalStateException("No MBID returned")
|
||||
|
||||
return MetadataPlaylist(
|
||||
id = mbid,
|
||||
title = name,
|
||||
description = description,
|
||||
thumbnails = listOf(
|
||||
Thumbnail(
|
||||
url = "https://ui-avatars.com/api/?name=$name&background=random",
|
||||
width = 300,
|
||||
height = 300
|
||||
)
|
||||
),
|
||||
trackCount = trackIds.size,
|
||||
externalUri = "https://listenbrainz.org/playlist/$mbid",
|
||||
owner = MetadataUser(
|
||||
id = username,
|
||||
username = username,
|
||||
displayName = username,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://listenbrainz.org/user/$username/"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updatePlaylist(
|
||||
id: String,
|
||||
name: String?,
|
||||
description: String?,
|
||||
isPublic: Boolean?,
|
||||
isCollaborating: Boolean?,
|
||||
imageBase64: String?,
|
||||
trackIds: List<String>?
|
||||
): MetadataPlaylist {
|
||||
requireUsername()
|
||||
// If we need to fetch first to get existing values?
|
||||
// LB edit API usually replaces fields.
|
||||
|
||||
val body = CreatePlaylistRequest(
|
||||
playlist = Playlist(
|
||||
title = name ?: "Untitled",
|
||||
annotation = description,
|
||||
track = trackIds?.map {
|
||||
PlaylistTrackInner(
|
||||
identifier = listOf("https://musicbrainz.org/recording/$it")
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val res = LbPlaylistsApi.editPlaylist(Uuid.parse(id), body)
|
||||
if (res.isLeft()) throw IllegalStateException("Failed to update playlist: ${res.leftOrNull()}")
|
||||
|
||||
return getPlaylist(id)
|
||||
}
|
||||
|
||||
override suspend fun deletePlaylist(id: String) {
|
||||
requireUsername()
|
||||
LbPlaylistsApi.deletePlaylist(Uuid.parse(id))
|
||||
}
|
||||
|
||||
override suspend fun addTracksToPlaylist(
|
||||
playlistId: String,
|
||||
trackIds: List<String>
|
||||
) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun removeTracksFromPlaylist(
|
||||
playlistId: String,
|
||||
trackIds: List<String>
|
||||
) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,279 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumType
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.listenbrainz.LBPlaylistSearchResponse
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzArtistEnricher
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataSearchAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val artistEnricher: MusicbrainzArtistEnricher,
|
||||
private val httpClient: HttpClient
|
||||
): MetadataSearchAPI {
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
override val supportedSearchTypes: List<MetadataSupportedSearchType> = listOf(
|
||||
MetadataSupportedSearchType.TRACK,
|
||||
MetadataSupportedSearchType.ARTIST,
|
||||
MetadataSupportedSearchType.ALBUM,
|
||||
MetadataSupportedSearchType.PLAYLIST,
|
||||
)
|
||||
|
||||
override suspend fun search(query: String): List<MetadataSearchResult> = coroutineScope {
|
||||
val playlists = async { searchPlaylists(query, PaginationStrategy.Offset(offset = 0, limit = 5)).items }
|
||||
val tracks = async { searchTracks(query, PaginationStrategy.Offset(offset = 0, limit = 5)).items }
|
||||
val artists = async { searchArtists(query, PaginationStrategy.Offset(offset = 0, limit = 5)).items }
|
||||
val albums = async { searchAlbums(query, PaginationStrategy.Offset(offset = 0, limit = 5)).items }
|
||||
|
||||
val results = mutableListOf<MetadataSearchResult>()
|
||||
results.addAll(playlists.await())
|
||||
results.addAll(tracks.await())
|
||||
results.addAll(artists.await())
|
||||
results.addAll(albums.await())
|
||||
results
|
||||
}
|
||||
|
||||
override suspend fun searchTracks(
|
||||
query: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataSearchResult.Track> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
|
||||
val result = musicbrainzRepository.searchRecordings(
|
||||
query = query,
|
||||
limit = paging.limit,
|
||||
offset = paging.offset
|
||||
)
|
||||
val items = result.recordings.map { recording ->
|
||||
val release = recording.releases.firstOrNull()
|
||||
val releaseGroupId = release?.releaseGroup?.id ?: release?.id ?: recording.id
|
||||
|
||||
val albumDetailed = MetadataAlbum.Detailed(
|
||||
id = releaseGroupId,
|
||||
title = release?.releaseGroup?.title ?: release?.title ?: recording.title, // Fallback
|
||||
description = null,
|
||||
thumbnails = listOf(
|
||||
Thumbnail("https://coverartarchive.org/release-group/$releaseGroupId/front-250.jpg", 250, 250),
|
||||
Thumbnail("https://coverartarchive.org/release-group/$releaseGroupId/front-500.jpg", 500, 500)
|
||||
),
|
||||
albumType = MetadataAlbumType.Album, // Default
|
||||
artists = emptyList(), // Can populate if needed
|
||||
externalUri = "https://musicbrainz.org/release-group/$releaseGroupId",
|
||||
releaseDate = release?.date,
|
||||
genres = emptyList(),
|
||||
trackCount = release?.trackCount ?: 0
|
||||
)
|
||||
|
||||
MetadataSearchResult.Track(
|
||||
data = MetadataTrack(
|
||||
id = recording.id,
|
||||
title = recording.title,
|
||||
durationMs = recording.length?.toLong() ?: 0L,
|
||||
trackNumber = null,
|
||||
discNumber = null,
|
||||
artists = recording.artistCredit.mapNotNull { credit ->
|
||||
credit.artist?.let { artist ->
|
||||
MetadataArtist.Basic(
|
||||
id = artist.id,
|
||||
name = artist.name,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://musicbrainz.org/artist/${artist.id}"
|
||||
)
|
||||
}
|
||||
},
|
||||
album = albumDetailed,
|
||||
explicit = recording.tags.any { it.name.contains("explicit", ignoreCase = true) },
|
||||
popularity = null,
|
||||
isrcCode = recording.isrcs.firstOrNull(),
|
||||
externalUri = "https://musicbrainz.org/recording/${recording.id}",
|
||||
thumbnails = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < result.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = result.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun searchArtists(
|
||||
query: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataSearchResult.Artist> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
val result = musicbrainzRepository.searchArtists(
|
||||
query = query,
|
||||
limit = paging.limit,
|
||||
offset = paging.offset
|
||||
)
|
||||
|
||||
val artistIds = result.artists.map { it.id }
|
||||
val enriched = artistEnricher.getEnrichedArtists(artistIds)
|
||||
|
||||
val items = enriched.map { (artist, images) ->
|
||||
MetadataSearchResult.Artist(
|
||||
data = MetadataArtist.Basic(
|
||||
id = artist.id,
|
||||
name = artist.name,
|
||||
thumbnails = images.map { Thumbnail(it, 300, 300) },
|
||||
externalUri = "https://musicbrainz.org/artist/${artist.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < result.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = result.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun searchAlbums(
|
||||
query: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataSearchResult.Album> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
val result = musicbrainzRepository.searchReleaseGroups(
|
||||
query = query,
|
||||
limit = paging.limit,
|
||||
offset = paging.offset
|
||||
)
|
||||
|
||||
val items = result.releaseGroups.map { group ->
|
||||
MetadataSearchResult.Album(
|
||||
data = MetadataAlbum.Basic(
|
||||
id = group.id,
|
||||
title = group.title,
|
||||
description = null,
|
||||
thumbnails = listOf(
|
||||
Thumbnail("https://coverartarchive.org/release-group/${group.id}/front-250.jpg", 250, 250),
|
||||
Thumbnail("https://coverartarchive.org/release-group/${group.id}/front-500.jpg", 500, 500)
|
||||
),
|
||||
albumType = when (group.primaryType?.lowercase()) {
|
||||
"album" -> MetadataAlbumType.Album
|
||||
"single" -> MetadataAlbumType.Single
|
||||
"compilation" -> MetadataAlbumType.Collection
|
||||
else -> MetadataAlbumType.Album
|
||||
},
|
||||
artists = emptyList(), // Populate?
|
||||
externalUri = "https://musicbrainz.org/release-group/${group.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < result.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = result.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun searchPlaylists(
|
||||
query: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataSearchResult.Playlist> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
try {
|
||||
val responseText = httpClient.get {
|
||||
url("https://api.listenbrainz.org/1/playlist/search")
|
||||
parameter("query", query)
|
||||
parameter("count", paging.limit)
|
||||
parameter("offset", paging.offset)
|
||||
}.bodyAsText()
|
||||
|
||||
val response = json.decodeFromString<LBPlaylistSearchResponse>(responseText)
|
||||
|
||||
val items = response.playlists.map { it.playlist }.map { playlist ->
|
||||
val id = playlist.identifier.substringAfterLast("/")
|
||||
MetadataSearchResult.Playlist(
|
||||
data = MetadataPlaylist(
|
||||
id = id,
|
||||
title = playlist.title,
|
||||
description = playlist.annotation,
|
||||
thumbnails = emptyList(), // Listenbrainz search doesn't return playlist covers usually
|
||||
trackCount = 0, // Not available in search result
|
||||
owner = MetadataUser(
|
||||
id = playlist.creator,
|
||||
username = playlist.creator,
|
||||
displayName = playlist.creator,
|
||||
thumbnails = emptyList(),
|
||||
externalUri = "https://listenbrainz.org/user/${playlist.creator}"
|
||||
),
|
||||
externalUri = "https://listenbrainz.org/playlist/$id"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val nextOffset = if (paging.offset + paging.limit < response.count) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = items,
|
||||
totalCount = response.count,
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return PaginationResult(emptyList(), 0, null)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(
|
||||
query: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataSearchResult.User> {
|
||||
return PaginationResult(emptyList(), 0, null)
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz
|
||||
|
||||
import arrow.core.Either
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz.MusicbrainzRepository
|
||||
import dev.krtirtho.spotube.listenbrainz.Api
|
||||
import dev.krtirtho.spotube.listenbrainz.Auth
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbCoreApi
|
||||
import dev.krtirtho.spotube.listenbrainz.api.LbRecordingsApi
|
||||
import dev.krtirtho.spotube.listenbrainz.models.RecordingFeedbackRequest
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class RealMusicbrainzListenbrainzMetadataTrackAPI(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val persistedStorage: PersistedStorageAPI
|
||||
) : MetadataTrackAPI {
|
||||
|
||||
private var cachedUsername: String? = null
|
||||
|
||||
private suspend fun requireUsername(): String {
|
||||
cachedUsername?.let { return it }
|
||||
|
||||
val auth = Auth.ApiKeyAuth {
|
||||
val token = persistedStorage.getString("listenbrainz_auth_token") ?: return@ApiKeyAuth null
|
||||
if (token.startsWith("Token ", ignoreCase = true)) token else "Token $token"
|
||||
}
|
||||
Api.setAuthProvider(auth)
|
||||
|
||||
val username = when (val res = LbCoreApi.validateToken()) {
|
||||
is Either.Left -> throw IllegalStateException("Unable to resolve ListenBrainz username: ${res.value}")
|
||||
is Either.Right -> res.value.data.userName
|
||||
}
|
||||
cachedUsername = username!!
|
||||
return username
|
||||
}
|
||||
|
||||
override suspend fun getTrack(id: String): MetadataTrack {
|
||||
val recording = musicbrainzRepository.getRecordingByMbid(
|
||||
mbid = id,
|
||||
includes = listOf("artists", "releases", "artist-credits", "release-groups")
|
||||
)
|
||||
val release = recording.releases.firstOrNull()
|
||||
?: throw IllegalStateException("No release found for track")
|
||||
|
||||
val album = release.toMetadataAlbumDetailed(release.releaseGroup?.id ?: release.id)
|
||||
|
||||
// This creates basic track metadata from MusicBrainz
|
||||
return recording.toMetadataTrack(album)
|
||||
}
|
||||
|
||||
override suspend fun savedTracks(
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataTrack> {
|
||||
val paging = pagination as? PaginationStrategy.Offset ?: PaginationStrategy.Offset(0, 20)
|
||||
|
||||
val username = try {
|
||||
requireUsername()
|
||||
} catch (_: Exception) {
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = 0,
|
||||
nextPagination = null
|
||||
)
|
||||
}
|
||||
|
||||
val res = LbRecordingsApi.getFeedback(
|
||||
userName = username,
|
||||
score = 1,
|
||||
count = paging.limit.toLong(),
|
||||
offset = paging.offset.toLong()
|
||||
)
|
||||
|
||||
val feedbackResponse = res.getOrNull()?.data ?: return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = 0,
|
||||
nextPagination = null
|
||||
)
|
||||
val feedbacks = feedbackResponse.feedback ?: emptyList()
|
||||
val mbids = feedbacks.mapNotNull { it.recordingMbid?.toString() }
|
||||
|
||||
if (mbids.isEmpty()) {
|
||||
return PaginationResult(
|
||||
items = emptyList(),
|
||||
totalCount = (feedbackResponse.totalCount ?: 0).toInt(),
|
||||
nextPagination = null
|
||||
)
|
||||
}
|
||||
|
||||
// Batch fetch metadata
|
||||
val query = mbids.joinToString(" OR ") { "rid:$it" }
|
||||
val searchRes = musicbrainzRepository.searchRecordings(query, limit = mbids.size)
|
||||
val recordingMap = searchRes.recordings.associateBy { it.id }
|
||||
|
||||
val tracks = mbids.mapNotNull { mbid ->
|
||||
val recording = recordingMap[mbid] ?: return@mapNotNull null
|
||||
val release = recording.releases.firstOrNull() ?: return@mapNotNull null
|
||||
val album = release.toMetadataAlbumDetailed(release.releaseGroup?.id ?: release.id)
|
||||
recording.toMetadataTrack(album)
|
||||
}
|
||||
|
||||
val nextOffset = if ((paging.offset + paging.limit) < (feedbackResponse.totalCount ?: 0)) {
|
||||
paging.offset + paging.limit
|
||||
} else null
|
||||
|
||||
return PaginationResult(
|
||||
items = tracks,
|
||||
totalCount = (feedbackResponse.totalCount ?: 0).toInt(),
|
||||
nextPagination = nextOffset?.let { PaginationStrategy.Offset(it, paging.limit) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun isSavedTracks(ids: List<String>): List<Boolean> {
|
||||
val username = try {
|
||||
requireUsername()
|
||||
} catch (_: Exception) {
|
||||
return ids.map { false }
|
||||
}
|
||||
|
||||
val uuids = ids.mapNotNull {
|
||||
try { Uuid.parse(it) } catch(_: Exception) { null }
|
||||
}
|
||||
if (uuids.isEmpty()) return ids.map { false }
|
||||
|
||||
val res = LbRecordingsApi.getFeedbackForRecordings(
|
||||
userName = username,
|
||||
recordingMbids = uuids
|
||||
)
|
||||
|
||||
val feedbackMap = res.getOrNull()?.data?.feedback?.associateBy { it.recordingMbid.toString() } ?: emptyMap()
|
||||
|
||||
return ids.map { id ->
|
||||
feedbackMap[id]?.score == 1L
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveTracks(ids: List<String>) {
|
||||
requireUsername()
|
||||
ids.forEach { id ->
|
||||
try {
|
||||
LbRecordingsApi.recordingFeedback(
|
||||
RecordingFeedbackRequest(
|
||||
recordingMbid = Uuid.parse(id),
|
||||
score = 1
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeSavedTracks(ids: List<String>) {
|
||||
requireUsername()
|
||||
ids.forEach { id ->
|
||||
try {
|
||||
LbRecordingsApi.recordingFeedback(
|
||||
RecordingFeedbackRequest(
|
||||
recordingMbid = Uuid.parse(id),
|
||||
score = 0
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun recommendationsBasedOnTracks(
|
||||
seedTrackIds: List<String>,
|
||||
limit: Int
|
||||
): List<MetadataTrack> {
|
||||
requireUsername()
|
||||
val idsParam = seedTrackIds.joinToString(",")
|
||||
|
||||
val jsonStr = try {
|
||||
val response = Api.client.get("https://api.listenbrainz.org/1/recommendation/playground/recording_recommendations") {
|
||||
parameter("recording_mbids", idsParam)
|
||||
parameter("count", limit)
|
||||
val token = persistedStorage.getString("listenbrainz_auth_token")
|
||||
if (token != null) {
|
||||
header("Authorization", "Token $token")
|
||||
}
|
||||
}
|
||||
response.bodyAsText()
|
||||
} catch (_: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val root = json.parseToJsonElement(jsonStr).jsonObject
|
||||
val payload = root["payload"]?.jsonObject
|
||||
val recordings = payload?.get("recordings")?.jsonArray ?: return emptyList()
|
||||
|
||||
val mbids = recordings.mapNotNull {
|
||||
it.jsonObject["recording_mbid"]?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
|
||||
if (mbids.isEmpty()) return emptyList()
|
||||
|
||||
val query = mbids.joinToString(" OR ") { "rid:$it" }
|
||||
val searchRes = musicbrainzRepository.searchRecordings(query, limit = mbids.size)
|
||||
val recordingMap = searchRes.recordings.associateBy { it.id }
|
||||
|
||||
return mbids.mapNotNull { mbid ->
|
||||
val recording = recordingMap[mbid] ?: return@mapNotNull null
|
||||
val release = recording.releases.firstOrNull() ?: return@mapNotNull null
|
||||
val album = release.toMetadataAlbumDetailed(release.releaseGroup?.id ?: release.id)
|
||||
recording.toMetadataTrack(album)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.listenbrainz
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylistExtensionSpopf(
|
||||
@SerialName("public")
|
||||
val isPublic: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylistExtension(
|
||||
@SerialName("https://musicbrainz.org/doc/jspf#playlist")
|
||||
val spopf: LBPlaylistExtensionSpopf? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylistUser(
|
||||
val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylist(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val annotation: String? = null,
|
||||
val creator: String, // In search response it is string (username)
|
||||
val extension: LBPlaylistExtension? = null,
|
||||
val date: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylistObject(
|
||||
val playlist: LBPlaylist,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LBPlaylistSearchResponse(
|
||||
@SerialName("playlist_count")
|
||||
val count: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val playlists: List<LBPlaylistObject> = emptyList(),
|
||||
)
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzTag(
|
||||
val name: String,
|
||||
val count: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzLifeSpan(
|
||||
val begin: String? = null,
|
||||
val end: String? = null,
|
||||
val ended: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzTextRepresentation(
|
||||
val language: String? = null,
|
||||
val script: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzArtist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val score: String? = null,
|
||||
@SerialName("sort-name")
|
||||
val sortName: String? = null,
|
||||
val country: String? = null,
|
||||
val type: String? = null,
|
||||
val gender: String? = null,
|
||||
val disambiguation: String? = null,
|
||||
@SerialName("life-span")
|
||||
val lifeSpan: MusicbrainzLifeSpan? = null,
|
||||
val tags: List<MusicbrainzTag> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzArtistCredit(
|
||||
val name: String? = null,
|
||||
@SerialName("joinphrase")
|
||||
val joinPhrase: String? = null,
|
||||
val artist: MusicbrainzArtist? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzReleaseGroup(
|
||||
val id: String,
|
||||
val title: String,
|
||||
@SerialName("primary-type")
|
||||
val primaryType: String? = null,
|
||||
@SerialName("secondary-types")
|
||||
val secondaryTypes: List<String> = emptyList(),
|
||||
@SerialName("first-release-date")
|
||||
val firstReleaseDate: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzRelease(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val score: String? = null,
|
||||
val status: String? = null,
|
||||
val quality: String? = null,
|
||||
val date: String? = null,
|
||||
val country: String? = null,
|
||||
@SerialName("barcode")
|
||||
val barCode: String? = null,
|
||||
@SerialName("track-count")
|
||||
val trackCount: Int? = null,
|
||||
@SerialName("text-representation")
|
||||
val textRepresentation: MusicbrainzTextRepresentation? = null,
|
||||
@SerialName("artist-credit")
|
||||
val artistCredit: List<MusicbrainzArtistCredit> = emptyList(),
|
||||
@SerialName("release-group")
|
||||
val releaseGroup: MusicbrainzReleaseGroup? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzRecording(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val length: Int? = null,
|
||||
val disambiguation: String? = null,
|
||||
val video: Boolean? = null,
|
||||
val score: String? = null,
|
||||
@SerialName("first-release-date")
|
||||
val firstReleaseDate: String? = null,
|
||||
@SerialName("artist-credit")
|
||||
val artistCredit: List<MusicbrainzArtistCredit> = emptyList(),
|
||||
val releases: List<MusicbrainzRelease> = emptyList(),
|
||||
val tags: List<MusicbrainzTag> = emptyList(),
|
||||
val isrcs: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzRecordingSearchResponse(
|
||||
val created: String? = null,
|
||||
val count: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val recordings: List<MusicbrainzRecording> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzArtistSearchResponse(
|
||||
val created: String? = null,
|
||||
val count: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val artists: List<MusicbrainzArtist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzReleaseSearchResponse(
|
||||
val created: String? = null,
|
||||
val count: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val releases: List<MusicbrainzRelease> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzReleaseGroupSearchResponse(
|
||||
val created: String? = null,
|
||||
val count: Int = 0,
|
||||
val offset: Int = 0,
|
||||
@SerialName("release-groups")
|
||||
val releaseGroups: List<MusicbrainzReleaseGroup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzUrlRelation(
|
||||
val artist: MusicbrainzArtist? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzUrlRelationList(
|
||||
val relations: List<MusicbrainzUrlRelation> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzUrl(
|
||||
val resource: String,
|
||||
@SerialName("relation-list")
|
||||
val relationList: List<MusicbrainzUrlRelationList> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzUrlResponse(
|
||||
val urls: List<MusicbrainzUrl> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MusicbrainzIsrcLookupResponse(
|
||||
val isrc: String,
|
||||
val recordings: List<MusicbrainzRecording> = emptyList(),
|
||||
)
|
||||
@ -1,175 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz
|
||||
|
||||
import dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.wikidata.WikidataRepository
|
||||
|
||||
class MusicbrainzArtistEnricher(
|
||||
private val musicbrainzRepository: MusicbrainzRepository,
|
||||
private val wikidataRepository: WikidataRepository
|
||||
) {
|
||||
suspend fun getArtistsWithImages(artistIds: List<String>): List<MusicbrainzArtist> {
|
||||
if (artistIds.isEmpty()) return emptyList()
|
||||
|
||||
// 1. Find Wikidata URLs for these artists
|
||||
val idsQuery = artistIds.joinToString(" OR ") { "targetid:$it" }
|
||||
val query = "relationtype:wikidata AND targettype:artist AND ($idsQuery)"
|
||||
|
||||
val urlResponse = musicbrainzRepository.searchUrls(
|
||||
query = query,
|
||||
limit = artistIds.size
|
||||
)
|
||||
|
||||
val urls = urlResponse.urls
|
||||
val wikidataIds = urls.map { it.resource.substringAfterLast("/") }
|
||||
|
||||
// Map MBID to Wikidata ID based on response
|
||||
// The response structure for /url search is a bit complex.
|
||||
// It returns URLs, and each URL has relation-list -> relations -> artist -> id
|
||||
|
||||
val mbidToWikidataId = mutableMapOf<String, String>()
|
||||
|
||||
urls.forEach { url ->
|
||||
val wikidataId = url.resource.substringAfterLast("/")
|
||||
val artistId = url.relationList.firstOrNull()?.relations?.firstOrNull()?.artist?.id
|
||||
if (artistId != null) {
|
||||
mbidToWikidataId[artistId] = wikidataId
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch images for Wikidata IDs
|
||||
val imagesMap = wikidataRepository.getArtistImages(wikidataIds)
|
||||
|
||||
// 3. Construct result
|
||||
// We need to return MusicbrainzArtist objects. We might need to fetch details if we don't have them.
|
||||
// But here we are enriching.
|
||||
// Logic in search.ht:
|
||||
// - Get wikidata IDs
|
||||
// - Fetch images
|
||||
// - Fetch artist details for artists WITHOUT wikidata link (using /artist search or lookup)
|
||||
// - Combine
|
||||
|
||||
// Let's search/fetch artist details for all IDs using their MBIDs
|
||||
// Optimally we can batch fetch if possible, but MB API usually requires individual lookups or search.
|
||||
// search.ht uses search endpoint with "arid:ID OR arid:ID..."
|
||||
|
||||
val artistsResponse = musicbrainzRepository.searchArtists(
|
||||
query = artistIds.joinToString(" OR ") { "arid:$it" },
|
||||
limit = artistIds.size
|
||||
)
|
||||
|
||||
val artists = artistsResponse.artists.map { artist ->
|
||||
// Check if we have an image for this artist
|
||||
// We need to know which wikidata ID corresponds to this artist
|
||||
val wikidataId = mbidToWikidataId[artist.id]
|
||||
val imageUrl = wikidataId?.let { imagesMap[it] }
|
||||
|
||||
// We don't have a field for 'images' in MusicbrainzArtist model yet.
|
||||
// We should probably return a Pair or a new model, or just rely on the fact that we can't modify MusicbrainzArtist easily if it's data class.
|
||||
// But wait, search.ht returns list of items which are maps.
|
||||
|
||||
// I'll return MusicbrainzArtist, but I need to attach the image somehow.
|
||||
// I'll assume passing the image URL up is handled by the caller or I'll wrap it.
|
||||
// But MusicbrainzArtist is a data class.
|
||||
|
||||
// Wait, Kotlin data classes are immutable.
|
||||
// references used in search.ht:
|
||||
// return { id: ..., name: ..., images: [url, ...] }
|
||||
|
||||
// So this Enricher should probably return a data structure that holds Artist + Image list.
|
||||
EnrichedArtist(artist, imageUrl?.let { listOf(it) } ?: emptyList())
|
||||
}
|
||||
|
||||
return artists.map { it.artist } // Wait, how to attach image?
|
||||
}
|
||||
|
||||
data class EnrichedArtist(
|
||||
val artist: MusicbrainzArtist,
|
||||
val images: List<String>
|
||||
)
|
||||
|
||||
suspend fun getEnrichedArtists(artistIds: List<String>): List<EnrichedArtist> {
|
||||
if (artistIds.isEmpty()) return emptyList()
|
||||
|
||||
// 1. Find Wikidata URLs for these artists
|
||||
val idsQuery = artistIds.joinToString(" OR ") { "targetid:$it" }
|
||||
val query = "relationtype:wikidata AND targettype:artist AND ($idsQuery)"
|
||||
|
||||
val urlResponse = musicbrainzRepository.searchUrls(
|
||||
query = query,
|
||||
limit = artistIds.size
|
||||
)
|
||||
|
||||
val urls = urlResponse.urls
|
||||
|
||||
val mbidToWikidataId = mutableMapOf<String, String>()
|
||||
urls.forEach { url ->
|
||||
val wikidataId = url.resource.substringAfterLast("/")
|
||||
val artistId = url.relationList.firstOrNull()?.relations?.firstOrNull()?.artist?.id
|
||||
if (artistId != null) {
|
||||
mbidToWikidataId[artistId] = wikidataId
|
||||
}
|
||||
}
|
||||
|
||||
val wikidataIds = mbidToWikidataId.values.toList()
|
||||
|
||||
// 2. Fetch images for Wikidata IDs
|
||||
val imagesMap = if (wikidataIds.isNotEmpty()) {
|
||||
wikidataRepository.getArtistImages(wikidataIds)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
// 3. Fetch artist details
|
||||
// search.ht fetches "missingArtistIds" (artists without wikidata link) separately.
|
||||
// But here we can just fetch ALL artists using one query (arid:...) because we need details for all of them anyway.
|
||||
// search.ht fetches "artistWithImages" from wikidata info (it gets artist object from /url response relation),
|
||||
// and "artistWithoutImages" from /artist search.
|
||||
// The /url response relation includes the artist object!
|
||||
|
||||
// Let's follow search.ht optimization:
|
||||
// Use artists from /url response for those that have wikidata.
|
||||
// Use /artist search for those that don't.
|
||||
|
||||
val artistsFromUrl = urls.mapNotNull { url ->
|
||||
val artist = url.relationList.firstOrNull()?.relations?.firstOrNull()?.artist
|
||||
val wikidataId = url.resource.substringAfterLast("/")
|
||||
val imageUrl = imagesMap[wikidataId]
|
||||
|
||||
if (artist != null) {
|
||||
EnrichedArtist(artist, imageUrl?.let { listOf(it) } ?: emptyList())
|
||||
} else null
|
||||
}
|
||||
|
||||
val foundArtistIds = artistsFromUrl.map { it.artist.id }.toSet()
|
||||
val missingRequestIds = artistIds.filter { !foundArtistIds.contains(it) }
|
||||
|
||||
val artistsFromSearch = if (missingRequestIds.isNotEmpty()) {
|
||||
val artistsResponse = musicbrainzRepository.searchArtists(
|
||||
query = missingRequestIds.joinToString(" OR ") { "arid:$it" },
|
||||
limit = missingRequestIds.size
|
||||
)
|
||||
artistsResponse.artists.map { EnrichedArtist(it, emptyList()) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return artistsFromUrl + artistsFromSearch
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.musicbrainz
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
interface MusicbrainzRepository {
|
||||
suspend fun searchRecordings(
|
||||
query: String,
|
||||
limit: Int = 25,
|
||||
offset: Int = 0,
|
||||
): MusicbrainzRecordingSearchResponse
|
||||
|
||||
suspend fun searchArtists(
|
||||
query: String,
|
||||
limit: Int = 25,
|
||||
offset: Int = 0,
|
||||
): MusicbrainzArtistSearchResponse
|
||||
|
||||
suspend fun searchReleases(
|
||||
query: String,
|
||||
limit: Int = 25,
|
||||
offset: Int = 0,
|
||||
): MusicbrainzReleaseSearchResponse
|
||||
|
||||
suspend fun searchReleaseGroups(
|
||||
query: String,
|
||||
limit: Int = 25,
|
||||
offset: Int = 0,
|
||||
): MusicbrainzReleaseGroupSearchResponse
|
||||
|
||||
suspend fun searchUrls(
|
||||
query: String,
|
||||
limit: Int = 25,
|
||||
): MusicbrainzUrlResponse
|
||||
|
||||
suspend fun getRecordingByMbid(
|
||||
mbid: String,
|
||||
includes: List<String> = emptyList(),
|
||||
): MusicbrainzRecording
|
||||
|
||||
suspend fun getArtistByMbid(
|
||||
mbid: String,
|
||||
includes: List<String> = emptyList(),
|
||||
): MusicbrainzArtist
|
||||
|
||||
suspend fun getReleaseByMbid(
|
||||
mbid: String,
|
||||
includes: List<String> = emptyList(),
|
||||
): MusicbrainzRelease
|
||||
|
||||
suspend fun lookupIsrc(
|
||||
isrc: String,
|
||||
includes: List<String> = emptyList(),
|
||||
): MusicbrainzIsrcLookupResponse
|
||||
}
|
||||
|
||||
class KtorMusicbrainzRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val baseUrl: String = "https://musicbrainz.org",
|
||||
private val userAgent: String = "Spotube/0.1 (https://github.com/KRTirtho/spotube)",
|
||||
) : MusicbrainzRepository {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun searchRecordings(
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): MusicbrainzRecordingSearchResponse {
|
||||
val payload = fetch(
|
||||
endpoint = "recording",
|
||||
query = query,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun searchArtists(
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): MusicbrainzArtistSearchResponse {
|
||||
val payload = fetch(
|
||||
endpoint = "artist",
|
||||
query = query,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun searchReleases(
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): MusicbrainzReleaseSearchResponse {
|
||||
val payload = fetch(
|
||||
endpoint = "release",
|
||||
query = query,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun searchReleaseGroups(
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
): MusicbrainzReleaseGroupSearchResponse {
|
||||
val payload = fetch(
|
||||
endpoint = "release-group",
|
||||
query = query,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun searchUrls(
|
||||
query: String,
|
||||
limit: Int,
|
||||
): MusicbrainzUrlResponse {
|
||||
val payload = fetch(
|
||||
endpoint = "url",
|
||||
query = query,
|
||||
limit = limit,
|
||||
)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun getRecordingByMbid(
|
||||
mbid: String,
|
||||
includes: List<String>,
|
||||
): MusicbrainzRecording {
|
||||
val payload = fetch(endpoint = "recording/${mbid.trim()}", inc = includes)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun getArtistByMbid(
|
||||
mbid: String,
|
||||
includes: List<String>,
|
||||
): MusicbrainzArtist {
|
||||
val payload = fetch(endpoint = "artist/${mbid.trim()}", inc = includes)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun getReleaseByMbid(
|
||||
mbid: String,
|
||||
includes: List<String>,
|
||||
): MusicbrainzRelease {
|
||||
val payload = fetch(endpoint = "release/${mbid.trim()}", inc = includes)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
override suspend fun lookupIsrc(
|
||||
isrc: String,
|
||||
includes: List<String>,
|
||||
): MusicbrainzIsrcLookupResponse {
|
||||
val payload = fetch(endpoint = "isrc/${isrc.trim()}", inc = includes)
|
||||
return json.decodeFromString(payload)
|
||||
}
|
||||
|
||||
private suspend fun fetch(
|
||||
endpoint: String,
|
||||
query: String? = null,
|
||||
limit: Int? = null,
|
||||
offset: Int? = null,
|
||||
inc: List<String> = emptyList(),
|
||||
): String {
|
||||
val response = httpClient.get {
|
||||
url("${baseUrl.trimEnd('/')}/ws/2/$endpoint")
|
||||
header(HttpHeaders.Accept, "application/json")
|
||||
header(HttpHeaders.UserAgent, userAgent)
|
||||
parameter("fmt", "json")
|
||||
query?.takeIf { it.isNotBlank() }?.let { parameter("query", it) }
|
||||
limit?.let { parameter("limit", it) }
|
||||
offset?.let { parameter("offset", it) }
|
||||
inc.toMusicbrainzInc()?.let { parameter("inc", it) }
|
||||
}
|
||||
|
||||
return response.requireBody()
|
||||
}
|
||||
}
|
||||
|
||||
class MusicbrainzApiException(
|
||||
val statusCode: Int,
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
private suspend fun HttpResponse.requireBody(): String {
|
||||
val text = bodyAsText()
|
||||
if (status.isSuccess()) return text
|
||||
|
||||
throw MusicbrainzApiException(
|
||||
statusCode = status.value,
|
||||
message = "MusicBrainz request failed (${status.value}) for ${request.url}. Response: ${text.take(512)}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<String>.toMusicbrainzInc(): String? {
|
||||
val cleaned = map { it.trim() }.filter { it.isNotBlank() }
|
||||
if (cleaned.isEmpty()) return null
|
||||
return cleaned.joinToString(separator = "+")
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.wikidata
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntityValue(
|
||||
val value: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntityDataValue(
|
||||
val value: String? = null // It can be object but for P18 (image) it is string (filename)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntitySnak(
|
||||
val datavalue: WikidataEntityDataValue? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntityClaim(
|
||||
val mainsnak: WikidataEntitySnak? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntity(
|
||||
val id: String,
|
||||
val claims: Map<String, List<WikidataEntityClaim>>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikidataEntitiesResponse(
|
||||
val entities: Map<String, WikidataEntity> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikimediaImageInfo(
|
||||
val thumburl: String? = null,
|
||||
val thumbwidth: Int? = null,
|
||||
val thumbheight: Int? = null,
|
||||
val url: String? = null,
|
||||
val width: Int? = null,
|
||||
val height: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikimediaPage(
|
||||
val pageid: Long,
|
||||
val title: String,
|
||||
val imageinfo: List<WikimediaImageInfo>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikimediaQuery(
|
||||
val pages: Map<String, WikimediaPage> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WikimediaResponse(
|
||||
val query: WikimediaQuery? = null
|
||||
)
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.plugin_apis.musicbrainz_listenbrainz.wikidata
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class WikidataRepository(private val httpClient: HttpClient) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
suspend fun getArtistImages(wikidataIds: List<String>): Map<String, String?> {
|
||||
if (wikidataIds.isEmpty()) return emptyMap()
|
||||
|
||||
try {
|
||||
// 1. Get image filenames from Wikidata
|
||||
val wikidataResponseText = httpClient.get {
|
||||
url("https://www.wikidata.org/w/api.php")
|
||||
parameter("format", "json")
|
||||
parameter("props", "claims")
|
||||
parameter("ids", wikidataIds.joinToString("|"))
|
||||
parameter("action", "wbgetentities")
|
||||
}.bodyAsText()
|
||||
|
||||
val wikidataResponse = json.decodeFromString<WikidataEntitiesResponse>(wikidataResponseText)
|
||||
|
||||
val idsWithImageNames = wikidataIds.map { id ->
|
||||
val imageName = wikidataResponse.entities[id]?.claims?.get("P18")?.firstOrNull()?.mainsnak?.datavalue?.value
|
||||
id to imageName
|
||||
}
|
||||
|
||||
val imageNames = idsWithImageNames.mapNotNull { it.second }
|
||||
if (imageNames.isEmpty()) return wikidataIds.associateWith { null }
|
||||
|
||||
val titles = imageNames.map { "File:$it" }.joinToString("|")
|
||||
|
||||
// 2. Get image URLs from Wikimedia Commons
|
||||
val commonsResponseText = httpClient.get {
|
||||
url("https://commons.wikimedia.org/w/api.php")
|
||||
parameter("prop", "imageinfo")
|
||||
parameter("action", "query")
|
||||
parameter("iiprop", "url|size")
|
||||
parameter("iiurlheight", 300)
|
||||
parameter("iiurlwidth", 300)
|
||||
parameter("format", "json")
|
||||
parameter("titles", titles)
|
||||
}.bodyAsText()
|
||||
|
||||
val commonsResponse = json.decodeFromString<WikimediaResponse>(commonsResponseText)
|
||||
val pages = commonsResponse.query?.pages?.values ?: emptyList()
|
||||
|
||||
val imagesMap = mutableMapOf<String, String?>()
|
||||
|
||||
// Initialize all with null
|
||||
wikidataIds.forEach { imagesMap[it] = null }
|
||||
|
||||
for (page in pages) {
|
||||
val imageName = page.title.removePrefix("File:")
|
||||
val imageUrl = page.imageinfo?.firstOrNull()?.thumburl ?: page.imageinfo?.firstOrNull()?.url
|
||||
|
||||
// Find original Wikidata ID for this image name
|
||||
val wikidataId = idsWithImageNames.firstOrNull { it.second == imageName }?.first
|
||||
if (wikidataId != null) {
|
||||
imagesMap[wikidataId] = imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return imagesMap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return wikidataIds.associateWith { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,20 +29,6 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
|
||||
),
|
||||
abilities = listOf(PluginAbility.AUDIO)
|
||||
)
|
||||
val MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN = PluginEntry(
|
||||
name = "MusicBrainz ListenBrainz",
|
||||
version = "0.1.0",
|
||||
apiVersion = PLUGIN_API_VERSION,
|
||||
description = "MusicBrainz ListenBrainz plugin for scrobbling tracks and fetching metadata.",
|
||||
author = "Spotube Team",
|
||||
capabilities = listOf(
|
||||
PluginCapability.NETWORK_REQUESTS,
|
||||
PluginCapability.PERSISTENT_STORAGE,
|
||||
PluginCapability.WEBVIEW,
|
||||
),
|
||||
abilities = listOf(PluginAbility.METADATA, PluginAbility.SCROBBLE)
|
||||
)
|
||||
|
||||
val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
|
||||
name = "LRCLib Lyrics",
|
||||
version = "0.1.0",
|
||||
@ -55,7 +41,6 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
|
||||
abilities = listOf(PluginAbility.LYRICS)
|
||||
)
|
||||
val BUILT_IN_PLUGINS = listOf(
|
||||
MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN,
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||
LRCLIB_BUILT_IN_PLUGIN,
|
||||
)
|
||||
|
||||
@ -103,8 +103,8 @@ class PluginManager(
|
||||
.map { p ->
|
||||
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
|
||||
val defaultSelectedPlugins = mapOf(
|
||||
PluginAbility.METADATA to MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN,
|
||||
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||
PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN,
|
||||
)
|
||||
if (json == null) {
|
||||
PluginManagerStates.Data(
|
||||
|
||||
@ -67,7 +67,6 @@ material3-window-size = "1.9.0"
|
||||
kotlinx-datetime = "0.7.1"
|
||||
media3 = "1.8.0"
|
||||
androidx-datastore = "1.2.1"
|
||||
kmpgen = "1.3.0"
|
||||
ksp = "2.3.5"
|
||||
spotube-gradle = "0.1.0"
|
||||
cryptography = "0.6.0"
|
||||
@ -127,6 +126,8 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
@ -171,7 +172,6 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio
|
||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
|
||||
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
|
||||
kmpgen = { id = "com.kroegerama.openapi-kmp-gen", version.ref = "kmpgen" }
|
||||
mavenPublish = { id = "maven-publish" }
|
||||
spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" }
|
||||
vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }
|
||||
Loading…
Reference in New Issue
Block a user