feat(lastfm): add Last.fm module gated by the full build

This commit is contained in:
maxrave-dev 2026-07-30 22:53:57 +07:00
parent 90ec899965
commit 8191438612
No known key found for this signature in database
14 changed files with 632 additions and 16 deletions

2
.gitignore vendored
View file

@ -33,6 +33,8 @@ sentry.properties
/.claude/settings.local.json
/androidApp/build/
/androidApp/cache/
/lastfm/build/
/lastfm-empty/build/
# libmpv native libraries (downloaded by `./gradlew :composeApp:mpvSetupAll`)
/mpv-natives/
/.omc/

View file

@ -159,6 +159,19 @@
<data android:scheme="simpmusic" />
</intent-filter>
<!-- Last.fm auth callback. The scheme is fixed by the callback URL registered on the
Last.fm API account, so it is not "simpmusic". -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="lastfm-auth"
android:scheme="wordbyword" />
</intent-filter>
<!-- Share filter -->
<intent-filter>
<action android:name="android.intent.action.SEND" />

View file

@ -35,6 +35,7 @@ import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.core.logger.Level
import org.simpmusic.crashlytics.configCrashlytics
import org.simpmusic.lastfm.configLastfm
import java.lang.reflect.Field
class SimpMusicApplication :
@ -50,6 +51,7 @@ class SimpMusicApplication :
super.onCreate()
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
configCrashlytics(this, BuildKonfig.sentryDsn)
configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret)
startKoin {
androidLogger(level = Level.DEBUG)
androidContext(this@SimpMusicApplication)

View file

@ -122,6 +122,15 @@ kotlin {
api(projects.domain)
implementation(projects.data)
// Last.fm (gated: real scrobbler for full builds, no-op stub for FOSS builds).
// `api` rather than `implementation` so :androidApp can hand it the credentials from
// BuildKonfig at startup, the same way it does for Sentry.
if (isFullBuild) {
api(projects.lastfm)
} else {
api(projects.lastfmEmpty)
}
// Navigation Compose
implementation(libs.navigation.compose)
@ -805,12 +814,28 @@ buildkonfig {
"sentryDsn",
properties.getProperty("SENTRY_DSN") ?: "",
)
buildConfigField(
STRING,
"lastfmApiKey",
properties.getProperty("LASTFM_API_KEY") ?: "",
)
buildConfigField(
STRING,
"lastfmSecret",
properties.getProperty("LASTFM_SECRET") ?: "",
)
} catch (e: Exception) {
println("Failed to load SENTRY_DSN from local.properties: ${e.message}")
println("Failed to load secrets from local.properties: ${e.message}")
buildConfigField(STRING, "sentryDsn", "")
buildConfigField(STRING, "lastfmApiKey", "")
buildConfigField(STRING, "lastfmSecret", "")
}
} else {
buildConfigField(STRING, "sentryDsn", "")
// A FOSS build ships no Last.fm credentials, so `isLastfmAvailable()` stays false and
// the feature hides itself. The stub module is linked in this flavour anyway.
buildConfigField(STRING, "lastfmApiKey", "")
buildConfigField(STRING, "lastfmSecret", "")
}
}
}

View file

