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