Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package com.theveloper.pixelplay.data.lastfm

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.theveloper.pixelplay.BuildConfig
import com.theveloper.pixelplay.data.network.lastfm.LastFmApiService
import com.theveloper.pixelplay.data.network.lastfm.buildLastFmSignature
import com.theveloper.pixelplay.data.preferences.dataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

/**
* Manages Last.fm authentication state, session key storage,
* and the two active scrobbling operations:
* - updateNowPlaying : called when a track starts
* - scrobble : called once the track qualifies (>30 s played
* AND >= half duration or 4 minutes)
*
* Auth uses the mobile auth flow (auth.getMobileSession) which accepts
* a username + password directly over HTTPS — no browser redirect needed.
*
* Ref: https://www.last.fm/api/mobileauth
*/
@Singleton
class LastFmRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: LastFmApiService
) {

// -----------------------------------------------------------------------
// DataStore keys
// -----------------------------------------------------------------------
private val KEY_SESSION = stringPreferencesKey("lastfm_session_key")
private val KEY_USERNAME = stringPreferencesKey("lastfm_username")

// -----------------------------------------------------------------------
// Public state
// -----------------------------------------------------------------------

/** Emits the stored session key, or null when not logged in. */
val sessionKeyFlow: Flow<String?> = context.dataStore.data
.map { prefs -> prefs[KEY_SESSION] }

/** Emits the Last.fm username, or null when not logged in. */
val usernameFlow: Flow<String?> = context.dataStore.data
.map { prefs -> prefs[KEY_USERNAME] }

/** True when a valid session key is persisted. */
val isLoggedInFlow: Flow<Boolean> = sessionKeyFlow.map { it != null }

// Internal cache so DualPlayerEngine can read synchronously
private val _sessionKey = MutableStateFlow<String?>(null)
val sessionKey = _sessionKey.asStateFlow()

// -----------------------------------------------------------------------
// Auth
// -----------------------------------------------------------------------

/**
* Authenticate with Last.fm using username + password.
* On success, persists the session key and username to DataStore.
*
* @return true on success, false on failure.
*/
suspend fun login(username: String, password: String): Boolean {
return try {
val params = buildParams(
"method" to "auth.getMobileSession",
"username" to username,
"password" to password
)
val response = apiService.getMobileSession(params)
val key = response.session?.key
if (key != null) {
context.dataStore.edit { prefs ->
prefs[KEY_SESSION] = key
prefs[KEY_USERNAME] = username
}
_sessionKey.value = key
Timber.tag(TAG).i("Last.fm login successful for $username")
true
} else {
Timber.tag(TAG).w("Last.fm login returned null session")
false
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Last.fm login failed")
false
}
}

/** Clear the stored session and mark the user as logged out. */
suspend fun logout() {
context.dataStore.edit { prefs ->
prefs.remove(KEY_SESSION)
prefs.remove(KEY_USERNAME)
}
_sessionKey.value = null
Timber.tag(TAG).i("Last.fm session cleared")
}

/** Warm the in-memory cache from DataStore on app start. */
suspend fun warmSessionCache(sk: String?) {
_sessionKey.value = sk
}

// -----------------------------------------------------------------------
// Scrobbling
// -----------------------------------------------------------------------

/**
* Notify Last.fm that the user has started playing a track.
* Should be called immediately when a track begins.
*
* Ref: https://www.last.fm/api/show/track.updateNowPlaying
*/
suspend fun updateNowPlaying(
artist: String,
track: String,
album: String? = null,
duration: Long? = null
) {
val sk = _sessionKey.value ?: return
try {
val extra = mutableMapOf(
"method" to "track.updateNowPlaying",
"artist" to artist,
"track" to track,
"sk" to sk
)
album?.let { extra["album"] = it }
duration?.let { extra["duration"] = it.toString() }
val params = buildParams(*extra.entries.map { it.key to it.value }.toTypedArray())
apiService.updateNowPlaying(params)
Timber.tag(TAG).d("Now playing: $artist - $track")
} catch (e: Exception) {
Timber.tag(TAG).w(e, "updateNowPlaying failed")
}
}

/**
* Scrobble a track to Last.fm.
*
* Call this once the track qualifies per the Scrobbling 2.0 spec:
* - Track duration > 30 seconds.
* - Track has been played for >= half its duration, or >= 4 minutes.
*
* @param startTimestamp UNIX UTC timestamp (seconds) when playback started.
*
* Ref: https://www.last.fm/api/show/track.scrobble
* https://www.last.fm/api/scrobbling
*/
suspend fun scrobble(
artist: String,
track: String,
startTimestamp: Long,
album: String? = null,
duration: Long? = null
) {
val sk = _sessionKey.value ?: return
try {
val extra = mutableMapOf(
"method" to "track.scrobble",
"artist[0]" to artist,
"track[0]" to track,
"timestamp[0]" to startTimestamp.toString(),
"sk" to sk
)
album?.let { extra["album[0]"] = it }
duration?.let { extra["duration[0]"] = it.toString() }
val params = buildParams(*extra.entries.map { it.key to it.value }.toTypedArray())
val response = apiService.scrobble(params)
val accepted = response.scrobbles?.attr?.accepted ?: 0
val ignored = response.scrobbles?.attr?.ignored ?: 0
Timber.tag(TAG).d("Scrobbled $artist - $track (accepted=$accepted, ignored=$ignored)")
} catch (e: Exception) {
Timber.tag(TAG).w(e, "Scrobble failed")
}
}

// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------

/**
* Build the full parameter map for a Last.fm API call:
* - Adds api_key and format automatically.
* - Computes and appends api_sig per the auth spec.
*/
private fun buildParams(vararg pairs: Pair<String, String>): Map<String, String> {
val map = mutableMapOf(*pairs)
map["api_key"] = BuildConfig.LASTFM_API_KEY
map["format"] = "json"
// api_sig is computed BEFORE adding format (format is excluded per spec)
map["api_sig"] = buildLastFmSignature(map, BuildConfig.LASTFM_SHARED_SECRET)
return map
}

companion object {
private const val TAG = "LastFmRepository"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.theveloper.pixelplay.data.network.lastfm

import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST

/**
* Retrofit interface for the Last.fm API.
*
* All write-capable methods (auth, scrobble, nowPlaying) use POST over HTTPS
* as required by Last.fm documentation.
*
* Base URL: https://ws.audioscrobbler.com/2.0/
*
* Each call includes:
* - method : the Last.fm API method name
* - api_key : application key (injected via BuildConfig)
* - api_sig : HMAC-style MD5 signature (see LastFmAuthHelper)
* - format : "json" (so responses are JSON, not XML)
*
* Refs:
* https://www.last.fm/api/mobileauth
* https://www.last.fm/api/show/track.updateNowPlaying
* https://www.last.fm/api/show/track.scrobble
*/
interface LastFmApiService {

/**
* Authenticate a user via username + password (mobile auth flow).
* Returns a session key to be stored and reused for all subsequent calls.
*
* Required fields: method, username, password, api_key, api_sig
* The `method` value MUST be "auth.getMobileSession".
*/
@FormUrlEncoded
@POST(".")
suspend fun getMobileSession(
@FieldMap fields: Map<String, String>
): LastFmSessionResponse

/**
* Notify Last.fm that the user has started listening to a track.
* Should be called at the start of playback.
*
* Required fields: method, artist, track, api_key, api_sig, sk
* Optional: album, trackNumber, duration, albumArtist
* The `method` value MUST be "track.updateNowPlaying".
*/
@FormUrlEncoded
@POST(".")
suspend fun updateNowPlaying(
@FieldMap fields: Map<String, String>
): LastFmNowPlayingResponse

/**
* Scrobble a single track (record a play in the user's history).
*
* Per Scrobbling 2.0 spec:
* - Track must be longer than 30 seconds.
* - Track must have been played for at least half its duration OR 4 minutes.
*
* Required fields: method, artist[0], track[0], timestamp[0], api_key, api_sig, sk
* Optional: album[0], trackNumber[0], duration[0], albumArtist[0]
* The `method` value MUST be "track.scrobble".
*/
@FormUrlEncoded
@POST(".")
suspend fun scrobble(
@FieldMap fields: Map<String, String>
): LastFmScrobbleResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.theveloper.pixelplay.data.network.lastfm

import java.security.MessageDigest

/**
* Builds the api_sig parameter required by all Last.fm write calls.
*
* Algorithm per https://www.last.fm/api/authspec (Section 8):
* 1. Collect all call parameters EXCEPT `format` and `callback`.
* 2. Sort them alphabetically by key.
* 3. Concatenate as "key1value1key2value2...".
* 4. Append the shared secret.
* 5. MD5-hash the resulting UTF-8 string.
*
* @param params The full set of request parameters (excluding format/callback).
* @param secret The application shared secret from last.fm/api/accounts.
* @return 32-character lowercase hex MD5 digest.
*/
fun buildLastFmSignature(params: Map<String, String>, secret: String): String {
val sorted = params
.filterKeys { it != "format" && it != "callback" }
.entries
.sortedBy { it.key }

val sigString = buildString {
for ((k, v) in sorted) {
append(k)
append(v)
}
append(secret)
}

val md = MessageDigest.getInstance("MD5")
val bytes = md.digest(sigString.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
Loading