@ -53,6 +53,7 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.simpmusic.lastfm.configLastfm
import org.koin.java.KoinJavaComponent.inject
import org.koin.mp.KoinPlatform.getKoin
import simpmusic.composeapp.generated.resources.Res
@ -131,6 +132,7 @@ fun runDesktopApp(args: Array<String> = emptyArray()) {
changeLanguageNative(language)
VersionManager.initialize()
configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret)
if (BuildKonfig.sentryDsn.isNotEmpty()) {
Sentry.init { options ->
options.dsn = BuildKonfig.sentryDsn

View file

@ -133,6 +133,11 @@ object DesktopDeepLinkHandler {
Uri.parse(convertedUrl)
}
// wordbyword://lastfm-auth?token=xxx → pass through untouched. It must NOT be rewritten
// to simpmusic.org like the branch above does: App.kt matches on this exact scheme to
// read the Last.fm request token.
parsed.scheme == "wordbyword" -> parsed
// https://simpmusic.org/app/... or YouTube URLs → pass through
else -> parsed
}

View file

@ -20,7 +20,14 @@ import com.maxrave.logger.Logger
object WindowsProtocolRegistrar {
private const val TAG = "WindowsProtocolRegistrar"
private const val SCHEME = "simpmusic"
private const val REG_KEY = "HKCU\\Software\\Classes\\$SCHEME"
/**
* The Last.fm auth callback. Its scheme is fixed by the callback URL registered on the Last.fm
* API account, so it cannot be folded into "simpmusic".
*/
private const val LASTFM_SCHEME = "wordbyword"
private fun regKeyOf(scheme: String) = "HKCU\\Software\\Classes\\$scheme"
fun register() {
if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) return
@ -30,33 +37,46 @@ object WindowsProtocolRegistrar {
return
}
register(SCHEME, "URL:SimpMusic Protocol", exePath)
register(LASTFM_SCHEME, "URL:SimpMusic Last.fm Callback", exePath)
}
private fun register(
scheme: String,
description: String,
exePath: String,
) {
val regKey = regKeyOf(scheme)
try {
if (isAlreadyRegistered(exePath)) {
Logger.d(TAG, "Protocol handler already registered with correct path")
if (isAlreadyRegistered(scheme, exePath)) {
Logger.d(TAG, "$scheme:// already registered with correct path")
return
}
Logger.d(TAG, "Registering simpmusic:// protocol handler -> $exePath")
Logger.d(TAG, "Registering $scheme:// protocol handler -> $exePath")
// Main key with protocol description
regAdd(REG_KEY, null, "URL:SimpMusic Protocol")
regAdd(REG_KEY, "URL Protocol", "")
regAdd(regKey, null, description)
regAdd(regKey, "URL Protocol", "")
// DefaultIcon
regAdd("$REG_KEY\\DefaultIcon", null, "\"$exePath\",0")
regAdd("$regKey\\DefaultIcon", null, "\"$exePath\",0")
// shell\open\command
regAdd("$REG_KEY\\shell\\open\\command", null, "\"$exePath\" \"%1\"")
regAdd("$regKey\\shell\\open\\command", null, "\"$exePath\" \"%1\"")
Logger.d(TAG, "Protocol handler registered successfully")
Logger.d(TAG, "$scheme:// registered successfully")
} catch (e: Exception) {
Logger.e(TAG, "Failed to register protocol handler: ${e.message}")
Logger.e(TAG, "Failed to register $scheme:// handler: ${e.message}")
}
}
private fun isAlreadyRegistered(currentExePath: String): Boolean {
private fun isAlreadyRegistered(
scheme: String,
currentExePath: String,
): Boolean {
return try {
val result = regQuery("$REG_KEY\\shell\\open\\command", null)
val result = regQuery("${regKeyOf(scheme)}\\shell\\open\\command", null)
// Registry stores path with quotes: "C:\path\to\SimpMusic.exe" "%1"
// Normalize both for comparison
val normalizedExe = currentExePath.replace("\\", "/").lowercase()

View file

@ -120,7 +120,7 @@ app {
mac {
info-plist.LSApplicationCategoryType = "public.app-category.music"
info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ]
url-schemes = [ "simpmusic" ]
url-schemes = [ "simpmusic", "wordbyword" ]
// Turn off Apple's "nano" malloc zone — the macOS counterpart of the
// MALLOC_ARENA_MAX cap the AppImage's AppRun sets for glibc.
@ -153,7 +153,7 @@ app {
windows {
start-menu.group = "SimpMusic"
url-schemes = [ "simpmusic" ]
url-schemes = [ "simpmusic", "wordbyword" ]
// DEBUG ONLY — remove before shipping a release.
// A Windows GUI binary has no console attached, so everything the app writes to
@ -169,7 +169,7 @@ app {
// desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke
// once AWT started initializing on a coroutine worker thread).
desktop-file.StartupWMClass = "SimpMusic"
url-schemes = [ "simpmusic" ]
url-schemes = [ "simpmusic", "wordbyword" ]
}
// --- Build settings -------------------------------------------------------

View file

@ -0,0 +1,44 @@
import com.android.build.gradle.internal.tasks.CompileArtProfileTask
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.android.lint)
}
kotlin {
android {
namespace = "org.simpmusic.lastfm"
compileSdk = 37
minSdk = 26
}
val xcfName = "lastfmKit"
iosArm64 {
binaries.framework {
baseName = xcfName
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = xcfName
}
}
jvm()
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlin.stdlib)
// Logger only — no Ktor, no okio, no API key. A FOSS build carries no Last.fm code.
implementation(projects.common)
}
}
}
}
tasks.withType<CompileArtProfileTask> {
enabled = false
}

View file

@ -0,0 +1,80 @@
package org.simpmusic.lastfm
import com.maxrave.logger.Logger
// NON-LASTFM build: direct scrobbling is not available in this build flavour, because a FOSS build
// ships no API secret. Every declaration mirrors the real module so callers never need to branch on
// build flavour — `isLastfmAvailable()` returning false is what hides the feature in the UI.
data class LastfmSession(
val username: String,
val sessionKey: String,
)
data class LastfmTrack(
val artist: String,
val track: String,
val album: String? = null,
val albumArtist: String? = null,
val durationSeconds: Int? = null,
)
sealed interface LastfmOutcome {
data object Ok : LastfmOutcome
data class Ignored(
val code: Int,
val message: String,
) : LastfmOutcome
data class Error(
val code: Int,
val message: String,
) : LastfmOutcome {
val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION
val retryable: Boolean get() = code == 11 || code == 16 || code == 29
}
companion object {
const val ERROR_INVALID_SESSION = 9
}
}
private const val TAG = "Lastfm"
private const val UNAVAILABLE = "NON-LASTFM build: Last.fm is not available"
fun configLastfm(
key: String,
secret: String,
) {
Logger.d(TAG, UNAVAILABLE)
}
fun isLastfmAvailable(): Boolean = false
fun authorizeUrl(): String? = null
suspend fun completeLogin(token: String): LastfmSession? {
Logger.d(TAG, UNAVAILABLE)
return null
}
suspend fun updateNowPlaying(
sessionKey: String,
track: LastfmTrack,
): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE)
suspend fun scrobble(
sessionKey: String,
track: LastfmTrack,
startedAtEpochSeconds: Long,
): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE)
suspend fun setLoved(
sessionKey: String,
artist: String,
track: String,
loved: Boolean,
): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE)

