feat(lastfm): add Last.fm module gated by the full build
This commit is contained in:
parent
90ec899965
commit
8191438612
14 changed files with 632 additions and 16 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 -------------------------------------------------------
|
||||
|
|
|
|||
44
lastfm-empty/build.gradle.kts
Normal file
44
lastfm-empty/build.gradle.kts
Normal 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
|
||||
}
|
||||
|
|
@ -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
52
lastfm/build.gradle.kts
Normal 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
|
||||
}
|
||||
147
lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt
Normal file
147
lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,8 @@ include(
|
|||
":crashlytics-empty",
|
||||
":cast",
|
||||
":cast-empty",
|
||||
":lastfm",
|
||||
":lastfm-empty",
|
||||
":kizzy",
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue