diff --git a/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt new file mode 100644 index 000000000..2495cb0c4 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt @@ -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 = context.dataStore.data + .map { prefs -> prefs[KEY_SESSION] } + + /** Emits the Last.fm username, or null when not logged in. */ + val usernameFlow: Flow = context.dataStore.data + .map { prefs -> prefs[KEY_USERNAME] } + + /** True when a valid session key is persisted. */ + val isLoggedInFlow: Flow = sessionKeyFlow.map { it != null } + + // Internal cache so DualPlayerEngine can read synchronously + private val _sessionKey = MutableStateFlow(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): Map { + 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" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt new file mode 100644 index 000000000..fbe565d97 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt @@ -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 + ): 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 + ): 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 + ): LastFmScrobbleResponse +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt new file mode 100644 index 000000000..d6261b4e8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt @@ -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, 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) } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt new file mode 100644 index 000000000..10aa8397a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt @@ -0,0 +1,82 @@ +package com.theveloper.pixelplay.data.network.lastfm + +import com.google.gson.annotations.SerializedName + +// --------------------------------------------------------------------------- +// auth.getMobileSession response +// Ref: https://www.last.fm/api/mobileauth +// --------------------------------------------------------------------------- +data class LastFmSessionResponse( + @SerializedName("session") val session: LastFmSession? +) + +data class LastFmSession( + @SerializedName("name") val name: String, + @SerializedName("key") val key: String, + @SerializedName("subscriber") val subscriber: Int +) + +// --------------------------------------------------------------------------- +// track.updateNowPlaying response +// Ref: https://www.last.fm/api/show/track.updateNowPlaying +// --------------------------------------------------------------------------- +data class LastFmNowPlayingResponse( + @SerializedName("nowplaying") val nowPlaying: LastFmNowPlayingResult? +) + +data class LastFmNowPlayingResult( + @SerializedName("artist") val artist: LastFmCorrectedValue?, + @SerializedName("track") val track: LastFmCorrectedValue?, + @SerializedName("album") val album: LastFmCorrectedValue?, + @SerializedName("albumartist") val albumArtist: LastFmCorrectedValue? +) + +// --------------------------------------------------------------------------- +// track.scrobble response +// Ref: https://www.last.fm/api/show/track.scrobble +// --------------------------------------------------------------------------- +data class LastFmScrobbleResponse( + @SerializedName("scrobbles") val scrobbles: LastFmScrobblesWrapper? +) + +data class LastFmScrobblesWrapper( + @SerializedName("scrobble") val scrobble: LastFmScrobbleResult?, + @SerializedName("@attr") val attr: LastFmScrobblesAttr? +) + +data class LastFmScrobbleResult( + @SerializedName("artist") val artist: LastFmCorrectedValue?, + @SerializedName("track") val track: LastFmCorrectedValue?, + @SerializedName("album") val album: LastFmCorrectedValue?, + @SerializedName("ignoredMessage") val ignoredMessage: LastFmIgnoredMessage? +) + +data class LastFmIgnoredMessage( + @SerializedName("code") val code: Int, + @SerializedName("#text") val text: String +) + +data class LastFmScrobblesAttr( + @SerializedName("accepted") val accepted: Int, + @SerializedName("ignored") val ignored: Int +) + +/** + * Shared wrapper for correctable string values returned by Last.fm + * (artist, track, album). The `corrected` field is "1" if Last.fm + * applied a correction, "0" otherwise. + */ +data class LastFmCorrectedValue( + @SerializedName("#text") val text: String, + @SerializedName("corrected") val corrected: String +) + +// --------------------------------------------------------------------------- +// Error envelope +// Last.fm returns HTTP 200 even on errors, with a JSON body: +// { "error": , "message": "" } +// --------------------------------------------------------------------------- +data class LastFmErrorResponse( + @SerializedName("error") val error: Int, + @SerializedName("message") val message: String +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86..5401c3318 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -1386,4 +1386,48 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { /** Increments [value] by 1, wrapping back to 0 on overflow. */ private fun incrementWrapped(value: Int?) = if (value == null || value == Int.MAX_VALUE) 0 else value + 1 + + // ---- Last.fm ------------------------------------------------------- + + private object LastFmKeys { + val SESSION_KEY = stringPreferencesKey("lastfm_session_key") + val USERNAME = stringPreferencesKey("lastfm_username") + val SCROBBLING_ENABLED = booleanPreferencesKey("lastfm_scrobbling_enabled") + } + + /** Returns the stored Last.fm session key, or null if not logged in. */ + val lastFmSessionKeyFlow: Flow = + dataStore.data.map { it[LastFmKeys.SESSION_KEY] } + + /** Returns the stored Last.fm username, or null if not logged in. */ + val lastFmUsernameFlow: Flow = + dataStore.data.map { it[LastFmKeys.USERNAME] } + + /** Returns whether Last.fm scrobbling is enabled (defaults true when logged in). */ + val lastFmScrobblingEnabledFlow: Flow = + dataStore.data.map { it[LastFmKeys.SCROBBLING_ENABLED] ?: true } + + /** Persists the Last.fm session key and username after a successful auth. */ + suspend fun setLastFmSession(sessionKey: String, username: String) { + dataStore.edit { + it[LastFmKeys.SESSION_KEY] = sessionKey + it[LastFmKeys.USERNAME] = username + it[LastFmKeys.SCROBBLING_ENABLED] = true + } + } + + /** Clears all Last.fm credentials (logout). */ + suspend fun clearLastFmSession() { + dataStore.edit { + it.remove(LastFmKeys.SESSION_KEY) + it.remove(LastFmKeys.USERNAME) + it.remove(LastFmKeys.SCROBBLING_ENABLED) + } + } + + /** Enables or disables Last.fm scrobbling without logging out. */ + suspend fun setLastFmScrobblingEnabled(enabled: Boolean) { + dataStore.edit { it[LastFmKeys.SCROBBLING_ENABLED] = enabled } + } + } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index adab461ca..271199f1b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -1426,8 +1426,9 @@ class MusicService : MediaLibraryService() { // Let the debounced updater handle it to prevent UI freezes. widgetUpdateManager.requestFullUpdate(false) mediaSession?.let { refreshMediaSessionUi(it) } - schedulePlaybackSnapshotPersist() - } + schedulePlaybackSnapshotPersist() + notifyLastFmNowPlaying(mediaItem) + } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { // Some devices/apps deliver title/artist/art after transition callback. @@ -2913,4 +2914,28 @@ class MusicService : MediaLibraryService() { } return future } + + // ---- Last.fm ------------------------------------------------------- + @Inject + lateinit var lastFmRepository: com.theveloper.pixelplay.data.lastfm.LastFmRepository + + /** + * Triggers Last.fm updateNowPlaying + schedules a scrobble for [mediaItem]. + * Called from [playerListener.onMediaItemTransition]. + */ + private fun notifyLastFmNowPlaying(mediaItem: MediaItem?) { + mediaItem ?: return + val track = mediaItem.mediaMetadata.title?.toString()?.takeIf { it.isNotBlank() } ?: return + val artist = mediaItem.mediaMetadata.artist?.toString()?.takeIf { it.isNotBlank() } ?: return + val album = mediaItem.mediaMetadata.albumTitle?.toString() + val durationMs = mediaItem.mediaMetadata.extras + ?.getLong("duration", 0L) ?: 0L + engine.onLastFmTrackStarted( + track = track, + artist = artist, + album = album, + durationMs = durationMs + ) + } + } diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 7f1d93599..643d64ab2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -586,4 +586,51 @@ object AppModule { ): ArtistImageRepository { return ArtistImageRepository(deezerApiService, musicDao) } + + /** + * Qualifier for the Last.fm Retrofit instance. + */ + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class LastFmRetrofit + + /** + * Provides a Retrofit instance configured for the Last.fm API. + * Base URL: https://ws.audioscrobbler.com/ + */ + @Provides + @Singleton + @LastFmRetrofit + fun provideLastFmRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://ws.audioscrobbler.com/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + /** + * Provides the Last.fm API service. + */ + @Provides + @Singleton + fun provideLastFmApiService(@LastFmRetrofit retrofit: Retrofit): com.theveloper.pixelplay.data.network.lastfm.LastFmApiService { + return retrofit.create(com.theveloper.pixelplay.data.network.lastfm.LastFmApiService::class.java) + } + + /** + * Provides the Last.fm repository for scrobbling and now-playing. + */ + @Provides + @Singleton + fun provideLastFmRepository( + lastFmApiService: com.theveloper.pixelplay.data.network.lastfm.LastFmApiService, + userPreferencesRepository: UserPreferencesRepository + ): com.theveloper.pixelplay.data.lastfm.LastFmRepository { + return com.theveloper.pixelplay.data.lastfm.LastFmRepositoryImpl( + lastFmApiService = lastFmApiService, + userPreferencesRepository = userPreferencesRepository + ) + } + } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt index 354d92987..c56ea1e6a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt @@ -9,6 +9,7 @@ import com.theveloper.pixelplay.data.netease.NeteaseRepository import com.theveloper.pixelplay.data.qqmusic.QqMusicRepository import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.telegram.TelegramRepository +import com.theveloper.pixelplay.data.lastfm.LastFmRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -28,7 +29,8 @@ enum class ExternalServiceAccount { NETEASE, QQ_MUSIC, NAVIDROME, - JELLYFIN + JELLYFIN, + LAST_FM } data class ExternalAccountUiModel( @@ -52,7 +54,8 @@ class AccountsViewModel @Inject constructor( private val neteaseRepository: NeteaseRepository, private val qqMusicRepository: QqMusicRepository, private val navidromeRepository: NavidromeRepository, - private val jellyfinRepository: JellyfinRepository + private val jellyfinRepository: JellyfinRepository, + private val lastFmRepository: LastFmRepository ) : ViewModel() { private val loggingOutServices = MutableStateFlow>(emptySet()) @@ -259,6 +262,7 @@ class AccountsViewModel @Inject constructor( ExternalServiceAccount.QQ_MUSIC -> qqMusicRepository.logout() ExternalServiceAccount.NAVIDROME -> navidromeRepository.logout() ExternalServiceAccount.JELLYFIN -> jellyfinRepository.logout() + ExternalServiceAccount.LAST_FM -> lastFmRepository.logout() } } } finally { @@ -274,4 +278,32 @@ class AccountsViewModel @Inject constructor( "$count $plural" } } + + // ---- Last.fm ------------------------------------------------------- + + /** One-shot UI state for Last.fm auth: null = idle, true = success, false = failed. */ + private val _lastFmLoginResult = MutableStateFlow?>(null) + val lastFmLoginResult: StateFlow?> = _lastFmLoginResult + + /** Last.fm username from persisted preferences, or null if not logged in. */ + val lastFmUsername: StateFlow = lastFmRepository.usernameFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** + * Authenticates with Last.fm using the Mobile Auth flow (username + password → session key). + * https://www.last.fm/api/mobileauth + */ + fun loginLastFm(username: String, password: String) { + viewModelScope.launch { + _lastFmLoginResult.value = null + val result = runCatching { lastFmRepository.login(username, password) } + _lastFmLoginResult.value = result + } + } + + /** Clears the one-shot login result after the UI has consumed it. */ + fun consumeLastFmLoginResult() { + _lastFmLoginResult.value = null + } + }