52
lastfm/build.gradle.kts Normal file
View file

@ -0,0 +1,52 @@
import com.android.build.gradle.internal.tasks.CompileArtProfileTask
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.android.lint)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
android {
namespace = "org.simpmusic.lastfm"
compileSdk = 37
minSdk = 26
}
val xcfName = "lastfmKit"
iosArm64 {
binaries.framework {
baseName = xcfName
}
}
iosSimulatorArm64 {
binaries.framework {
baseName = xcfName
}
}
jvm()
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlin.stdlib)
implementation(projects.common)
implementation(projects.ktorExt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)
// MD5 for api_sig. okio hashes on every target this module builds for,
// so no expect/actual is needed just to sign a request.
implementation(libs.okio)
}
}
}
}
tasks.withType<CompileArtProfileTask> {
enabled = false
}

View file

@ -0,0 +1,147 @@
package org.simpmusic.lastfm
import com.maxrave.logger.Logger
// LAST.FM build: the real scrobbler. `lastfm-empty` mirrors every declaration in this file as a
// no-op, so callers never branch on the build flavour — see `cast` / `cast-empty` for the same
// arrangement.
/** A logged-in Last.fm account. The session key has no expiry; the user revokes it from last.fm. */
data class LastfmSession(
val username: String,
val sessionKey: String,
)
/** What SimpMusic knows about a track, in the shape Last.fm's parameters expect. */
data class LastfmTrack(
val artist: String,
val track: String,
val album: String? = null,
val albumArtist: String? = null,
val durationSeconds: Int? = null,
)
/**
* The outcome of a write call.
*
* [Ignored] is the case worth spelling out: Last.fm answers `status="ok"` even when it threw the
* scrobble away, and only says so in `ignoredMessage`. Treating a 200 as success would hide bad
* metadata forever — codes 1 and 2 mean the artist or track name failed Last.fm's filter, which is
* exactly what a polluted artist like "13M plays" produces.
*/
sealed interface LastfmOutcome {
data object Ok : LastfmOutcome
data class Ignored(
val code: Int,
val message: String,
) : LastfmOutcome
data class Error(
val code: Int,
val message: String,
) : LastfmOutcome {
/** Code 9 means the stored session key is dead — the user has to log in again. */
val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION
/** 11 offline, 16 temporary, 29 rate limited. Every other code is a malformed request. */
val retryable: Boolean get() = code == 11 || code == 16 || code == 29
}
companion object {
const val ERROR_INVALID_SESSION = 9
}
}
private const val TAG = "Lastfm"
private var apiKey: String = ""
private var sharedSecret: String = ""
/**
* Hands the module its credentials.
*
* They arrive from the caller rather than living here because BuildKonfig generates into
* `composeApp`, which depends on this module and not the other way round — the same reason
* `configCrashlytics` takes the Sentry DSN as a parameter.
*/
fun configLastfm(
key: String,
secret: String,
) {
apiKey = key
sharedSecret = secret
Logger.d(TAG, "Last.fm configured: ${if (isLastfmAvailable()) "credentials present" else "no credentials"}")
}
/**
* Whether this build can talk to Last.fm at all.
*
* False in a FOSS build, where the empty module answers for this function, and also false in a full
* build whose `local.properties` carries no key — so the settings entry hides itself rather than
* offering a login that could never succeed.
*/
fun isLastfmAvailable(): Boolean = apiKey.isNotEmpty() && sharedSecret.isNotEmpty()
/**
* Step one: where to send the user so they can grant access.
*
* This is Last.fm's **web** flow, and the missing `token` parameter is the whole point of it.
* Last.fm generates the token itself and redirects to the callback registered on the API account
* with `?token=` appended. The desktop flow — call `auth.getToken` first, then open this URL with
* `&token=` already on it — tells Last.fm the app is holding the token, so it renders a "return to
* the application" page and never calls the callback at all.
*
* No `cb` parameter is passed either: the callback on the API account is the one to use.
*/
fun authorizeUrl(): String? {
if (!isLastfmAvailable()) return null
return "https://www.last.fm/api/auth/?api_key=$apiKey"
}
/**
* Step two: trade the token that came back on the callback for a session key.
*
* A token is user- and account-specific, lives 60 minutes, and is consumed by this call — so a
* failure here means sending the user through [authorizeUrl] again, not retrying the same token.
*/
suspend fun completeLogin(token: String): LastfmSession? {
if (!isLastfmAvailable()) return null
return LastfmClient.getSession(apiKey, sharedSecret, token)
}
/** Tells Last.fm what is playing right now. Does not affect charts, and is not worth retrying. */
suspend fun updateNowPlaying(
sessionKey: String,
track: LastfmTrack,
): LastfmOutcome {
if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured")
return LastfmClient.updateNowPlaying(apiKey, sharedSecret, sessionKey, track)
}
/**
* Records a play.
*
* [startedAtEpochSeconds] is when playback STARTED. Last.fm's own docs disagree with themselves
* here — the method page says the track started, the scrobbling guide says it finished — and every
* scrobbler in the wild sends the start time, so that is what this takes.
*/
suspend fun scrobble(
sessionKey: String,
track: LastfmTrack,
startedAtEpochSeconds: Long,
): LastfmOutcome {
if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured")
return LastfmClient.scrobble(apiKey, sharedSecret, sessionKey, track, startedAtEpochSeconds)
}
/** Mirrors a like onto Last.fm's loved tracks. */
suspend fun setLoved(
sessionKey: String,
artist: String,
track: String,
loved: Boolean,
): LastfmOutcome {
if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured")
return LastfmClient.setLoved(apiKey, sharedSecret, sessionKey, artist, track, loved)
}

View file

@ -0,0 +1,222 @@
package org.simpmusic.lastfm
import com.maxrave.ktorext.getEngine
import com.maxrave.logger.Logger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Parameters
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okio.ByteString.Companion.encodeUtf8
private const val TAG = "LastfmClient"
private const val ENDPOINT = "https://ws.audioscrobbler.com/2.0/"
/**
* Every call Last.fm needs, signed and parsed.
*
* Responses are read as loose [JsonObject]s instead of `@Serializable` classes on purpose. Last.fm's
* JSON is a mechanical translation of its XML: numbers arrive as strings, attributes hide under
* `@attr`, and a field is an object with one element but an array with several. A schema that
* mirrors all of that breaks the moment a response has two entries instead of one, and there is
* nothing here worth reading past a handful of fields.
*/
internal object LastfmClient {
private val json = Json { ignoreUnknownKeys = true }
private val client =
HttpClient(getEngine()) {
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = 15_000
socketTimeoutMillis = 15_000
}
}
/**
* Builds `api_sig`.
*
* Parameters are ordered by name, concatenated as `<name><value>`, the shared secret is appended
* and the whole thing is MD5'd. `format` is excluded because the spec says so — signing it is
* the classic reason every request comes back "Invalid method signature supplied" (code 13).
*/
private fun sign(
params: Map<String, String>,
secret: String,
): String =
params
.filterKeys { it != "format" && it != "callback" }
.entries
// sortedBy, not toSortedMap: the latter is a JDK collection and does not exist in
// common Kotlin. Parameter names are ASCII, so String ordering is the alphabetical
// ordering the spec asks for.
.sortedBy { it.key }
.joinToString(separator = "") { "${it.key}${it.value}" }
.plus(secret)
.encodeUtf8()
.md5()
.hex()
/**
* Signs [params], posts them as a form and hands back the parsed body.
*
* Everything goes over POST, including the two auth calls: the per-method pages call them GET
* but the authentication spec calls them POST, and POST is accepted for both.
*/
private suspend fun call(
params: Map<String, String>,
secret: String,
): JsonObject? =
runCatching {
val signed = params + ("api_sig" to sign(params, secret)) + ("format" to "json")
val body =
client
.submitForm(
url = ENDPOINT,
formParameters =
Parameters.build {
signed.forEach { (key, value) -> append(key, value) }
},
).bodyAsText()
json.parseToJsonElement(body).jsonObject
}.onFailure {
Logger.e(TAG, "Request failed: ${it.message}")
}.getOrNull()
/** Reads a field that Last.fm may send as either a JSON string or a JSON number. */
private fun JsonObject.intOrNull(key: String): Int? = this[key]?.jsonPrimitive?.content?.toIntOrNull()
private fun JsonObject.stringOrNull(key: String): String? = this[key]?.jsonPrimitive?.content
/** Turns an `{"error": N, "message": "..."}` body into an outcome, or null when the call worked. */
private fun JsonObject.asErrorOrNull(): LastfmOutcome.Error? {
val code = intOrNull("error") ?: return null
return LastfmOutcome.Error(code, stringOrNull("message") ?: "Unknown Last.fm error")
}
/**
* Reads the `ignoredMessage` that rides along with an otherwise successful write.
*
* Code 0 means nothing was ignored. 1 and 2 mean the artist or track name failed Last.fm's
* filter, 3 and 4 mean the timestamp was too far in the past or future, 5 means the daily
* scrobble limit is spent.
*/
private fun JsonObject.ignoredOrNull(): LastfmOutcome.Ignored? {
val ignored = this["ignoredMessage"]?.jsonObject ?: return null
val code = ignored.intOrNull("code") ?: return null
if (code == 0) return null
return LastfmOutcome.Ignored(code, ignored.stringOrNull("#text").orEmpty())
}
suspend fun getSession(
apiKey: String,
secret: String,
token: String,
): LastfmSession? {
val response =
call(
mapOf("method" to "auth.getSession", "api_key" to apiKey, "token" to token),
secret,
) ?: return null
response.asErrorOrNull()?.let {
Logger.e(TAG, "auth.getSession failed: ${it.code} ${it.message}")
return null
}
val session = response["session"]?.jsonObject ?: return null
val name = session.stringOrNull("name") ?: return null
val key = session.stringOrNull("key") ?: return null
return LastfmSession(username = name, sessionKey = key)
}
suspend fun updateNowPlaying(
apiKey: String,
secret: String,
sessionKey: String,
track: LastfmTrack,
): LastfmOutcome {
val params =
buildMap {
put("method", "track.updateNowPlaying")
put("api_key", apiKey)
put("sk", sessionKey)
put("artist", track.artist)
put("track", track.track)
track.album?.takeIf { it.isNotBlank() }?.let { put("album", it) }
track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist", it) }
track.durationSeconds?.takeIf { it > 0 }?.let { put("duration", it.toString()) }
}
val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error")
response.asErrorOrNull()?.let { return it }
val nowPlaying = response["nowplaying"]?.jsonObject ?: return LastfmOutcome.Ok
return nowPlaying.ignoredOrNull() ?: LastfmOutcome.Ok
}
/**
* Sends one play.
*
* The parameters are indexed (`artist[0]`) even though a single scrobble may be sent with plain
* names. Indexing one entry is the same shape a batch uses, so growing this into an offline
* queue later — Last.fm takes up to 50 per request — costs no rewrite.
*
* `duration` is always sent: the method page lists it as optional but the scrobbling guide lists
* it as required, and sending it satisfies both.
*/
suspend fun scrobble(
apiKey: String,
secret: String,
sessionKey: String,
track: LastfmTrack,
startedAtEpochSeconds: Long,
): LastfmOutcome {
val params =
buildMap {
put("method", "track.scrobble")
put("api_key", apiKey)
put("sk", sessionKey)
put("artist[0]", track.artist)
put("track[0]", track.track)
put("timestamp[0]", startedAtEpochSeconds.toString())
track.album?.takeIf { it.isNotBlank() }?.let { put("album[0]", it) }
track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist[0]", it) }
track.durationSeconds?.takeIf { it > 0 }?.let { put("duration[0]", it.toString()) }
put("chosenByUser[0]", "1")
}
val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error")
response.asErrorOrNull()?.let { return it }
val scrobbles = response["scrobbles"]?.jsonObject ?: return LastfmOutcome.Ok
// One scrobble comes back as an object, several as an array — read whichever arrived.
val first =
when (val entry = scrobbles["scrobble"]) {
is JsonArray -> entry.firstOrNull()?.jsonObject
is JsonObject -> entry
else -> null
}
return first?.ignoredOrNull() ?: LastfmOutcome.Ok
}
suspend fun setLoved(
apiKey: String,
secret: String,
sessionKey: String,
artist: String,
track: String,
loved: Boolean,
): LastfmOutcome {
val params =
mapOf(
"method" to if (loved) "track.love" else "track.unlove",
"api_key" to apiKey,
"sk" to sessionKey,
"artist" to artist,
"track" to track,
)
val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error")
return response.asErrorOrNull() ?: LastfmOutcome.Ok
}
}

View file

@ -60,6 +60,8 @@ include(
":crashlytics-empty",
":cast",
":cast-empty",
":lastfm",
":lastfm-empty",
":kizzy",
)