refactor: remove MusicBrainz ListenBrainz plugin and related configurations

This commit is contained in:
Kingkor Roy Tirtho 2026-06-30 17:13:21 +06:00
parent 0c426689cb
commit 28ff68dcc9
25 changed files with 7 additions and 15825 deletions

View File

@ -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`). - 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 ## 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/`. - 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`. - 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`.

View File

@ -29,6 +29,5 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.zipline.gradle.plugin) apply false alias(libs.plugins.zipline.gradle.plugin) apply false
alias(libs.plugins.spotubeGradle) apply false alias(libs.plugins.spotubeGradle) apply false
alias(libs.plugins.kmpgen) apply false
alias(libs.plugins.vlcjBundler) apply false alias(libs.plugins.vlcjBundler) apply false
} }

View File

@ -29,7 +29,6 @@ plugins {
alias(libs.plugins.composeHotReload) alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.zipline.gradle.plugin) alias(libs.plugins.zipline.gradle.plugin)
alias(libs.plugins.kmpgen)
alias(libs.plugins.vlcjBundler) alias(libs.plugins.vlcjBundler)
} }
@ -38,14 +37,6 @@ vlcjBundler {
objectName = "VLCBundleLoaderGenerated" // or rename the object objectName = "VLCBundleLoaderGenerated" // or rename the object
} }
kmpgen {
spec(
packageName = "dev.krtirtho.spotube.listenbrainz"
) {
specFile = file("./specs/listenbrainz-openapi.yaml")
}
}
kotlin { kotlin {
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x. // 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. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
@ -103,6 +94,8 @@ kotlin {
// ktor // ktor
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.logging) 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.client.cio)
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)

File diff suppressed because it is too large Load Diff

View File

@ -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.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI 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.common.RealCoreAPI
import dev.krtirtho.spotube.core.zipline.plugin_apis.lrclib.RealLRCLibLyricsAPI 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.core.zipline.plugin_apis.newpipe_yt.RealNewPipeAudioAPI
import dev.krtirtho.spotube.modules.plugin.LRCLIB_BUILT_IN_PLUGIN 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.NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import io.ktor.client.HttpClient
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.reflect.KClass import kotlin.reflect.KClass
class BuiltInPluginService( class BuiltInPluginService(
@ -52,10 +45,10 @@ class BuiltInPluginService(
override val loggedInFlow = loggedInStateFlow.asStateFlow() override val loggedInFlow = loggedInStateFlow.asStateFlow()
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>() 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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val persistedStorage by lazy { RealPersistedStorageAPI(pluginInfo) }
private fun runLogInFlowObservers() = scope.launch { private fun runLogInFlowObservers() = scope.launch {
val coreAPI = servicesRegistry[CoreAPI::class] as CoreAPI? val coreAPI = servicesRegistry[CoreAPI::class] as CoreAPI?
@ -72,17 +65,6 @@ class BuiltInPluginService(
servicesRegistry[AudioAPI::class] = RealNewPipeAudioAPI() servicesRegistry[AudioAPI::class] = RealNewPipeAudioAPI()
} }
MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN -> {
servicesRegistry.putAll(
createMusicbrainzListenbrainzPluginAPIs(
scope = scope,
httpClient = HttpClient(),
webViewController = webViewController,
persistedStorage = persistedStorage
)
)
}
LRCLIB_BUILT_IN_PLUGIN -> { LRCLIB_BUILT_IN_PLUGIN -> {
servicesRegistry[CoreAPI::class] = RealCoreAPI() servicesRegistry[CoreAPI::class] = RealCoreAPI()
servicesRegistry[LyricsAPI::class] = RealLRCLibLyricsAPI() servicesRegistry[LyricsAPI::class] = RealLRCLibLyricsAPI()

View File

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

View File

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

View File

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

View File

@ -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}",
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = "+")
}

View File

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

View File

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

View File

@ -29,20 +29,6 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
), ),
abilities = listOf(PluginAbility.AUDIO) 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( val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
name = "LRCLib Lyrics", name = "LRCLib Lyrics",
version = "0.1.0", version = "0.1.0",
@ -55,7 +41,6 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry(
abilities = listOf(PluginAbility.LYRICS) abilities = listOf(PluginAbility.LYRICS)
) )
val BUILT_IN_PLUGINS = listOf( val BUILT_IN_PLUGINS = listOf(
MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN,
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
LRCLIB_BUILT_IN_PLUGIN, LRCLIB_BUILT_IN_PLUGIN,
) )

View File

@ -103,8 +103,8 @@ class PluginManager(
.map { p -> .map { p ->
val json = p[DatabaseKeys.PLUGINS_STATE_KEY] val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
val defaultSelectedPlugins = mapOf( val defaultSelectedPlugins = mapOf(
PluginAbility.METADATA to MUSICBRAINZ_LISTENBRAINZ_BUILT_IN_PLUGIN,
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
PluginAbility.SCROBBLE to LRCLIB_BUILT_IN_PLUGIN,
) )
if (json == null) { if (json == null) {
PluginManagerStates.Data( PluginManagerStates.Data(

View File

@ -67,7 +67,6 @@ material3-window-size = "1.9.0"
kotlinx-datetime = "0.7.1" kotlinx-datetime = "0.7.1"
media3 = "1.8.0" media3 = "1.8.0"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
kmpgen = "1.3.0"
ksp = "2.3.5" ksp = "2.3.5"
spotube-gradle = "0.1.0" spotube-gradle = "0.1.0"
cryptography = "0.6.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-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", 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-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-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-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" } 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" } jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" } zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
kmpgen = { id = "com.kroegerama.openapi-kmp-gen", version.ref = "kmpgen" }
mavenPublish = { id = "maven-publish" } mavenPublish = { id = "maven-publish" }
spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" } spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" }
vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" } vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }