From d0281d768875b94f3042161ceaf9bc6d885e0430 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Wed, 29 Jul 2026 20:24:48 +0300 Subject: [PATCH 1/3] Add opt-in ListenBrainz scrobbling --- CHANGELOG.md | 6 + PRIVACY.md | 2 +- .../3.json | 2121 +++++++++++++++++ .../data/database/ListenBrainzDao.kt | 45 + .../ListenBrainzPendingListenEntity.kt | 41 + .../data/database/Migrations.kt | 36 + .../pixelplayeross/data/database/MusicDao.kt | 8 +- .../data/database/PixelPlayerDatabase.kt | 6 +- .../data/database/SongEntity.kt | 5 +- .../listenbrainz/ListenBrainzApiService.kt | 25 + .../data/listenbrainz/ListenBrainzModels.kt | 67 + .../listenbrainz/ListenBrainzRepository.kt | 270 +++ .../data/listenbrainz/ScrobbleManager.kt | 138 ++ .../ListenBrainzPreferencesRepository.kt | 53 + .../data/worker/ScrobbleFlushWorker.kt | 121 + .../lostf1sh/pixelplayeross/di/AppModule.kt | 10 +- .../pixelplayeross/di/NetworkModule.kt | 39 + .../lostf1sh/pixelplayeross/di/Qualifiers.kt | 7 + .../presentation/screens/AccountsScreen.kt | 337 ++- .../viewmodel/AccountsViewModel.kt | 113 +- .../viewmodel/ListeningStatsTracker.kt | 21 +- app/src/main/res/values/strings.xml | 14 + app/src/main/res/xml/backup_rules.xml | 3 + .../main/res/xml/data_extraction_rules.xml | 6 + .../data/listenbrainz/ScrobbleManagerTest.kt | 40 + .../viewmodel/ListeningStatsTrackerTest.kt | 8 +- docs/FDROID.md | 5 +- 27 files changed, 3496 insertions(+), 51 deletions(-) create mode 100644 app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzDao.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzPendingListenEntity.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManager.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ListenBrainzPreferencesRepository.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ScrobbleFlushWorker.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManagerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f32d73..9e60f4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to PixelPlayerOSS will be documented in this file. +## [Unreleased] + +### Added +- Optional ListenBrainz scrobbling, disabled by default. Connect a ListenBrainz account with a user token from the Accounts screen; listens that reach the ListenBrainz threshold (4 minutes or half the track, whichever is lower) queue offline and submit with retry, with per-source toggles for local files, Subsonic, and Jellyfin playback. Now-playing status is reported while scrobbling is enabled, and disconnecting deletes any queued listens. +- MusicBrainz identifier columns in the library database as groundwork for tag lookup. + ## [0.1.0] - 2026-06-09 ### Initial release diff --git a/PRIVACY.md b/PRIVACY.md index 90a85f3e..73cfc942 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -13,7 +13,7 @@ Network features are optional and user-controlled: - Navidrome/Subsonic and Jellyfin are used only after the user signs in to a self-hosted server. Those servers may receive the authentication, library, playback state, and play history requests needed for their protocols. - LRCLIB lyric lookup is used only when online lyrics are enabled. - Deezer artist artwork lookup is used only when online artist images are enabled. -- PixelPlayerOSS does not submit listening activity to public scrobbling services such as ListenBrainz or Last.fm. +- ListenBrainz scrobbling is optional and disabled by default. It activates only after the user connects a ListenBrainz account with their own user token. While connected, the app submits listening activity (track title, artist, album, duration, listen timestamps, and MusicBrainz identifiers when available) to ListenBrainz for the playback sources the user has enabled; per-source toggles cover local files, Navidrome/Subsonic, and Jellyfin playback. Disconnecting stops submissions and deletes any queued listens. Last.fm is not supported. Server credentials and preferences are stored locally. The app does not sell or share user data. diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json new file mode 100644 index 00000000..c19a11cb --- /dev/null +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json @@ -0,0 +1,2121 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "fc184763730dff1ccef99cb6ad5563b8", + "entities": [ + { + "tableName": "album_art_themes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumArtUriString` TEXT NOT NULL, `paletteStyle` TEXT NOT NULL, `light_primary` TEXT NOT NULL, `light_onPrimary` TEXT NOT NULL, `light_primaryContainer` TEXT NOT NULL, `light_onPrimaryContainer` TEXT NOT NULL, `light_secondary` TEXT NOT NULL, `light_onSecondary` TEXT NOT NULL, `light_secondaryContainer` TEXT NOT NULL, `light_onSecondaryContainer` TEXT NOT NULL, `light_tertiary` TEXT NOT NULL, `light_onTertiary` TEXT NOT NULL, `light_tertiaryContainer` TEXT NOT NULL, `light_onTertiaryContainer` TEXT NOT NULL, `light_background` TEXT NOT NULL, `light_onBackground` TEXT NOT NULL, `light_surface` TEXT NOT NULL, `light_onSurface` TEXT NOT NULL, `light_surfaceVariant` TEXT NOT NULL, `light_onSurfaceVariant` TEXT NOT NULL, `light_error` TEXT NOT NULL, `light_onError` TEXT NOT NULL, `light_outline` TEXT NOT NULL, `light_errorContainer` TEXT NOT NULL, `light_onErrorContainer` TEXT NOT NULL, `light_inversePrimary` TEXT NOT NULL, `light_inverseSurface` TEXT NOT NULL, `light_inverseOnSurface` TEXT NOT NULL, `light_surfaceTint` TEXT NOT NULL, `light_outlineVariant` TEXT NOT NULL, `light_scrim` TEXT NOT NULL, `light_surfaceBright` TEXT NOT NULL, `light_surfaceDim` TEXT NOT NULL, `light_surfaceContainer` TEXT NOT NULL, `light_surfaceContainerHigh` TEXT NOT NULL, `light_surfaceContainerHighest` TEXT NOT NULL, `light_surfaceContainerLow` TEXT NOT NULL, `light_surfaceContainerLowest` TEXT NOT NULL, `light_primaryFixed` TEXT NOT NULL, `light_primaryFixedDim` TEXT NOT NULL, `light_onPrimaryFixed` TEXT NOT NULL, `light_onPrimaryFixedVariant` TEXT NOT NULL, `light_secondaryFixed` TEXT NOT NULL, `light_secondaryFixedDim` TEXT NOT NULL, `light_onSecondaryFixed` TEXT NOT NULL, `light_onSecondaryFixedVariant` TEXT NOT NULL, `light_tertiaryFixed` TEXT NOT NULL, `light_tertiaryFixedDim` TEXT NOT NULL, `light_onTertiaryFixed` TEXT NOT NULL, `light_onTertiaryFixedVariant` TEXT NOT NULL, `dark_primary` TEXT NOT NULL, `dark_onPrimary` TEXT NOT NULL, `dark_primaryContainer` TEXT NOT NULL, `dark_onPrimaryContainer` TEXT NOT NULL, `dark_secondary` TEXT NOT NULL, `dark_onSecondary` TEXT NOT NULL, `dark_secondaryContainer` TEXT NOT NULL, `dark_onSecondaryContainer` TEXT NOT NULL, `dark_tertiary` TEXT NOT NULL, `dark_onTertiary` TEXT NOT NULL, `dark_tertiaryContainer` TEXT NOT NULL, `dark_onTertiaryContainer` TEXT NOT NULL, `dark_background` TEXT NOT NULL, `dark_onBackground` TEXT NOT NULL, `dark_surface` TEXT NOT NULL, `dark_onSurface` TEXT NOT NULL, `dark_surfaceVariant` TEXT NOT NULL, `dark_onSurfaceVariant` TEXT NOT NULL, `dark_error` TEXT NOT NULL, `dark_onError` TEXT NOT NULL, `dark_outline` TEXT NOT NULL, `dark_errorContainer` TEXT NOT NULL, `dark_onErrorContainer` TEXT NOT NULL, `dark_inversePrimary` TEXT NOT NULL, `dark_inverseSurface` TEXT NOT NULL, `dark_inverseOnSurface` TEXT NOT NULL, `dark_surfaceTint` TEXT NOT NULL, `dark_outlineVariant` TEXT NOT NULL, `dark_scrim` TEXT NOT NULL, `dark_surfaceBright` TEXT NOT NULL, `dark_surfaceDim` TEXT NOT NULL, `dark_surfaceContainer` TEXT NOT NULL, `dark_surfaceContainerHigh` TEXT NOT NULL, `dark_surfaceContainerHighest` TEXT NOT NULL, `dark_surfaceContainerLow` TEXT NOT NULL, `dark_surfaceContainerLowest` TEXT NOT NULL, `dark_primaryFixed` TEXT NOT NULL, `dark_primaryFixedDim` TEXT NOT NULL, `dark_onPrimaryFixed` TEXT NOT NULL, `dark_onPrimaryFixedVariant` TEXT NOT NULL, `dark_secondaryFixed` TEXT NOT NULL, `dark_secondaryFixedDim` TEXT NOT NULL, `dark_onSecondaryFixed` TEXT NOT NULL, `dark_onSecondaryFixedVariant` TEXT NOT NULL, `dark_tertiaryFixed` TEXT NOT NULL, `dark_tertiaryFixedDim` TEXT NOT NULL, `dark_onTertiaryFixed` TEXT NOT NULL, `dark_onTertiaryFixedVariant` TEXT NOT NULL, PRIMARY KEY(`albumArtUriString`))", + "fields": [ + { + "fieldPath": "albumArtUriString", + "columnName": "albumArtUriString", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paletteStyle", + "columnName": "paletteStyle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primary", + "columnName": "light_primary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimary", + "columnName": "light_onPrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryContainer", + "columnName": "light_primaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryContainer", + "columnName": "light_onPrimaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondary", + "columnName": "light_secondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondary", + "columnName": "light_onSecondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryContainer", + "columnName": "light_secondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryContainer", + "columnName": "light_onSecondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiary", + "columnName": "light_tertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiary", + "columnName": "light_onTertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryContainer", + "columnName": "light_tertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryContainer", + "columnName": "light_onTertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.background", + "columnName": "light_background", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onBackground", + "columnName": "light_onBackground", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surface", + "columnName": "light_surface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSurface", + "columnName": "light_onSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceVariant", + "columnName": "light_surfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSurfaceVariant", + "columnName": "light_onSurfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.error", + "columnName": "light_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onError", + "columnName": "light_onError", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.outline", + "columnName": "light_outline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.errorContainer", + "columnName": "light_errorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onErrorContainer", + "columnName": "light_onErrorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inversePrimary", + "columnName": "light_inversePrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inverseSurface", + "columnName": "light_inverseSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.inverseOnSurface", + "columnName": "light_inverseOnSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceTint", + "columnName": "light_surfaceTint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.outlineVariant", + "columnName": "light_outlineVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.scrim", + "columnName": "light_scrim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceBright", + "columnName": "light_surfaceBright", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceDim", + "columnName": "light_surfaceDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainer", + "columnName": "light_surfaceContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerHigh", + "columnName": "light_surfaceContainerHigh", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerHighest", + "columnName": "light_surfaceContainerHighest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerLow", + "columnName": "light_surfaceContainerLow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.surfaceContainerLowest", + "columnName": "light_surfaceContainerLowest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryFixed", + "columnName": "light_primaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.primaryFixedDim", + "columnName": "light_primaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryFixed", + "columnName": "light_onPrimaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onPrimaryFixedVariant", + "columnName": "light_onPrimaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryFixed", + "columnName": "light_secondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.secondaryFixedDim", + "columnName": "light_secondaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryFixed", + "columnName": "light_onSecondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onSecondaryFixedVariant", + "columnName": "light_onSecondaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryFixed", + "columnName": "light_tertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.tertiaryFixedDim", + "columnName": "light_tertiaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryFixed", + "columnName": "light_onTertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lightThemeValues.onTertiaryFixedVariant", + "columnName": "light_onTertiaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primary", + "columnName": "dark_primary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimary", + "columnName": "dark_onPrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryContainer", + "columnName": "dark_primaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryContainer", + "columnName": "dark_onPrimaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondary", + "columnName": "dark_secondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondary", + "columnName": "dark_onSecondary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryContainer", + "columnName": "dark_secondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryContainer", + "columnName": "dark_onSecondaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiary", + "columnName": "dark_tertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiary", + "columnName": "dark_onTertiary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryContainer", + "columnName": "dark_tertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryContainer", + "columnName": "dark_onTertiaryContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.background", + "columnName": "dark_background", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onBackground", + "columnName": "dark_onBackground", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surface", + "columnName": "dark_surface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSurface", + "columnName": "dark_onSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceVariant", + "columnName": "dark_surfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSurfaceVariant", + "columnName": "dark_onSurfaceVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.error", + "columnName": "dark_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onError", + "columnName": "dark_onError", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.outline", + "columnName": "dark_outline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.errorContainer", + "columnName": "dark_errorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onErrorContainer", + "columnName": "dark_onErrorContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inversePrimary", + "columnName": "dark_inversePrimary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inverseSurface", + "columnName": "dark_inverseSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.inverseOnSurface", + "columnName": "dark_inverseOnSurface", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceTint", + "columnName": "dark_surfaceTint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.outlineVariant", + "columnName": "dark_outlineVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.scrim", + "columnName": "dark_scrim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceBright", + "columnName": "dark_surfaceBright", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceDim", + "columnName": "dark_surfaceDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainer", + "columnName": "dark_surfaceContainer", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerHigh", + "columnName": "dark_surfaceContainerHigh", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerHighest", + "columnName": "dark_surfaceContainerHighest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerLow", + "columnName": "dark_surfaceContainerLow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.surfaceContainerLowest", + "columnName": "dark_surfaceContainerLowest", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryFixed", + "columnName": "dark_primaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.primaryFixedDim", + "columnName": "dark_primaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryFixed", + "columnName": "dark_onPrimaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onPrimaryFixedVariant", + "columnName": "dark_onPrimaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryFixed", + "columnName": "dark_secondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.secondaryFixedDim", + "columnName": "dark_secondaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryFixed", + "columnName": "dark_onSecondaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onSecondaryFixedVariant", + "columnName": "dark_onSecondaryFixedVariant", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryFixed", + "columnName": "dark_tertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.tertiaryFixedDim", + "columnName": "dark_tertiaryFixedDim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryFixed", + "columnName": "dark_onTertiaryFixed", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "darkThemeValues.onTertiaryFixedVariant", + "columnName": "dark_onTertiaryFixedVariant", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumArtUriString" + ] + }, + "indices": [ + { + "name": "index_album_art_themes_albumArtUriString_paletteStyle", + "unique": false, + "columnNames": [ + "albumArtUriString", + "paletteStyle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_art_themes_albumArtUriString_paletteStyle` ON `${TABLE_NAME}` (`albumArtUriString`, `paletteStyle`)" + } + ] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_artist` TEXT, `album_artist_id` INTEGER NOT NULL DEFAULT 0, `album_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, `media_store_date_added` INTEGER NOT NULL DEFAULT 0, `media_store_date_modified` INTEGER NOT NULL DEFAULT 0, `title_user_edited` INTEGER NOT NULL DEFAULT 0, `artist_user_edited` INTEGER NOT NULL DEFAULT 0, `album_user_edited` INTEGER NOT NULL DEFAULT 0, `genre_user_edited` INTEGER NOT NULL DEFAULT 0, `mb_recording_id` TEXT, `mb_release_id` TEXT, `mb_artist_id` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtistId", + "columnName": "album_artist_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "albumName", + "columnName": "album_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentUriString", + "columnName": "content_uri_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtUriString", + "columnName": "album_art_uri_string", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "filePath", + "columnName": "file_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDirectoryPath", + "columnName": "parent_directory_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "defaultValue": "null" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "sampleRate", + "columnName": "sample_rate", + "affinity": "INTEGER" + }, + { + "fieldPath": "artistsJson", + "columnName": "artists_json", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateAdded", + "columnName": "media_store_date_added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateModified", + "columnName": "media_store_date_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "titleUserEdited", + "columnName": "title_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "artistUserEdited", + "columnName": "artist_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "albumUserEdited", + "columnName": "album_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "genreUserEdited", + "columnName": "genre_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mbRecordingId", + "columnName": "mb_recording_id", + "affinity": "TEXT" + }, + { + "fieldPath": "mbReleaseId", + "columnName": "mb_release_id", + "affinity": "TEXT" + }, + { + "fieldPath": "mbArtistId", + "columnName": "mb_artist_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_songs_title", + "unique": false, + "columnNames": [ + "title" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_songs_album_id", + "unique": false, + "columnNames": [ + "album_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)" + }, + { + "name": "index_songs_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_songs_artist_name", + "unique": false, + "columnNames": [ + "artist_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)" + }, + { + "name": "index_songs_genre", + "unique": false, + "columnNames": [ + "genre" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)" + }, + { + "name": "index_songs_parent_directory_path", + "unique": false, + "columnNames": [ + "parent_directory_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)" + }, + { + "name": "index_songs_file_path", + "unique": false, + "columnNames": [ + "file_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)" + }, + { + "name": "index_songs_content_uri_string", + "unique": false, + "columnNames": [ + "content_uri_string" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)" + }, + { + "name": "index_songs_date_added", + "unique": false, + "columnNames": [ + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)" + }, + { + "name": "index_songs_duration", + "unique": false, + "columnNames": [ + "duration" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)" + }, + { + "name": "index_songs_source_type", + "unique": false, + "columnNames": [ + "source_type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)" + }, + { + "name": "index_songs_album_artist_id", + "unique": false, + "columnNames": [ + "album_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `${TABLE_NAME}` (`album_artist_id`)" + }, + { + "name": "index_songs_parent_directory_path_source_type_album_id", + "unique": false, + "columnNames": [ + "parent_directory_path", + "source_type", + "album_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)" + }, + { + "name": "index_songs_parent_directory_path_source_type_id", + "unique": false, + "columnNames": [ + "parent_directory_path", + "source_type", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "album_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtUriString", + "columnName": "album_art_uri_string", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_albums_title", + "unique": false, + "columnNames": [ + "title" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_albums_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_albums_artist_name", + "unique": false, + "columnNames": [ + "artist_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)" + }, + { + "name": "index_albums_album_artist", + "unique": false, + "columnNames": [ + "album_artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)" + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT" + }, + { + "fieldPath": "customImageUri", + "columnName": "custom_image_uri", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "transition_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromTrackId", + "columnName": "fromTrackId", + "affinity": "TEXT" + }, + { + "fieldPath": "toTrackId", + "columnName": "toTrackId", + "affinity": "TEXT" + }, + { + "fieldPath": "settings.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings.durationMs", + "columnName": "durationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settings.curveIn", + "columnName": "curveIn", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settings.curveOut", + "columnName": "curveOut", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transition_rules_playlistId_fromTrackId_toTrackId", + "unique": true, + "columnNames": [ + "playlistId", + "fromTrackId", + "toTrackId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)" + } + ] + }, + { + "tableName": "song_artist_cross_ref", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPrimary", + "columnName": "is_primary", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "artist_id" + ] + }, + "indices": [ + { + "name": "index_song_artist_cross_ref_song_id", + "unique": false, + "columnNames": [ + "song_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)" + }, + { + "name": "index_song_artist_cross_ref_artist_id", + "unique": false, + "columnNames": [ + "artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)" + }, + { + "name": "index_song_artist_cross_ref_is_primary", + "unique": false, + "columnNames": [ + "is_primary" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "song_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_engagements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayDurationMs", + "columnName": "total_play_duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedTimestamp", + "columnName": "last_played_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id" + ] + }, + "indices": [ + { + "name": "index_song_engagements_play_count", + "unique": false, + "columnNames": [ + "play_count" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)" + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [ + { + "name": "index_favorites_timestamp", + "unique": false, + "columnNames": [ + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)" + } + ] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + } + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isQueueGenerated", + "columnName": "is_queue_generated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverImageUri", + "columnName": "cover_image_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "coverColorArgb", + "columnName": "cover_color_argb", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverIconName", + "columnName": "cover_icon_name", + "affinity": "TEXT" + }, + { + "fieldPath": "coverShapeType", + "columnName": "cover_shape_type", + "affinity": "TEXT" + }, + { + "fieldPath": "coverShapeDetail1", + "columnName": "cover_shape_detail_1", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail2", + "columnName": "cover_shape_detail_2", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail3", + "columnName": "cover_shape_detail_3", + "affinity": "REAL" + }, + { + "fieldPath": "coverShapeDetail4", + "columnName": "cover_shape_detail_4", + "affinity": "REAL" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_last_modified", + "unique": false, + "columnNames": [ + "last_modified" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)" + } + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "song_id" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_playlist_id_sort_order", + "unique": false, + "columnNames": [ + "playlist_id", + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)" + }, + { + "name": "index_playlist_songs_song_id", + "unique": false, + "columnNames": [ + "song_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)" + } + ] + }, + { + "tableName": "navidrome_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album_artist` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `cover_art_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `suffix` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "navidromeId", + "columnName": "navidrome_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "bitRate", + "columnName": "bitRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "suffix", + "columnName": "suffix", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_navidrome_songs_navidrome_id", + "unique": false, + "columnNames": [ + "navidrome_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_navidrome_id` ON `${TABLE_NAME}` (`navidrome_id`)" + }, + { + "name": "index_navidrome_songs_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + }, + { + "name": "index_navidrome_songs_playlist_id_date_added", + "unique": false, + "columnNames": [ + "playlist_id", + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" + } + ] + }, + { + "tableName": "navidrome_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `owner` TEXT, `cover_art_id` TEXT, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `public` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArtId", + "columnName": "cover_art_id", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "public", + "columnName": "public", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncTime", + "columnName": "last_sync_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "jellyfin_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfin_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album_artist` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jellyfinId", + "columnName": "jellyfin_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "bitRate", + "columnName": "bitRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_jellyfin_songs_jellyfin_id", + "unique": false, + "columnNames": [ + "jellyfin_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)" + }, + { + "name": "index_jellyfin_songs_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + }, + { + "name": "index_jellyfin_songs_playlist_id_date_added", + "unique": false, + "columnNames": [ + "playlist_id", + "date_added" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)" + } + ] + }, + { + "tableName": "jellyfin_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songCount", + "columnName": "song_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncTime", + "columnName": "last_sync_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "listenbrainz_pending_listens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `listened_at_ms` INTEGER NOT NULL, `track_name` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `release_name` TEXT, `duration_ms` INTEGER, `recording_mbid` TEXT, `source` TEXT NOT NULL, `attempts` INTEGER NOT NULL DEFAULT 0, `created_at_ms` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listenedAtMs", + "columnName": "listened_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackName", + "columnName": "track_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistName", + "columnName": "artist_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseName", + "columnName": "release_name", + "affinity": "TEXT" + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "recordingMbid", + "columnName": "recording_mbid", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAtMs", + "columnName": "created_at_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fc184763730dff1ccef99cb6ad5563b8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzDao.kt new file mode 100644 index 00000000..602bac1e --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzDao.kt @@ -0,0 +1,45 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ListenBrainzDao { + + @Insert + suspend fun insert(listen: ListenBrainzPendingListenEntity): Long + + /** Oldest-first so the flush preserves listen order. */ + @Query("SELECT * FROM listenbrainz_pending_listens ORDER BY listened_at_ms ASC LIMIT :limit") + suspend fun oldestPending(limit: Int): List + + @Query("DELETE FROM listenbrainz_pending_listens WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + @Query("UPDATE listenbrainz_pending_listens SET attempts = attempts + 1 WHERE id IN (:ids)") + suspend fun incrementAttempts(ids: List) + + @Query("SELECT COUNT(*) FROM listenbrainz_pending_listens") + suspend fun count(): Int + + @Query("SELECT COUNT(*) FROM listenbrainz_pending_listens") + fun countFlow(): Flow + + /** Drops the oldest rows beyond the queue cap so the table cannot grow unbounded offline. */ + @Query( + """ + DELETE FROM listenbrainz_pending_listens + WHERE id IN ( + SELECT id FROM listenbrainz_pending_listens + ORDER BY listened_at_ms ASC + LIMIT :overflow + ) + """ + ) + suspend fun deleteOldest(overflow: Int) + + @Query("DELETE FROM listenbrainz_pending_listens") + suspend fun clear() +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzPendingListenEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzPendingListenEntity.kt new file mode 100644 index 00000000..e2711096 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/ListenBrainzPendingListenEntity.kt @@ -0,0 +1,41 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A listen awaiting submission to ListenBrainz. The table is the pending queue: rows are deleted + * on successful submission or permanent rejection, so no status column exists. + * + * Metadata is snapshotted at enqueue time — the song may be edited or deleted before the queue + * flushes, and ListenBrainz should receive what was actually played. + */ +@Entity(tableName = "listenbrainz_pending_listens") +data class ListenBrainzPendingListenEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + /** Epoch millis of when the listen started (ListenBrainz `listened_at` semantics). */ + @ColumnInfo(name = "listened_at_ms") val listenedAtMs: Long, + @ColumnInfo(name = "track_name") val trackName: String, + @ColumnInfo(name = "artist_name") val artistName: String, + @ColumnInfo(name = "release_name") val releaseName: String? = null, + @ColumnInfo(name = "duration_ms") val durationMs: Long? = null, + @ColumnInfo(name = "recording_mbid") val recordingMbid: String? = null, + /** One of [ListenBrainzSource] — which per-source toggle admitted this listen. */ + @ColumnInfo(name = "source") val source: String, + @ColumnInfo(name = "attempts", defaultValue = "0") val attempts: Int = 0, + @ColumnInfo(name = "created_at_ms") val createdAtMs: Long +) + +/** Playback source labels stored in [ListenBrainzPendingListenEntity.source]. */ +object ListenBrainzSource { + const val LOCAL = "LOCAL" + const val NAVIDROME = "NAVIDROME" + const val JELLYFIN = "JELLYFIN" + + fun fromSourceType(sourceType: Int): String = when (sourceType) { + SourceType.NAVIDROME -> NAVIDROME + SourceType.JELLYFIN -> JELLYFIN + else -> LOCAL + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt index 42943f11..1ffe44fe 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt @@ -51,3 +51,39 @@ val MIGRATION_1_2 = object : Migration(1, 2) { db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `songs` (`album_artist_id`)") } } + +/** + * v2 -> v3: opt-in ListenBrainz scrobbling + MusicBrainz identifier storage. + * + * - `listenbrainz_pending_listens`: offline scrobble queue. Rows snapshot track metadata at + * enqueue time and are deleted on successful submission or permanent rejection. + * - `songs.mb_recording_id` / `mb_release_id` / `mb_artist_id`: MusicBrainz identifiers, read + * from embedded file tags or applied via tag lookup. Scrobbles carry the recording MBID when + * known for better server-side matching. + * + * Additive and idempotent, per the Auto Backup drift guard above. + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `listenbrainz_pending_listens` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `listened_at_ms` INTEGER NOT NULL, + `track_name` TEXT NOT NULL, + `artist_name` TEXT NOT NULL, + `release_name` TEXT, + `duration_ms` INTEGER, + `recording_mbid` TEXT, + `source` TEXT NOT NULL, + `attempts` INTEGER NOT NULL DEFAULT 0, + `created_at_ms` INTEGER NOT NULL + ) + """.trimIndent() + ) + + db.addColumnIfMissing("songs", "mb_recording_id", "`mb_recording_id` TEXT") + db.addColumnIfMissing("songs", "mb_release_id", "`mb_release_id` TEXT") + db.addColumnIfMissing("songs", "mb_artist_id", "`mb_artist_id` TEXT") + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt index 93c175b6..37e4b0d9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt @@ -72,7 +72,10 @@ private const val SONG_DETAIL_PROJECTION = """ songs.title_user_edited AS title_user_edited, songs.artist_user_edited AS artist_user_edited, songs.album_user_edited AS album_user_edited, - songs.genre_user_edited AS genre_user_edited + songs.genre_user_edited AS genre_user_edited, + songs.mb_recording_id AS mb_recording_id, + songs.mb_release_id AS mb_release_id, + songs.mb_artist_id AS mb_artist_id """ private const val SONG_LIST_PROJECTION = """ @@ -81,7 +84,8 @@ private const val SONG_LIST_PROJECTION = """ parent_directory_path, is_favorite, NULL AS lyrics, track_number, disc_number, year, date_added, mime_type, bitrate, sample_rate, artists_json, source_type, media_store_date_added, media_store_date_modified, title_user_edited, - artist_user_edited, album_user_edited, genre_user_edited + artist_user_edited, album_user_edited, genre_user_edited, + mb_recording_id, mb_release_id, mb_artist_id """ data class DeviceCapabilitySongRow( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt index 21871c66..98c693d3 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt @@ -22,9 +22,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase NavidromeSongEntity::class, NavidromePlaylistEntity::class, JellyfinSongEntity::class, - JellyfinPlaylistEntity::class + JellyfinPlaylistEntity::class, + ListenBrainzPendingListenEntity::class ], - version = 2, + version = 3, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { @@ -38,6 +39,7 @@ abstract class PixelPlayerDatabase : RoomDatabase() { abstract fun localPlaylistDao(): LocalPlaylistDao abstract fun navidromeDao(): NavidromeDao abstract fun jellyfinDao(): JellyfinDao + abstract fun listenBrainzDao(): ListenBrainzDao companion object { fun installFavoriteSyncTriggers(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt index 9b198a5e..496e0dcf 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt @@ -91,7 +91,10 @@ data class SongEntity( @ColumnInfo(name = "title_user_edited", defaultValue = "0") val titleUserEdited: Boolean = false, @ColumnInfo(name = "artist_user_edited", defaultValue = "0") val artistUserEdited: Boolean = false, @ColumnInfo(name = "album_user_edited", defaultValue = "0") val albumUserEdited: Boolean = false, - @ColumnInfo(name = "genre_user_edited", defaultValue = "0") val genreUserEdited: Boolean = false + @ColumnInfo(name = "genre_user_edited", defaultValue = "0") val genreUserEdited: Boolean = false, + @ColumnInfo(name = "mb_recording_id") val mbRecordingId: String? = null, + @ColumnInfo(name = "mb_release_id") val mbReleaseId: String? = null, + @ColumnInfo(name = "mb_artist_id") val mbArtistId: String? = null ) private fun SongEntity.toSongInternal(artists: List): Song { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt new file mode 100644 index 00000000..8be69b4f --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt @@ -0,0 +1,25 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST + +/** + * Retrofit interface for the ListenBrainz API. + * Authorization header format: `Token `. + */ +interface ListenBrainzApiService { + + @POST("1/submit-listens") + suspend fun submitListens( + @Header("Authorization") authorization: String, + @Body submission: ListenBrainzSubmission + ): Response + + @GET("1/validate-token") + suspend fun validateToken( + @Header("Authorization") authorization: String + ): Response +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt new file mode 100644 index 00000000..52871677 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt @@ -0,0 +1,67 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import com.google.gson.annotations.SerializedName + +/** + * Request body for `POST /1/submit-listens`. + * Gson omits null fields, which matters: `playing_now` payloads must not carry `listened_at`. + */ +data class ListenBrainzSubmission( + @SerializedName("listen_type") val listenType: String, + @SerializedName("payload") val payload: List +) { + companion object { + const val TYPE_PLAYING_NOW = "playing_now" + const val TYPE_IMPORT = "import" + } +} + +data class ListenBrainzListen( + /** Epoch seconds of when the listen started. Null for `playing_now`. */ + @SerializedName("listened_at") val listenedAt: Long? = null, + @SerializedName("track_metadata") val trackMetadata: ListenBrainzTrackMetadata +) + +data class ListenBrainzTrackMetadata( + @SerializedName("artist_name") val artistName: String, + @SerializedName("track_name") val trackName: String, + @SerializedName("release_name") val releaseName: String? = null, + @SerializedName("additional_info") val additionalInfo: ListenBrainzAdditionalInfo? = null +) + +data class ListenBrainzAdditionalInfo( + @SerializedName("media_player") val mediaPlayer: String? = null, + @SerializedName("submission_client") val submissionClient: String? = null, + @SerializedName("duration_ms") val durationMs: Long? = null, + @SerializedName("recording_mbid") val recordingMbid: String? = null +) + +/** Response of `GET /1/validate-token`. */ +data class ListenBrainzTokenValidation( + @SerializedName("valid") val valid: Boolean = false, + @SerializedName("user_name") val userName: String? = null +) + +/** Connection state surfaced on the Accounts screen. */ +data class ListenBrainzAccountState( + val isConnected: Boolean = false, + val userName: String? = null, + /** True when the stored token was rejected (HTTP 401); flushing is paused until reconnect. */ + val needsReauth: Boolean = false +) + +sealed interface ListenBrainzSubmitResult { + data object Success : ListenBrainzSubmitResult + + /** Token rejected; queue flushing pauses until the user reconnects. */ + data object AuthFailed : ListenBrainzSubmitResult + + /** The server rejected the payload as invalid (HTTP 400) — permanent for this payload. */ + data object InvalidPayload : ListenBrainzSubmitResult + + /** + * Rate limited or transient failure — retry later with backoff, but no earlier than + * [retryAfterSeconds] when the server sent a retry window. + */ + data class TransientError(val retryAfterSeconds: Long? = null) : ListenBrainzSubmitResult +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt new file mode 100644 index 00000000..d942d9d1 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt @@ -0,0 +1,270 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzDao +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzPendingListenEntity +import com.lostf1sh.pixelplayeross.data.worker.ScrobbleFlushWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import retrofit2.Response +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the ListenBrainz account (user token in EncryptedSharedPreferences, mirroring the + * Navidrome credential pattern) and all API submission paths. + * + * Scrobbling is opt-in: with no stored token, every path here is a no-op. + */ +@Singleton +class ListenBrainzRepository @Inject constructor( + private val api: ListenBrainzApiService, + private val listenBrainzDao: ListenBrainzDao, + private val workManager: WorkManager, + @ApplicationContext private val context: Context +) { + companion object { + private const val TAG = "ListenBrainzRepo" + private const val PREFS_NAME = "listenbrainz_prefs" + private const val KEY_TOKEN = "token" + private const val KEY_USER_NAME = "user_name" + private const val KEY_AUTH_INVALID = "auth_invalid" + + private const val SUBMISSION_CLIENT = "PixelPlayerOSS" + private const val MAX_QUEUE_SIZE = 3000 + } + + private val prefs: SharedPreferences = createCredentialPrefs() + + /** Serializes queue mutation against disconnect so consent revocation is a clean boundary. */ + private val queueMutex = Mutex() + + @Volatile + private var cachedToken: String? = prefs.getString(KEY_TOKEN, null) + + private val _accountState = MutableStateFlow( + ListenBrainzAccountState( + isConnected = cachedToken != null, + userName = prefs.getString(KEY_USER_NAME, null), + needsReauth = prefs.getBoolean(KEY_AUTH_INVALID, false) + ) + ) + val accountState: StateFlow = _accountState.asStateFlow() + + val pendingListenCount = listenBrainzDao.countFlow() + + private fun createEncryptedPrefs(): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private fun createCredentialPrefs(): SharedPreferences = try { + createEncryptedPrefs() + } catch (e: Exception) { + Timber.e(e, "$TAG: EncryptedSharedPreferences unreadable, deleting and recreating") + context.deleteSharedPreferences(PREFS_NAME) + try { + createEncryptedPrefs() + } catch (e2: Exception) { + Timber.e(e2, "$TAG: Encrypted prefs unavailable, falling back to plain") + context.getSharedPreferences("${PREFS_NAME}_plain", Context.MODE_PRIVATE) + } + } + + fun hasToken(): Boolean = cachedToken != null + + /** + * True when a token is stored and not known to be invalid. Gate outbound requests on this; + * [hasToken] alone only decides whether listens should still be collected into the queue. + */ + fun isAuthorized(): Boolean = cachedToken != null && !_accountState.value.needsReauth + + /** + * Inserts under the same lock [disconnect] takes, so a listen admitted before a disconnect + * can never land in the queue after consent was revoked. Returns false when disconnected. + */ + suspend fun enqueueListen(listen: ListenBrainzPendingListenEntity): Boolean { + return queueMutex.withLock { + if (cachedToken == null) return@withLock false + listenBrainzDao.insert(listen) + val overflow = listenBrainzDao.count() - MAX_QUEUE_SIZE + if (overflow > 0) { + listenBrainzDao.deleteOldest(overflow) + Timber.w("ListenBrainz queue capped at %d, dropped %d oldest listens", MAX_QUEUE_SIZE, overflow) + } + true + } + } + + /** + * Validates [token] against the API and stores it on success. + * Returns the ListenBrainz user name on success. + */ + suspend fun connect(token: String): Result { + val trimmed = token.trim() + if (trimmed.isEmpty()) return Result.failure(IllegalArgumentException("Empty token")) + return try { + val response = api.validateToken(authHeader(trimmed)) + val body = response.body() + if (response.isSuccessful && body?.valid == true) { + val userName = body.userName.orEmpty() + prefs.edit { + putString(KEY_TOKEN, trimmed) + putString(KEY_USER_NAME, userName) + putBoolean(KEY_AUTH_INVALID, false) + } + cachedToken = trimmed + _accountState.value = ListenBrainzAccountState( + isConnected = true, + userName = userName, + needsReauth = false + ) + scheduleFlush() + Result.success(userName) + } else { + Result.failure(IllegalStateException("Token rejected by ListenBrainz")) + } + } catch (e: Exception) { + Timber.e(e, "$TAG: Token validation failed") + Result.failure(e) + } + } + + /** Clears the account and drops any queued listens — disconnecting revokes consent. */ + suspend fun disconnect() { + queueMutex.withLock { + prefs.edit { + remove(KEY_TOKEN) + remove(KEY_USER_NAME) + remove(KEY_AUTH_INVALID) + } + cachedToken = null + _accountState.value = ListenBrainzAccountState() + listenBrainzDao.clear() + } + } + + suspend fun submitListens(listens: List): ListenBrainzSubmitResult { + val token = cachedToken ?: return ListenBrainzSubmitResult.AuthFailed + val submission = ListenBrainzSubmission( + listenType = ListenBrainzSubmission.TYPE_IMPORT, + payload = listens.map { it.toListen() } + ) + return submit(token, submission) + } + + suspend fun submitPlayingNow( + trackName: String, + artistName: String, + releaseName: String?, + durationMs: Long?, + recordingMbid: String? + ): ListenBrainzSubmitResult { + val token = cachedToken ?: return ListenBrainzSubmitResult.AuthFailed + val submission = ListenBrainzSubmission( + listenType = ListenBrainzSubmission.TYPE_PLAYING_NOW, + payload = listOf( + ListenBrainzListen( + trackMetadata = trackMetadata(trackName, artistName, releaseName, durationMs, recordingMbid) + ) + ) + ) + return submit(token, submission) + } + + private suspend fun submit(token: String, submission: ListenBrainzSubmission): ListenBrainzSubmitResult { + return try { + val response = api.submitListens(authHeader(token), submission) + when { + response.isSuccessful -> ListenBrainzSubmitResult.Success + response.code() == 401 -> { + markAuthInvalid() + ListenBrainzSubmitResult.AuthFailed + } + response.code() == 400 -> ListenBrainzSubmitResult.InvalidPayload + response.code() == 429 -> ListenBrainzSubmitResult.TransientError( + retryAfterSeconds = response.retryAfterSeconds() + ) + else -> ListenBrainzSubmitResult.TransientError() + } + } catch (e: Exception) { + Timber.d(e, "$TAG: Submission failed, will retry") + ListenBrainzSubmitResult.TransientError() + } + } + + private fun Response<*>.retryAfterSeconds(): Long? { + return headers()["Retry-After"]?.toLongOrNull() + ?: headers()["X-RateLimit-Reset-In"]?.toLongOrNull() + } + + private fun markAuthInvalid() { + prefs.edit { putBoolean(KEY_AUTH_INVALID, true) } + _accountState.value = _accountState.value.copy(needsReauth = true) + } + + fun scheduleFlush() { + workManager.enqueueUniqueWork( + ScrobbleFlushWorker.WORK_NAME, + ExistingWorkPolicy.KEEP, + ScrobbleFlushWorker.request() + ) + } + + /** Chains the next flush attempt no earlier than a server-directed retry window. */ + fun scheduleFlushAfter(delaySeconds: Long) { + workManager.enqueueUniqueWork( + ScrobbleFlushWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + ScrobbleFlushWorker.request(initialDelaySeconds = delaySeconds) + ) + } + + private fun authHeader(token: String) = "Token $token" + + private fun ListenBrainzPendingListenEntity.toListen(): ListenBrainzListen { + return ListenBrainzListen( + listenedAt = listenedAtMs / 1000, + trackMetadata = trackMetadata(trackName, artistName, releaseName, durationMs, recordingMbid) + ) + } + + private fun trackMetadata( + trackName: String, + artistName: String, + releaseName: String?, + durationMs: Long?, + recordingMbid: String? + ): ListenBrainzTrackMetadata { + return ListenBrainzTrackMetadata( + artistName = artistName, + trackName = trackName, + releaseName = releaseName, + additionalInfo = ListenBrainzAdditionalInfo( + mediaPlayer = SUBMISSION_CLIENT, + submissionClient = SUBMISSION_CLIENT, + durationMs = durationMs, + recordingMbid = recordingMbid + ) + ) + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManager.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManager.kt new file mode 100644 index 00000000..4c3c7b78 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManager.kt @@ -0,0 +1,138 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import android.os.SystemClock +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzDao +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzPendingListenEntity +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzSource +import com.lostf1sh.pixelplayeross.data.database.MusicDao +import com.lostf1sh.pixelplayeross.data.database.SongEntity +import com.lostf1sh.pixelplayeross.data.preferences.ListenBrainzPreferencesRepository +import com.lostf1sh.pixelplayeross.di.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Bridges finalized listening sessions to the ListenBrainz submission queue. + * + * Fed by [com.lostf1sh.pixelplayeross.presentation.viewmodel.ListeningStatsTracker]: the listen + * threshold is evaluated synchronously (cheap math, safe inside the tracker's synchronized + * finalize), everything else — song resolution, toggle check, queue insert — runs on [scope]. + * + * The ListenBrainz threshold is deliberately independent of the tracker's own stats floor, and + * of the Subsonic scrobble in MusicService (which fires on raw track-end with no threshold). + */ +@Singleton +class ScrobbleManager @Inject constructor( + private val musicDao: MusicDao, + private val listenBrainzDao: ListenBrainzDao, + private val listenBrainzRepository: ListenBrainzRepository, + private val listenBrainzPreferences: ListenBrainzPreferencesRepository, + @AppScope private val scope: CoroutineScope +) { + private var lastPlayingNowSongId: String? = null + private var lastPlayingNowRealtimeMs: Long = 0L + + init { + // Flush listens left queued by a previous process (e.g. killed while offline). + scope.launch { + runCatching { + if (listenBrainzRepository.isAuthorized() && listenBrainzDao.count() > 0) { + listenBrainzRepository.scheduleFlush() + } + }.onFailure { Timber.e(it, "Failed to schedule startup scrobble flush") } + } + } + + fun onSessionFinalized( + songId: String, + startedAtEpochMs: Long, + listenedMs: Long, + trackDurationMs: Long + ) { + if (!listenBrainzRepository.hasToken()) return + if (!meetsListenThreshold(listenedMs, trackDurationMs)) return + scope.launch { + runCatching { enqueueListen(songId, startedAtEpochMs) } + .onFailure { Timber.e(it, "Failed to enqueue ListenBrainz listen for song=%s", songId) } + } + } + + fun onPlayingNow(songId: String) { + if (!listenBrainzRepository.isAuthorized()) return + val now = SystemClock.elapsedRealtime() + synchronized(this) { + if (songId == lastPlayingNowSongId && now - lastPlayingNowRealtimeMs < PLAYING_NOW_DEBOUNCE_MS) { + return + } + lastPlayingNowSongId = songId + lastPlayingNowRealtimeMs = now + } + scope.launch { + runCatching { + val song = resolveAdmittedSong(songId) ?: return@launch + listenBrainzRepository.submitPlayingNow( + trackName = song.title, + artistName = song.artistName, + releaseName = song.albumName.takeIf { it.isNotBlank() }, + durationMs = song.duration.takeIf { it > 0 }, + recordingMbid = song.mbRecordingId + ) + }.onFailure { Timber.d(it, "ListenBrainz playing-now failed for song=%s", songId) } + } + } + + private suspend fun enqueueListen(songId: String, startedAtEpochMs: Long) { + val song = resolveAdmittedSong(songId) ?: return + val inserted = listenBrainzRepository.enqueueListen( + ListenBrainzPendingListenEntity( + listenedAtMs = startedAtEpochMs, + trackName = song.title, + artistName = song.artistName, + releaseName = song.albumName.takeIf { it.isNotBlank() }, + durationMs = song.duration.takeIf { it > 0 }, + recordingMbid = song.mbRecordingId, + source = ListenBrainzSource.fromSourceType(song.sourceType), + createdAtMs = System.currentTimeMillis() + ) + ) + // While the token is known-invalid, listens keep queueing but flushing waits for reconnect. + if (inserted && listenBrainzRepository.isAuthorized()) { + listenBrainzRepository.scheduleFlush() + } + } + + /** + * Resolves the song, or null when it no longer exists, lacks the metadata ListenBrainz + * requires, or its playback source's scrobble toggle is off. + */ + private suspend fun resolveAdmittedSong(songId: String): SongEntity? { + val id = songId.toLongOrNull() ?: return null + val song = musicDao.getSongByIdOnce(id) ?: return null + if (song.title.isBlank() || song.artistName.isBlank()) return null + val source = ListenBrainzSource.fromSourceType(song.sourceType) + if (!listenBrainzPreferences.isSourceEnabled(source)) return null + return song + } + + companion object { + private const val PLAYING_NOW_DEBOUNCE_MS = 30_000L + private val FOUR_MINUTES_MS = TimeUnit.MINUTES.toMillis(4) + + /** + * ListenBrainz listen rule: 4 minutes or half the track, whichever is lower; + * plain 4-minute rule when the duration is unknown. + */ + fun meetsListenThreshold(listenedMs: Long, trackDurationMs: Long): Boolean { + val required = if (trackDurationMs > 0) { + minOf(FOUR_MINUTES_MS, trackDurationMs / 2) + } else { + FOUR_MINUTES_MS + } + return listenedMs >= required + } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ListenBrainzPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ListenBrainzPreferencesRepository.kt new file mode 100644 index 00000000..7ccb7f67 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/ListenBrainzPreferencesRepository.kt @@ -0,0 +1,53 @@ +package com.lostf1sh.pixelplayeross.data.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Per-source scrobble toggles. All default to enabled — connecting a ListenBrainz account is the + * consent act; users disable a streaming source only when their server already scrobbles + * server-side. With no account connected these values have no effect. + */ +@Singleton +class ListenBrainzPreferencesRepository @Inject constructor( + private val dataStore: DataStore +) { + private object Keys { + val SCROBBLE_LOCAL = booleanPreferencesKey("listenbrainz_scrobble_local") + val SCROBBLE_NAVIDROME = booleanPreferencesKey("listenbrainz_scrobble_navidrome") + val SCROBBLE_JELLYFIN = booleanPreferencesKey("listenbrainz_scrobble_jellyfin") + } + + val scrobbleLocalFlow: Flow = dataStore.data.map { it[Keys.SCROBBLE_LOCAL] ?: true } + val scrobbleNavidromeFlow: Flow = dataStore.data.map { it[Keys.SCROBBLE_NAVIDROME] ?: true } + val scrobbleJellyfinFlow: Flow = dataStore.data.map { it[Keys.SCROBBLE_JELLYFIN] ?: true } + + suspend fun setScrobbleLocal(enabled: Boolean) { + dataStore.edit { it[Keys.SCROBBLE_LOCAL] = enabled } + } + + suspend fun setScrobbleNavidrome(enabled: Boolean) { + dataStore.edit { it[Keys.SCROBBLE_NAVIDROME] = enabled } + } + + suspend fun setScrobbleJellyfin(enabled: Boolean) { + dataStore.edit { it[Keys.SCROBBLE_JELLYFIN] = enabled } + } + + suspend fun isSourceEnabled(source: String): Boolean { + val key = when (source) { + ListenBrainzSource.NAVIDROME -> Keys.SCROBBLE_NAVIDROME + ListenBrainzSource.JELLYFIN -> Keys.SCROBBLE_JELLYFIN + else -> Keys.SCROBBLE_LOCAL + } + return dataStore.data.first()[key] ?: true + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ScrobbleFlushWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ScrobbleFlushWorker.kt new file mode 100644 index 00000000..f10a2c06 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/ScrobbleFlushWorker.kt @@ -0,0 +1,121 @@ +package com.lostf1sh.pixelplayeross.data.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkerParameters +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzDao +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzPendingListenEntity +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzRepository +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzSubmitResult +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import timber.log.Timber +import java.util.concurrent.TimeUnit + +/** + * Drains the ListenBrainz pending-listen queue in batches, oldest first. + * + * Terminal outcomes per batch: success and permanent rejection delete rows; an invalid token + * pauses flushing entirely (the queue survives until the user reconnects); rate limits and + * network failures retry with backoff. + */ +@HiltWorker +class ScrobbleFlushWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val listenBrainzDao: ListenBrainzDao, + private val repository: ListenBrainzRepository +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + if (!repository.isAuthorized()) return Result.success() + + repeat(MAX_BATCHES_PER_RUN) { + val batch = listenBrainzDao.oldestPending(BATCH_SIZE) + if (batch.isEmpty()) return Result.success() + + when (val result = repository.submitListens(batch)) { + ListenBrainzSubmitResult.Success -> { + listenBrainzDao.deleteByIds(batch.map { it.id }) + } + ListenBrainzSubmitResult.InvalidPayload -> { + if (!dropInvalidListens(batch)) { + listenBrainzDao.incrementAttempts(batch.map { it.id }) + return Result.retry() + } + } + ListenBrainzSubmitResult.AuthFailed -> { + Timber.w("ListenBrainz token rejected; pausing scrobble flush") + return Result.success() + } + is ListenBrainzSubmitResult.TransientError -> { + listenBrainzDao.incrementAttempts(batch.map { it.id }) + val retryAfterSeconds = result.retryAfterSeconds + return if (retryAfterSeconds != null) { + // Honor the server's retry window instead of the generic backoff. + repository.scheduleFlushAfter(retryAfterSeconds) + Result.success() + } else { + Result.retry() + } + } + } + } + // Batch budget exhausted; remaining rows flush on the next enqueue or app start. + return Result.success() + } + + /** + * A batch-level 400 does not identify the offending listen, so resubmit individually: + * valid rows submit and delete, invalid rows delete. Returns false when no progress was + * made (transient failure mid-drain), signalling the caller to retry the whole batch. + */ + private suspend fun dropInvalidListens(batch: List): Boolean { + var progressed = false + for (listen in batch) { + when (repository.submitListens(listOf(listen))) { + ListenBrainzSubmitResult.Success -> { + listenBrainzDao.deleteByIds(listOf(listen.id)) + progressed = true + } + ListenBrainzSubmitResult.InvalidPayload -> { + Timber.w( + "Dropping listen rejected by ListenBrainz: %s — %s", + listen.artistName, listen.trackName + ) + listenBrainzDao.deleteByIds(listOf(listen.id)) + progressed = true + } + ListenBrainzSubmitResult.AuthFailed, + is ListenBrainzSubmitResult.TransientError -> return progressed + } + } + return true + } + + companion object { + const val WORK_NAME = "listenbrainz_scrobble_flush" + private const val BATCH_SIZE = 50 + private const val MAX_BATCHES_PER_RUN = 100 + + fun request(initialDelaySeconds: Long = 0L): OneTimeWorkRequest { + val builder = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + if (initialDelaySeconds > 0L) { + builder.setInitialDelay(initialDelaySeconds, TimeUnit.SECONDS) + } + return builder.build() + } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt index b10a64d7..7ffc6619 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -22,7 +22,9 @@ import com.lostf1sh.pixelplayeross.data.database.EngagementDao import com.lostf1sh.pixelplayeross.data.database.FavoritesDao import com.lostf1sh.pixelplayeross.data.database.LyricsDao import com.lostf1sh.pixelplayeross.data.database.LocalPlaylistDao +import com.lostf1sh.pixelplayeross.data.database.ListenBrainzDao import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_2_3 import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SearchHistoryDao @@ -124,7 +126,7 @@ object AppModule { "pixelplayer_database" ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) if (BuildConfig.DEBUG) { @@ -194,6 +196,12 @@ object AppModule { return database.jellyfinDao() } + @Singleton + @Provides + fun provideListenBrainzDao(database: PixelPlayerDatabase): ListenBrainzDao { + return database.listenBrainzDao() + } + @Provides @Singleton fun provideImageLoader( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt new file mode 100644 index 00000000..d045dd17 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt @@ -0,0 +1,39 @@ +package com.lostf1sh.pixelplayeross.di + +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzApiService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + /** + * Base URL is provided in exactly one place so a user-configurable endpoint + * (self-hosted ListenBrainz / Maloja) stays a trivial follow-up. + */ + private const val LISTENBRAINZ_BASE_URL = "https://api.listenbrainz.org/" + + @Provides + @Singleton + @ListenBrainzRetrofit + fun provideListenBrainzRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(LISTENBRAINZ_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideListenBrainzApiService(@ListenBrainzRetrofit retrofit: Retrofit): ListenBrainzApiService { + return retrofit.create(ListenBrainzApiService::class.java) + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/Qualifiers.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/Qualifiers.kt index afb41f61..0cceff26 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/Qualifiers.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/Qualifiers.kt @@ -29,3 +29,10 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for ListenBrainz Retrofit instance. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ListenBrainzRetrofit diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt index daf94669..98b1d00d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt @@ -4,6 +4,7 @@ package com.lostf1sh.pixelplayeross.presentation.screens import android.content.Context import android.content.Intent +import android.net.Uri import android.widget.Toast import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -34,7 +35,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.CloudQueue +import androidx.compose.material.icons.rounded.GraphicEq import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -44,8 +47,11 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -67,7 +73,10 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -79,6 +88,8 @@ import com.lostf1sh.pixelplayeross.presentation.navidrome.auth.NavidromeLoginAct import com.lostf1sh.pixelplayeross.presentation.viewmodel.AccountsViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.ExternalAccountUiModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.ExternalServiceAccount +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ListenBrainzConnectState +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ListenBrainzUiModel import kotlin.math.roundToInt import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -93,6 +104,15 @@ fun AccountsScreen( ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val listenBrainzConnectState by viewModel.listenBrainzConnectState.collectAsStateWithLifecycle() + var showListenBrainzDialog by remember { mutableStateOf(false) } + + LaunchedEffect(listenBrainzConnectState) { + if (listenBrainzConnectState == ListenBrainzConnectState.Success) { + showListenBrainzDialog = false + viewModel.resetListenBrainzConnectState() + } + } val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -158,6 +178,20 @@ fun AccountsScreen( } } + val onConnectService: (ExternalServiceAccount) -> Unit = { service -> + if (service == ExternalServiceAccount.LISTENBRAINZ) { + showListenBrainzDialog = true + } else { + openService( + context = context, + service = service, + onOpenNavidromeDashboard = onOpenNavidromeDashboard, + onOpenJellyfinDashboard = onOpenJellyfinDashboard, + preferDashboard = false + ) + } + } + Box(modifier = Modifier.nestedScroll(nestedScrollConnection).fillMaxSize()) { val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() } @@ -210,22 +244,45 @@ fun AccountsScreen( painterResource(R.drawable.ic_jellyfin) } else if (account.service == ExternalServiceAccount.NAVIDROME) { painterResource(R.drawable.ic_navidrome_md3) + } else null, + extraContent = if (account.service == ExternalServiceAccount.LISTENBRAINZ) { + { + uiState.listenBrainz?.let { listenBrainz -> + ListenBrainzCardExtras( + model = listenBrainz, + onToggleLocal = viewModel::setListenBrainzScrobbleLocal, + onToggleNavidrome = viewModel::setListenBrainzScrobbleNavidrome, + onToggleJellyfin = viewModel::setListenBrainzScrobbleJellyfin, + onReconnect = { showListenBrainzDialog = true } + ) + } + } } else null ) } + + if (uiState.disconnectedServices.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.accounts_available_services), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp, top = 4.dp) + ) + } + item { + AvailableServicesCard( + disconnectedServices = uiState.disconnectedServices, + onConnect = onConnectService + ) + } + } } else { item { EmptyAccountsCard( disconnectedServices = uiState.disconnectedServices, - onConnect = { service -> - openService( - context = context, - service = service, - onOpenNavidromeDashboard = onOpenNavidromeDashboard, - onOpenJellyfinDashboard = onOpenJellyfinDashboard, - preferDashboard = false - ) - } + onConnect = onConnectService ) } } @@ -239,6 +296,17 @@ fun AccountsScreen( expandedTitleStartPadding = 20.dp, collapsedTitleStartPadding = 68.dp ) + + if (showListenBrainzDialog) { + ListenBrainzTokenDialog( + connectState = listenBrainzConnectState, + onConnect = viewModel::connectListenBrainz, + onDismiss = { + showListenBrainzDialog = false + viewModel.resetListenBrainzConnectState() + } + ) + } } } @@ -327,7 +395,8 @@ private fun ConnectedAccountCard( account: ExternalAccountUiModel, onManage: () -> Unit, onLogout: () -> Unit, - painter: androidx.compose.ui.graphics.painter.Painter? = null + painter: androidx.compose.ui.graphics.painter.Painter? = null, + extraContent: (@Composable () -> Unit)? = null ) { val statusConnected = stringResource(R.string.presentation_batch_b_accounts_status_connected) val openService = stringResource(R.string.presentation_batch_b_accounts_open_service) @@ -441,6 +510,8 @@ private fun ConnectedAccountCard( } } + extraContent?.invoke() + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)) FilledTonalButton( @@ -500,7 +571,6 @@ private fun EmptyAccountsCard( ) { val noLinkedTitle = stringResource(R.string.presentation_batch_b_accounts_no_linked_title) val noLinkedBody = stringResource(R.string.presentation_batch_b_accounts_no_linked_body) - val connectTemplate = stringResource(R.string.presentation_batch_b_accounts_connect_service) Card( shape = AbsoluteSmoothCornerShape(28.dp, 60), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), @@ -523,30 +593,7 @@ private fun EmptyAccountsCard( ) disconnectedServices.forEach { service -> - val painter = when (service) { - ExternalServiceAccount.JELLYFIN -> painterResource(R.drawable.ic_jellyfin) - ExternalServiceAccount.NAVIDROME -> painterResource(R.drawable.ic_navidrome_md3) - } - FilledTonalButton( - onClick = { onConnect(service) }, - enabled = true, - shape = AbsoluteSmoothCornerShape(18.dp, 60), - colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors( - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth().height(48.dp) - ) { - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = connectTemplate.format(serviceDisplayName(service)) - ) - } + ConnectServiceButton(service = service, onConnect = onConnect) } } } @@ -580,6 +627,14 @@ private fun servicePalette(service: ExternalServiceAccount): ServicePalette { primaryActionContainer = Color(0xFFE3F2FD), primaryActionTint = Color(0xFF1565C0) ) + ExternalServiceAccount.LISTENBRAINZ -> ServicePalette( + iconContainer = Color(0xFFEB743B), + iconTint = Color.White, + statusContainer = Color(0xFFFFE8DC), + statusTint = Color(0xFFA84A17), + primaryActionContainer = Color(0xFFFFE3D3), + primaryActionTint = Color(0xFFA84A17) + ) } } @@ -587,6 +642,7 @@ private fun accountIcon(service: ExternalServiceAccount): ImageVector { return when (service) { ExternalServiceAccount.NAVIDROME -> Icons.Rounded.CloudQueue ExternalServiceAccount.JELLYFIN -> Icons.Rounded.CloudQueue + ExternalServiceAccount.LISTENBRAINZ -> Icons.Rounded.GraphicEq } } @@ -636,6 +692,7 @@ private fun serviceDisplayName(service: ExternalServiceAccount): String { return when (service) { ExternalServiceAccount.NAVIDROME -> stringResource(R.string.cd_subsonic_logo) ExternalServiceAccount.JELLYFIN -> stringResource(R.string.auth_jellyfin_title) + ExternalServiceAccount.LISTENBRAINZ -> stringResource(R.string.accounts_listenbrainz_title) } } @@ -667,6 +724,12 @@ private fun openService( ) } } + ExternalServiceAccount.LISTENBRAINZ -> { + safeStartActivity( + context = context, + intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://listenbrainz.org")) + ) + } } } @@ -679,3 +742,207 @@ private fun safeStartActivity( Toast.makeText(context, context.getString(R.string.accounts_unable_open_screen), Toast.LENGTH_SHORT).show() } } + +@Composable +private fun AvailableServicesCard( + disconnectedServices: ImmutableList, + onConnect: (ExternalServiceAccount) -> Unit +) { + Card( + shape = AbsoluteSmoothCornerShape(28.dp, 60), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + disconnectedServices.forEach { service -> + ConnectServiceButton(service = service, onConnect = onConnect) + } + } + } +} + +@Composable +private fun ConnectServiceButton( + service: ExternalServiceAccount, + onConnect: (ExternalServiceAccount) -> Unit +) { + val connectTemplate = stringResource(R.string.presentation_batch_b_accounts_connect_service) + FilledTonalButton( + onClick = { onConnect(service) }, + shape = AbsoluteSmoothCornerShape(18.dp, 60), + colors = androidx.compose.material3.ButtonDefaults.filledTonalButtonColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + when (service) { + ExternalServiceAccount.JELLYFIN -> Icon( + painter = painterResource(R.drawable.ic_jellyfin), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + ExternalServiceAccount.NAVIDROME -> Icon( + painter = painterResource(R.drawable.ic_navidrome_md3), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + ExternalServiceAccount.LISTENBRAINZ -> Icon( + imageVector = Icons.Rounded.GraphicEq, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = connectTemplate.format(serviceDisplayName(service)) + ) + } +} + +@Composable +private fun ListenBrainzCardExtras( + model: ListenBrainzUiModel, + onToggleLocal: (Boolean) -> Unit, + onToggleNavidrome: (Boolean) -> Unit, + onToggleJellyfin: (Boolean) -> Unit, + onReconnect: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (model.needsReauth) { + Surface( + shape = AbsoluteSmoothCornerShape(14.dp, 60), + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = stringResource(R.string.accounts_listenbrainz_needs_reauth), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + TextButton(onClick = onReconnect) { + Text( + text = stringResource(R.string.accounts_listenbrainz_reconnect), + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + ScrobbleSourceToggleRow( + label = stringResource(R.string.accounts_listenbrainz_scrobble_local), + checked = model.scrobbleLocal, + onCheckedChange = onToggleLocal + ) + ScrobbleSourceToggleRow( + label = stringResource(R.string.accounts_listenbrainz_scrobble_navidrome), + checked = model.scrobbleNavidrome, + onCheckedChange = onToggleNavidrome + ) + ScrobbleSourceToggleRow( + label = stringResource(R.string.accounts_listenbrainz_scrobble_jellyfin), + checked = model.scrobbleJellyfin, + onCheckedChange = onToggleJellyfin + ) + Text( + text = stringResource(R.string.accounts_listenbrainz_toggle_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ScrobbleSourceToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@Composable +private fun ListenBrainzTokenDialog( + connectState: ListenBrainzConnectState, + onConnect: (String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var token by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.accounts_listenbrainz_token_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.accounts_listenbrainz_token_dialog_body), + style = MaterialTheme.typography.bodyMedium + ) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text(stringResource(R.string.accounts_listenbrainz_token_hint)) }, + singleLine = true, + isError = connectState == ListenBrainzConnectState.Failed, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth() + ) + if (connectState == ListenBrainzConnectState.Failed) { + Text( + text = stringResource(R.string.accounts_listenbrainz_connect_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + TextButton( + onClick = { + safeStartActivity( + context = context, + intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://listenbrainz.org/settings/")) + ) + } + ) { + Text(stringResource(R.string.accounts_listenbrainz_get_token)) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConnect(token) }, + enabled = token.isNotBlank() && connectState != ListenBrainzConnectState.Connecting + ) { + if (connectState == ListenBrainzConnectState.Connecting) { + LoadingIndicator(modifier = Modifier.size(16.dp)) + } else { + Text(stringResource(R.string.accounts_listenbrainz_connect)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt index 72ee4b81..cddb580d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt @@ -3,12 +3,15 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lostf1sh.pixelplayeross.data.jellyfin.JellyfinRepository +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzRepository import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository +import com.lostf1sh.pixelplayeross.data.preferences.ListenBrainzPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -20,7 +23,8 @@ import kotlinx.collections.immutable.toImmutableList enum class ExternalServiceAccount { NAVIDROME, - JELLYFIN + JELLYFIN, + LISTENBRAINZ } data class ExternalAccountUiModel( @@ -31,15 +35,34 @@ data class ExternalAccountUiModel( val isLoggingOut: Boolean ) +data class ListenBrainzUiModel( + val userName: String?, + val pendingCount: Int, + val needsReauth: Boolean, + val scrobbleLocal: Boolean, + val scrobbleNavidrome: Boolean, + val scrobbleJellyfin: Boolean +) + +sealed interface ListenBrainzConnectState { + data object Idle : ListenBrainzConnectState + data object Connecting : ListenBrainzConnectState + data object Success : ListenBrainzConnectState + data object Failed : ListenBrainzConnectState +} + data class AccountsUiState( val connectedAccounts: List = emptyList(), - val disconnectedServices: ImmutableList = persistentListOf() + val disconnectedServices: ImmutableList = persistentListOf(), + val listenBrainz: ListenBrainzUiModel? = null ) @HiltViewModel class AccountsViewModel @Inject constructor( private val navidromeRepository: NavidromeRepository, - private val jellyfinRepository: JellyfinRepository + private val jellyfinRepository: JellyfinRepository, + private val listenBrainzRepository: ListenBrainzRepository, + private val listenBrainzPreferences: ListenBrainzPreferencesRepository ) : ViewModel() { private val loggingOutServices = MutableStateFlow>(emptySet()) @@ -58,6 +81,32 @@ class AccountsViewModel @Inject constructor( connected to playlistCount } + private val listenBrainzStateFlow = combine( + listenBrainzRepository.accountState, + listenBrainzRepository.pendingListenCount, + listenBrainzPreferences.scrobbleLocalFlow, + listenBrainzPreferences.scrobbleNavidromeFlow, + listenBrainzPreferences.scrobbleJellyfinFlow + ) { account, pendingCount, local, navidrome, jellyfin -> + if (account.isConnected) { + ListenBrainzUiModel( + userName = account.userName, + pendingCount = pendingCount, + needsReauth = account.needsReauth, + scrobbleLocal = local, + scrobbleNavidrome = navidrome, + scrobbleJellyfin = jellyfin + ) + } else { + null + } + } + + private val _listenBrainzConnectState = + MutableStateFlow(ListenBrainzConnectState.Idle) + val listenBrainzConnectState: StateFlow = + _listenBrainzConnectState.asStateFlow() + val uiState: StateFlow = combine( combine( listOf( @@ -65,8 +114,9 @@ class AccountsViewModel @Inject constructor( jellyfinStateFlow ) ) { it.toList() }, + listenBrainzStateFlow, loggingOutServices - ) { states, activeLogouts -> + ) { states, listenBrainz, activeLogouts -> val (navidromeConnected, navidromePlaylistCount) = states[0] val (jellyfinConnected, jellyfinPlaylistCount) = states[1] @@ -105,16 +155,39 @@ class AccountsViewModel @Inject constructor( ) ) } + if (listenBrainz != null) { + add( + ExternalAccountUiModel( + service = ExternalServiceAccount.LISTENBRAINZ, + title = "ListenBrainz", + accountLabel = listenBrainz.userName + ?.takeIf { it.isNotBlank() } + ?: "ListenBrainz account connected", + syncedContentLabel = if (listenBrainz.pendingCount > 0) { + formatCount( + count = listenBrainz.pendingCount, + singular = "queued listen", + plural = "queued listens" + ) + } else { + "Scrobbling listens as you play" + }, + isLoggingOut = ExternalServiceAccount.LISTENBRAINZ in activeLogouts + ) + ) + } } val disconnectedServices = buildList { if (!navidromeConnected) add(ExternalServiceAccount.NAVIDROME) if (!jellyfinConnected) add(ExternalServiceAccount.JELLYFIN) + if (listenBrainz == null) add(ExternalServiceAccount.LISTENBRAINZ) } AccountsUiState( connectedAccounts = connectedAccounts, - disconnectedServices = disconnectedServices.toImmutableList() + disconnectedServices = disconnectedServices.toImmutableList(), + listenBrainz = listenBrainz ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountsUiState()) @@ -128,6 +201,7 @@ class AccountsViewModel @Inject constructor( when (service) { ExternalServiceAccount.NAVIDROME -> navidromeRepository.logout() ExternalServiceAccount.JELLYFIN -> jellyfinRepository.logout() + ExternalServiceAccount.LISTENBRAINZ -> listenBrainzRepository.disconnect() } } } finally { @@ -136,6 +210,35 @@ class AccountsViewModel @Inject constructor( } } + fun connectListenBrainz(token: String) { + if (_listenBrainzConnectState.value == ListenBrainzConnectState.Connecting) return + viewModelScope.launch { + _listenBrainzConnectState.value = ListenBrainzConnectState.Connecting + val result = listenBrainzRepository.connect(token) + _listenBrainzConnectState.value = if (result.isSuccess) { + ListenBrainzConnectState.Success + } else { + ListenBrainzConnectState.Failed + } + } + } + + fun resetListenBrainzConnectState() { + _listenBrainzConnectState.value = ListenBrainzConnectState.Idle + } + + fun setListenBrainzScrobbleLocal(enabled: Boolean) { + viewModelScope.launch { listenBrainzPreferences.setScrobbleLocal(enabled) } + } + + fun setListenBrainzScrobbleNavidrome(enabled: Boolean) { + viewModelScope.launch { listenBrainzPreferences.setScrobbleNavidrome(enabled) } + } + + fun setListenBrainzScrobbleJellyfin(enabled: Boolean) { + viewModelScope.launch { listenBrainzPreferences.setScrobbleJellyfin(enabled) } + } + private fun formatCount(count: Int, singular: String, plural: String): String { return if (count == 1) { "1 $singular" diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt index 85d1ebec..2eb38aab 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTracker.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import android.os.SystemClock import androidx.media3.common.C import com.lostf1sh.pixelplayeross.data.DailyMixManager +import com.lostf1sh.pixelplayeross.data.listenbrainz.ScrobbleManager import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.stats.PlaybackStatsRepository import kotlinx.coroutines.CoroutineScope @@ -31,7 +32,8 @@ import timber.log.Timber @Singleton class ListeningStatsTracker @Inject constructor( private val dailyMixManager: DailyMixManager, - private val playbackStatsRepository: PlaybackStatsRepository + private val playbackStatsRepository: PlaybackStatsRepository, + private val scrobbleManager: ScrobbleManager ) { private var currentSession: ActiveSession? = null private var pendingVoluntarySongId: String? = null @@ -123,17 +125,24 @@ class ListeningStatsTracker @Inject constructor( if (pendingVoluntarySongId == safeSongId) { pendingVoluntarySongId = null } + if (isPlaying) { + scrobbleManager.onPlayingNow(safeSongId) + } } @Synchronized fun onPlayStateChanged(isPlaying: Boolean, positionMs: Long) { val session = currentSession ?: return + val wasPlaying = session.isPlaying val nowRealtime = SystemClock.elapsedRealtime() accumulateRealtimeListening(session, nowRealtime) session.isPlaying = isPlaying session.lastRealtimeMs = nowRealtime session.lastKnownPositionMs = positionMs.coerceAtLeast(0L) session.lastUpdateEpochMs = System.currentTimeMillis() + if (!wasPlaying && isPlaying) { + scrobbleManager.onPlayingNow(session.songId) + } } @Synchronized @@ -193,6 +202,7 @@ class ListeningStatsTracker @Inject constructor( } val existing = currentSession if (existing?.songId == safeSongId) { + val wasPlaying = existing.isPlaying updateDuration(normalizeDuration(durationMs, fallbackDurationMs)) val nowRealtime = SystemClock.elapsedRealtime() accumulateRealtimeListening(existing, nowRealtime) @@ -200,6 +210,9 @@ class ListeningStatsTracker @Inject constructor( existing.lastRealtimeMs = nowRealtime existing.lastKnownPositionMs = positionMs.coerceAtLeast(0L) existing.lastUpdateEpochMs = System.currentTimeMillis() + if (!wasPlaying && isPlaying) { + scrobbleManager.onPlayingNow(existing.songId) + } return } onTrackChanged( @@ -226,6 +239,12 @@ class ListeningStatsTracker @Inject constructor( val nowEpoch = System.currentTimeMillis() accumulateRealtimeListening(session, nowRealtime) val listened = session.accumulatedListeningMs.coerceAtLeast(0L) + scrobbleManager.onSessionFinalized( + songId = session.songId, + startedAtEpochMs = session.startedAtEpochMs, + listenedMs = listened, + trackDurationMs = session.totalDurationMs + ) if (listened >= MIN_SESSION_LISTEN_MS) { val rawEndTimestamp = when { session.isPlaying -> nowEpoch diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0261b126..515a9a98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,20 @@ Error OK Cancel + ListenBrainz + Connect ListenBrainz + Paste your user token to scrobble your listens to ListenBrainz. You can find it in your settings on listenbrainz.org. + User token + Connect + Token was rejected. Check it and try again. + Open listenbrainz.org + Your token is no longer valid — reconnect to resume scrobbling. Queued listens are kept. + Reconnect + Scrobble local files + Scrobble Subsonic streaming + Scrobble Jellyfin streaming + If your server already scrobbles to ListenBrainz, turn that source off here to avoid duplicate listens. + Available services Import Search diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 6093f3bb..af308b08 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,6 +6,9 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 5690ad26..02f3f7b0 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -7,6 +7,9 @@ + + + @@ -18,6 +21,9 @@ + + + diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManagerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManagerTest.kt new file mode 100644 index 00000000..456fea10 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ScrobbleManagerTest.kt @@ -0,0 +1,40 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ScrobbleManagerTest { + + @Test + fun `long track requires four minutes`() { + val tenMinutes = 600_000L + assertFalse(ScrobbleManager.meetsListenThreshold(listenedMs = 239_999L, trackDurationMs = tenMinutes)) + assertTrue(ScrobbleManager.meetsListenThreshold(listenedMs = 240_000L, trackDurationMs = tenMinutes)) + } + + @Test + fun `short track requires half its duration`() { + val threeMinutes = 180_000L + assertFalse(ScrobbleManager.meetsListenThreshold(listenedMs = 89_999L, trackDurationMs = threeMinutes)) + assertTrue(ScrobbleManager.meetsListenThreshold(listenedMs = 90_000L, trackDurationMs = threeMinutes)) + } + + @Test + fun `eight minute track still needs only four minutes`() { + val eightMinutes = 480_000L + assertTrue(ScrobbleManager.meetsListenThreshold(listenedMs = 240_000L, trackDurationMs = eightMinutes)) + } + + @Test + fun `unknown duration falls back to four minute rule`() { + assertFalse(ScrobbleManager.meetsListenThreshold(listenedMs = 239_999L, trackDurationMs = 0L)) + assertTrue(ScrobbleManager.meetsListenThreshold(listenedMs = 240_000L, trackDurationMs = 0L)) + assertFalse(ScrobbleManager.meetsListenThreshold(listenedMs = 200_000L, trackDurationMs = -1L)) + } + + @Test + fun `zero listening never scrobbles`() { + assertFalse(ScrobbleManager.meetsListenThreshold(listenedMs = 0L, trackDurationMs = 180_000L)) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTrackerTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTrackerTest.kt index 1b09437c..ba1a9fa9 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTrackerTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ListeningStatsTrackerTest.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import android.os.SystemClock import com.google.common.truth.Truth.assertThat import com.lostf1sh.pixelplayeross.data.DailyMixManager +import com.lostf1sh.pixelplayeross.data.listenbrainz.ScrobbleManager import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.stats.PlaybackStatsRepository import io.mockk.coVerify @@ -19,6 +20,7 @@ class ListeningStatsTrackerTest { private val dailyMixManager: DailyMixManager = mockk(relaxed = true) private val playbackStatsRepository: PlaybackStatsRepository = mockk(relaxed = true) + private val scrobbleManager: ScrobbleManager = mockk(relaxed = true) @BeforeEach fun setUp() { @@ -34,7 +36,8 @@ class ListeningStatsTrackerTest { fun `finalizeCurrentSession preserves listening longer than track duration`() { val tracker = ListeningStatsTracker( dailyMixManager = dailyMixManager, - playbackStatsRepository = playbackStatsRepository + playbackStatsRepository = playbackStatsRepository, + scrobbleManager = scrobbleManager ) val song = song( songId = "looped-song", @@ -67,7 +70,8 @@ class ListeningStatsTrackerTest { fun `onProgress accumulates incremental listening time`() { val tracker = ListeningStatsTracker( dailyMixManager = dailyMixManager, - playbackStatsRepository = playbackStatsRepository + playbackStatsRepository = playbackStatsRepository, + scrobbleManager = scrobbleManager ) val song = song(songId = "song-1") val firstChunkMs = 7_000L diff --git a/docs/FDROID.md b/docs/FDROID.md index d1e191c4..c203eeba 100644 --- a/docs/FDROID.md +++ b/docs/FDROID.md @@ -65,7 +65,10 @@ These properties got the app accepted and must be maintained: 4. Optional network services documented in `PRIVACY.md`. 5. Release builds left unsigned when local signing keys are absent. 6. Store metadata in Fastlane format. -7. No ListenBrainz or Last.fm public scrobbling integration. +7. ListenBrainz scrobbling is strictly opt-in and disabled by default: nothing is submitted + until the user connects their own ListenBrainz token, and disconnecting deletes queued + listens. Last.fm remains unsupported. The data sent while opted in is documented in + `PRIVACY.md`. ## Asset Licenses From 32e280339640b2d5143b74da70da2ac027498ff5 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 30 Jul 2026 23:23:16 +0300 Subject: [PATCH 2/3] Support custom ListenBrainz-compatible server URLs Issue #55 explicitly asks for a custom endpoint so self-hosted servers like Maloja can receive scrobbles. Route every ListenBrainz request through an endpoint holder that re-roots the API path under a stored custom base URL (path prefixes like Maloja's /apis/listenbrainz are preserved), validate the token against the entered server on connect, and persist the URL alongside the token. The Accounts dialog gains an optional server URL field with its own validation error. --- CHANGELOG.md | 2 +- PRIVACY.md | 2 +- .../data/listenbrainz/ListenBrainzEndpoint.kt | 53 ++++++++++++++ .../data/listenbrainz/ListenBrainzModels.kt | 4 +- .../listenbrainz/ListenBrainzRepository.kt | 48 +++++++++++-- .../pixelplayeross/di/NetworkModule.kt | 27 +++++--- .../presentation/screens/AccountsScreen.kt | 39 +++++++++-- .../viewmodel/AccountsViewModel.kt | 13 ++-- app/src/main/res/values/strings.xml | 6 +- .../listenbrainz/ListenBrainzEndpointTest.kt | 69 +++++++++++++++++++ 10 files changed, 236 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpoint.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpointTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e60f4dc..01b7615f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to PixelPlayerOSS will be documented in this file. ## [Unreleased] ### Added -- Optional ListenBrainz scrobbling, disabled by default. Connect a ListenBrainz account with a user token from the Accounts screen; listens that reach the ListenBrainz threshold (4 minutes or half the track, whichever is lower) queue offline and submit with retry, with per-source toggles for local files, Subsonic, and Jellyfin playback. Now-playing status is reported while scrobbling is enabled, and disconnecting deletes any queued listens. +- Optional ListenBrainz scrobbling, disabled by default. Connect a ListenBrainz account with a user token from the Accounts screen; listens that reach the ListenBrainz threshold (4 minutes or half the track, whichever is lower) queue offline and submit with retry, with per-source toggles for local files, Subsonic, and Jellyfin playback. Now-playing status is reported while scrobbling is enabled, and disconnecting deletes any queued listens. An optional custom server URL scrobbles to self-hosted ListenBrainz-compatible servers such as Maloja instead of listenbrainz.org. - MusicBrainz identifier columns in the library database as groundwork for tag lookup. ## [0.1.0] - 2026-06-09 diff --git a/PRIVACY.md b/PRIVACY.md index 73cfc942..45e6af6f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -13,7 +13,7 @@ Network features are optional and user-controlled: - Navidrome/Subsonic and Jellyfin are used only after the user signs in to a self-hosted server. Those servers may receive the authentication, library, playback state, and play history requests needed for their protocols. - LRCLIB lyric lookup is used only when online lyrics are enabled. - Deezer artist artwork lookup is used only when online artist images are enabled. -- ListenBrainz scrobbling is optional and disabled by default. It activates only after the user connects a ListenBrainz account with their own user token. While connected, the app submits listening activity (track title, artist, album, duration, listen timestamps, and MusicBrainz identifiers when available) to ListenBrainz for the playback sources the user has enabled; per-source toggles cover local files, Navidrome/Subsonic, and Jellyfin playback. Disconnecting stops submissions and deletes any queued listens. Last.fm is not supported. +- ListenBrainz scrobbling is optional and disabled by default. It activates only after the user connects a ListenBrainz account with their own user token. While connected, the app submits listening activity (track title, artist, album, duration, listen timestamps, and MusicBrainz identifiers when available) to the configured ListenBrainz server for the playback sources the user has enabled — listenbrainz.org by default, or a user-supplied custom URL for self-hosted ListenBrainz-compatible servers such as Maloja; per-source toggles cover local files, Navidrome/Subsonic, and Jellyfin playback. Disconnecting stops submissions and deletes any queued listens. Last.fm is not supported. Server credentials and preferences are stored locally. The app does not sell or share user data. diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpoint.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpoint.kt new file mode 100644 index 00000000..449857f6 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpoint.kt @@ -0,0 +1,53 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Holds the ListenBrainz-compatible API root all requests are routed to. + * + * The Retrofit stack is built once against the official base URL; a client + * interceptor re-roots every request through [rewrite], so a self-hosted + * ListenBrainz or Maloja endpoint applies without rebuilding the network stack. + */ +@Singleton +class ListenBrainzEndpoint @Inject constructor() { + + @Volatile + var customBaseUrl: HttpUrl? = null + private set + + /** Null routes requests to the official endpoint. */ + fun setCustom(baseUrl: HttpUrl?) { + customBaseUrl = baseUrl + } + + /** Re-roots [requestUrl] under the custom base, keeping the API path and query. */ + fun rewrite(requestUrl: HttpUrl): HttpUrl { + val base = customBaseUrl ?: return requestUrl + val resolved = base.resolve(requestUrl.encodedPath.removePrefix("/")) + ?: return requestUrl + return resolved.newBuilder() + .encodedQuery(requestUrl.encodedQuery) + .build() + } + + companion object { + const val DEFAULT_BASE_URL = "https://api.listenbrainz.org/" + + /** + * Normalizes user input into an API root: defaults the scheme to https and + * guarantees a trailing slash so relative API paths append to a path prefix + * (Maloja serves the ListenBrainz API under `/apis/listenbrainz/`) instead + * of replacing it. Returns null when the input is not a usable http(s) URL. + */ + fun parseBaseUrl(input: String): HttpUrl? { + val trimmed = input.trim().trimEnd('/') + if (trimmed.isEmpty()) return null + val withScheme = if ("://" in trimmed) trimmed else "https://$trimmed" + return "$withScheme/".toHttpUrlOrNull() + } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt index 52871677..fc008c9f 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt @@ -47,7 +47,9 @@ data class ListenBrainzAccountState( val isConnected: Boolean = false, val userName: String? = null, /** True when the stored token was rejected (HTTP 401); flushing is paused until reconnect. */ - val needsReauth: Boolean = false + val needsReauth: Boolean = false, + /** Custom ListenBrainz-compatible API root; null means the official endpoint. */ + val serverUrl: String? = null ) sealed interface ListenBrainzSubmitResult { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt index d942d9d1..17c180f2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt @@ -21,6 +21,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** Connecting failed because the entered server URL is not a usable http(s) URL. */ +class InvalidServerUrlException : IllegalArgumentException("Invalid ListenBrainz server URL") + /** * Owns the ListenBrainz account (user token in EncryptedSharedPreferences, mirroring the * Navidrome credential pattern) and all API submission paths. @@ -32,6 +35,7 @@ class ListenBrainzRepository @Inject constructor( private val api: ListenBrainzApiService, private val listenBrainzDao: ListenBrainzDao, private val workManager: WorkManager, + private val endpoint: ListenBrainzEndpoint, @ApplicationContext private val context: Context ) { companion object { @@ -40,6 +44,7 @@ class ListenBrainzRepository @Inject constructor( private const val KEY_TOKEN = "token" private const val KEY_USER_NAME = "user_name" private const val KEY_AUTH_INVALID = "auth_invalid" + private const val KEY_SERVER_URL = "server_url" private const val SUBMISSION_CLIENT = "PixelPlayerOSS" private const val MAX_QUEUE_SIZE = 3000 @@ -57,11 +62,18 @@ class ListenBrainzRepository @Inject constructor( ListenBrainzAccountState( isConnected = cachedToken != null, userName = prefs.getString(KEY_USER_NAME, null), - needsReauth = prefs.getBoolean(KEY_AUTH_INVALID, false) + needsReauth = prefs.getBoolean(KEY_AUTH_INVALID, false), + serverUrl = prefs.getString(KEY_SERVER_URL, null) ) ) val accountState: StateFlow = _accountState.asStateFlow() + init { + endpoint.setCustom( + prefs.getString(KEY_SERVER_URL, null)?.let(ListenBrainzEndpoint::parseBaseUrl) + ) + } + val pendingListenCount = listenBrainzDao.countFlow() private fun createEncryptedPrefs(): SharedPreferences { @@ -116,35 +128,59 @@ class ListenBrainzRepository @Inject constructor( } /** - * Validates [token] against the API and stores it on success. - * Returns the ListenBrainz user name on success. + * Validates [token] against the chosen server and stores both on success. + * A blank [serverUrl] means the official listenbrainz.org endpoint; anything else + * must parse into an http(s) URL and may point at any ListenBrainz-compatible + * server (self-hosted ListenBrainz, Maloja, …). Returns the user name on success. */ - suspend fun connect(token: String): Result { + suspend fun connect(token: String, serverUrl: String? = null): Result { val trimmed = token.trim() if (trimmed.isEmpty()) return Result.failure(IllegalArgumentException("Empty token")) + + val trimmedUrl = serverUrl?.trim().orEmpty() + val customBase = if (trimmedUrl.isEmpty()) { + null + } else { + ListenBrainzEndpoint.parseBaseUrl(trimmedUrl) + ?: return Result.failure(InvalidServerUrlException()) + } + + val previousBase = endpoint.customBaseUrl + endpoint.setCustom(customBase) return try { val response = api.validateToken(authHeader(trimmed)) val body = response.body() if (response.isSuccessful && body?.valid == true) { + // Maloja's validate-token replies without a user name; fall back to + // an empty name rather than treating the account as invalid. val userName = body.userName.orEmpty() + val storedUrl = customBase?.toString() prefs.edit { putString(KEY_TOKEN, trimmed) putString(KEY_USER_NAME, userName) putBoolean(KEY_AUTH_INVALID, false) + if (storedUrl != null) { + putString(KEY_SERVER_URL, storedUrl) + } else { + remove(KEY_SERVER_URL) + } } cachedToken = trimmed _accountState.value = ListenBrainzAccountState( isConnected = true, userName = userName, - needsReauth = false + needsReauth = false, + serverUrl = storedUrl ) scheduleFlush() Result.success(userName) } else { + endpoint.setCustom(previousBase) Result.failure(IllegalStateException("Token rejected by ListenBrainz")) } } catch (e: Exception) { Timber.e(e, "$TAG: Token validation failed") + endpoint.setCustom(previousBase) Result.failure(e) } } @@ -156,8 +192,10 @@ class ListenBrainzRepository @Inject constructor( remove(KEY_TOKEN) remove(KEY_USER_NAME) remove(KEY_AUTH_INVALID) + remove(KEY_SERVER_URL) } cachedToken = null + endpoint.setCustom(null) _accountState.value = ListenBrainzAccountState() listenBrainzDao.clear() } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt index d045dd17..b83dd07e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.lostf1sh.pixelplayeross.di import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzApiService +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzEndpoint import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,19 +15,27 @@ import retrofit2.converter.gson.GsonConverterFactory @InstallIn(SingletonComponent::class) object NetworkModule { - /** - * Base URL is provided in exactly one place so a user-configurable endpoint - * (self-hosted ListenBrainz / Maloja) stays a trivial follow-up. - */ - private const val LISTENBRAINZ_BASE_URL = "https://api.listenbrainz.org/" - @Provides @Singleton @ListenBrainzRetrofit - fun provideListenBrainzRetrofit(okHttpClient: OkHttpClient): Retrofit { + fun provideListenBrainzRetrofit( + okHttpClient: OkHttpClient, + endpoint: ListenBrainzEndpoint + ): Retrofit { + val client = okHttpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + val rewritten = endpoint.rewrite(request.url) + if (rewritten == request.url) { + chain.proceed(request) + } else { + chain.proceed(request.newBuilder().url(rewritten).build()) + } + } + .build() return Retrofit.Builder() - .baseUrl(LISTENBRAINZ_BASE_URL) - .client(okHttpClient) + .baseUrl(ListenBrainzEndpoint.DEFAULT_BASE_URL) + .client(client) .addConverterFactory(GsonConverterFactory.create()) .build() } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt index 98b1d00d..82eeca94 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt @@ -300,6 +300,7 @@ fun AccountsScreen( if (showListenBrainzDialog) { ListenBrainzTokenDialog( connectState = listenBrainzConnectState, + initialServerUrl = uiState.listenBrainz?.serverUrl, onConnect = viewModel::connectListenBrainz, onDismiss = { showListenBrainzDialog = false @@ -854,6 +855,13 @@ private fun ListenBrainzCardExtras( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + model.serverUrl?.let { serverUrl -> + Text( + text = stringResource(R.string.accounts_listenbrainz_custom_server, serverUrl), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } @@ -883,11 +891,14 @@ private fun ScrobbleSourceToggleRow( @Composable private fun ListenBrainzTokenDialog( connectState: ListenBrainzConnectState, - onConnect: (String) -> Unit, + initialServerUrl: String?, + onConnect: (String, String) -> Unit, onDismiss: () -> Unit ) { val context = LocalContext.current var token by remember { mutableStateOf("") } + var serverUrl by remember { mutableStateOf(initialServerUrl.orEmpty()) } + val failedState = connectState as? ListenBrainzConnectState.Failed AlertDialog( onDismissRequest = onDismiss, @@ -903,14 +914,32 @@ private fun ListenBrainzTokenDialog( onValueChange = { token = it }, label = { Text(stringResource(R.string.accounts_listenbrainz_token_hint)) }, singleLine = true, - isError = connectState == ListenBrainzConnectState.Failed, + isError = failedState != null && !failedState.invalidUrl, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth() ) - if (connectState == ListenBrainzConnectState.Failed) { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text(stringResource(R.string.accounts_listenbrainz_server_hint)) }, + supportingText = { + Text(stringResource(R.string.accounts_listenbrainz_server_supporting)) + }, + singleLine = true, + isError = failedState?.invalidUrl == true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + if (failedState != null) { Text( - text = stringResource(R.string.accounts_listenbrainz_connect_failed), + text = stringResource( + if (failedState.invalidUrl) { + R.string.accounts_listenbrainz_invalid_url + } else { + R.string.accounts_listenbrainz_connect_failed + } + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -929,7 +958,7 @@ private fun ListenBrainzTokenDialog( }, confirmButton = { TextButton( - onClick = { onConnect(token) }, + onClick = { onConnect(token, serverUrl) }, enabled = token.isNotBlank() && connectState != ListenBrainzConnectState.Connecting ) { if (connectState == ListenBrainzConnectState.Connecting) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt index cddb580d..9928683a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt @@ -3,6 +3,7 @@ package com.lostf1sh.pixelplayeross.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lostf1sh.pixelplayeross.data.jellyfin.JellyfinRepository +import com.lostf1sh.pixelplayeross.data.listenbrainz.InvalidServerUrlException import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzRepository import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository import com.lostf1sh.pixelplayeross.data.preferences.ListenBrainzPreferencesRepository @@ -39,6 +40,7 @@ data class ListenBrainzUiModel( val userName: String?, val pendingCount: Int, val needsReauth: Boolean, + val serverUrl: String?, val scrobbleLocal: Boolean, val scrobbleNavidrome: Boolean, val scrobbleJellyfin: Boolean @@ -48,7 +50,7 @@ sealed interface ListenBrainzConnectState { data object Idle : ListenBrainzConnectState data object Connecting : ListenBrainzConnectState data object Success : ListenBrainzConnectState - data object Failed : ListenBrainzConnectState + data class Failed(val invalidUrl: Boolean = false) : ListenBrainzConnectState } data class AccountsUiState( @@ -93,6 +95,7 @@ class AccountsViewModel @Inject constructor( userName = account.userName, pendingCount = pendingCount, needsReauth = account.needsReauth, + serverUrl = account.serverUrl, scrobbleLocal = local, scrobbleNavidrome = navidrome, scrobbleJellyfin = jellyfin @@ -210,15 +213,17 @@ class AccountsViewModel @Inject constructor( } } - fun connectListenBrainz(token: String) { + fun connectListenBrainz(token: String, serverUrl: String) { if (_listenBrainzConnectState.value == ListenBrainzConnectState.Connecting) return viewModelScope.launch { _listenBrainzConnectState.value = ListenBrainzConnectState.Connecting - val result = listenBrainzRepository.connect(token) + val result = listenBrainzRepository.connect(token, serverUrl) _listenBrainzConnectState.value = if (result.isSuccess) { ListenBrainzConnectState.Success } else { - ListenBrainzConnectState.Failed + ListenBrainzConnectState.Failed( + invalidUrl = result.exceptionOrNull() is InvalidServerUrlException + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 515a9a98..503dcbfa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,7 +15,11 @@ Paste your user token to scrobble your listens to ListenBrainz. You can find it in your settings on listenbrainz.org. User token Connect - Token was rejected. Check it and try again. + Couldn\'t connect. Check your token and server URL, then try again. + Server URL (optional) + Leave empty for listenbrainz.org. Works with self-hosted servers that speak the ListenBrainz API, such as Maloja. + Enter a valid server URL, like https://maloja.example.com/apis/listenbrainz + Scrobbling to %1$s Open listenbrainz.org Your token is no longer valid — reconnect to resume scrobbling. Queued listens are kept. Reconnect diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpointTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpointTest.kt new file mode 100644 index 00000000..5b1e506c --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzEndpointTest.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Test + +class ListenBrainzEndpointTest { + + @Test + fun parseBaseUrl_defaultsSchemeToHttpsAndAppendsSlash() { + val parsed = ListenBrainzEndpoint.parseBaseUrl("listen.example.com") + + assertThat(parsed.toString()).isEqualTo("https://listen.example.com/") + } + + @Test + fun parseBaseUrl_keepsPathPrefixAndExplicitScheme() { + val parsed = ListenBrainzEndpoint.parseBaseUrl("http://maloja.example.com/apis/listenbrainz/") + + assertThat(parsed.toString()).isEqualTo("http://maloja.example.com/apis/listenbrainz/") + } + + @Test + fun parseBaseUrl_rejectsNonHttpSchemesAndGarbage() { + assertThat(ListenBrainzEndpoint.parseBaseUrl("ftp://example.com")).isNull() + assertThat(ListenBrainzEndpoint.parseBaseUrl("not a url")).isNull() + assertThat(ListenBrainzEndpoint.parseBaseUrl(" ")).isNull() + } + + @Test + fun rewrite_withoutCustomBaseLeavesRequestUntouched() { + val endpoint = ListenBrainzEndpoint() + val request = "https://api.listenbrainz.org/1/submit-listens".toHttpUrl() + + assertThat(endpoint.rewrite(request)).isEqualTo(request) + } + + @Test + fun rewrite_rerootsApiPathUnderCustomBaseWithPathPrefix() { + val endpoint = ListenBrainzEndpoint() + endpoint.setCustom(ListenBrainzEndpoint.parseBaseUrl("https://maloja.example.com/apis/listenbrainz")) + + val rewritten = endpoint.rewrite("https://api.listenbrainz.org/1/submit-listens".toHttpUrl()) + + assertThat(rewritten.toString()) + .isEqualTo("https://maloja.example.com/apis/listenbrainz/1/submit-listens") + } + + @Test + fun rewrite_preservesQueryParameters() { + val endpoint = ListenBrainzEndpoint() + endpoint.setCustom(ListenBrainzEndpoint.parseBaseUrl("https://listen.example.com")) + + val rewritten = endpoint.rewrite("https://api.listenbrainz.org/1/validate-token?token=abc".toHttpUrl()) + + assertThat(rewritten.toString()) + .isEqualTo("https://listen.example.com/1/validate-token?token=abc") + } + + @Test + fun rewrite_clearedCustomBaseRoutesBackToOfficialEndpoint() { + val endpoint = ListenBrainzEndpoint() + endpoint.setCustom(ListenBrainzEndpoint.parseBaseUrl("https://listen.example.com")) + endpoint.setCustom(null) + val request = "https://api.listenbrainz.org/1/submit-listens".toHttpUrl() + + assertThat(endpoint.rewrite(request)).isEqualTo(request) + } +} From 4f78875869f65503899d502cd31738a65b8f148b Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Fri, 31 Jul 2026 12:50:42 +0300 Subject: [PATCH 3/3] Show live ListenBrainz stats on the accounts card and drop the hero card The Accounts page opens straight into linked services now that the Connected Accounts hero and its stat tiles are gone. The ListenBrainz card instead shows the account's total listen count and a server- confirmed "Scrobbling now" row polled from playing-now while the screen is open, with a shimmer skeleton during the first fetch. Both degrade gracefully on ListenBrainz-compatible servers that only implement submission, like Maloja. --- .../listenbrainz/ListenBrainzApiService.kt | 13 + .../data/listenbrainz/ListenBrainzModels.kt | 44 +++ .../listenbrainz/ListenBrainzRepository.kt | 21 ++ .../presentation/screens/AccountsScreen.kt | 259 ++++++++++++++---- .../viewmodel/AccountsViewModel.kt | 65 ++++- app/src/main/res/values/strings.xml | 5 + .../values/strings_presentation_batch_b.xml | 4 - .../ListenBrainzProfileStatsTest.kt | 63 +++++ 8 files changed, 406 insertions(+), 68 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzProfileStatsTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt index 8be69b4f..5ffbb958 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzApiService.kt @@ -5,6 +5,7 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.Path /** * Retrofit interface for the ListenBrainz API. @@ -22,4 +23,16 @@ interface ListenBrainzApiService { suspend fun validateToken( @Header("Authorization") authorization: String ): Response + + @GET("1/user/{userName}/listen-count") + suspend fun getListenCount( + @Header("Authorization") authorization: String, + @Path("userName") userName: String + ): Response + + @GET("1/user/{userName}/playing-now") + suspend fun getPlayingNow( + @Header("Authorization") authorization: String, + @Path("userName") userName: String + ): Response } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt index fc008c9f..4469877c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzModels.kt @@ -42,6 +42,50 @@ data class ListenBrainzTokenValidation( @SerializedName("user_name") val userName: String? = null ) +/** Response of `GET /1/user/{user}/listen-count`. */ +data class ListenBrainzListenCountResponse( + @SerializedName("payload") val payload: ListenBrainzListenCountPayload? = null +) + +data class ListenBrainzListenCountPayload( + @SerializedName("count") val count: Long = 0 +) + +/** Response of `GET /1/user/{user}/playing-now`. */ +data class ListenBrainzPlayingNowResponse( + @SerializedName("payload") val payload: ListenBrainzPlayingNowPayload? = null +) + +data class ListenBrainzPlayingNowPayload( + @SerializedName("listens") val listens: List = emptyList() +) + +/** + * Profile numbers shown on the Accounts screen. Null [listenCount] or a false + * [playingNowAvailable] means the connected server doesn't expose that endpoint + * (Maloja's ListenBrainz shim only implements submission), not an error state. + */ +data class ListenBrainzProfileStats( + val listenCount: Long? = null, + val playingNowAvailable: Boolean = false, + val nowPlayingTrack: String? = null, + val nowPlayingArtist: String? = null +) + +internal fun buildProfileStats( + listenCount: Long?, + playingNowAvailable: Boolean, + nowPlaying: ListenBrainzTrackMetadata? +): ListenBrainzProfileStats? { + if (listenCount == null && !playingNowAvailable) return null + return ListenBrainzProfileStats( + listenCount = listenCount, + playingNowAvailable = playingNowAvailable, + nowPlayingTrack = nowPlaying?.trackName, + nowPlayingArtist = nowPlaying?.artistName + ) +} + /** Connection state surfaced on the Accounts screen. */ data class ListenBrainzAccountState( val isConnected: Boolean = false, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt index 17c180f2..ca2c2243 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzRepository.kt @@ -201,6 +201,27 @@ class ListenBrainzRepository @Inject constructor( } } + /** + * Reads the connected account's public profile numbers. Returns null when the account has no + * user name (Maloja replies without one) or the server exposes neither endpoint, so the UI + * hides the stats instead of surfacing an error. + */ + suspend fun fetchProfileStats(): ListenBrainzProfileStats? { + val token = cachedToken ?: return null + val userName = _accountState.value.userName?.takeIf { it.isNotBlank() } ?: return null + val auth = authHeader(token) + val listenCount = runCatching { + api.getListenCount(auth, userName).takeIf { it.isSuccessful }?.body()?.payload?.count + }.getOrNull() + val playingNowResponse = runCatching { api.getPlayingNow(auth, userName) }.getOrNull() + return buildProfileStats( + listenCount = listenCount, + playingNowAvailable = playingNowResponse?.isSuccessful == true, + nowPlaying = playingNowResponse?.takeIf { it.isSuccessful } + ?.body()?.payload?.listens?.firstOrNull()?.trackMetadata + ) + } + suspend fun submitListens(listens: List): ListenBrainzSubmitResult { val token = cachedToken ?: return ListenBrainzSubmitResult.AuthFailed val submission = ListenBrainzSubmission( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt index 82eeca94..166656d5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/AccountsScreen.kt @@ -6,9 +6,16 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,6 +61,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,8 +70,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource @@ -81,6 +94,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.lostf1sh.pixelplayeross.R +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzProfileStats import com.lostf1sh.pixelplayeross.presentation.components.CollapsibleCommonTopBar import com.lostf1sh.pixelplayeross.presentation.components.MiniPlayerHeight import com.lostf1sh.pixelplayeross.presentation.jellyfin.auth.JellyfinLoginActivity @@ -89,7 +103,9 @@ import com.lostf1sh.pixelplayeross.presentation.viewmodel.AccountsViewModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.ExternalAccountUiModel import com.lostf1sh.pixelplayeross.presentation.viewmodel.ExternalServiceAccount import com.lostf1sh.pixelplayeross.presentation.viewmodel.ListenBrainzConnectState +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ListenBrainzStatsUiState import com.lostf1sh.pixelplayeross.presentation.viewmodel.ListenBrainzUiModel +import java.text.NumberFormat import kotlin.math.roundToInt import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -206,13 +222,6 @@ fun AccountsScreen( ), verticalArrangement = Arrangement.spacedBy(14.dp) ) { - item { - AccountsHeroSection( - connectedCount = uiState.connectedAccounts.size, - disconnectedCount = uiState.disconnectedServices.size - ) - } - if (uiState.connectedAccounts.isNotEmpty()) { item { Text( @@ -312,57 +321,7 @@ fun AccountsScreen( } @Composable -private fun AccountsHeroSection( - connectedCount: Int, - disconnectedCount: Int -) { - val connectedHeroTitle = stringResource(R.string.presentation_batch_b_accounts_connected_hero_title) - val connectedHeroBody = stringResource(R.string.presentation_batch_b_accounts_connected_hero_body) - val statActive = stringResource(R.string.presentation_batch_b_accounts_stat_active) - val statAvailable = stringResource(R.string.presentation_batch_b_accounts_stat_available) - val sectionShape = AbsoluteSmoothCornerShape(30.dp, 60) - Card( - shape = sectionShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = connectedHeroTitle, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = connectedHeroBody, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - HeroStatTile( - title = statActive, - value = connectedCount.toString(), - modifier = Modifier.weight(1f) - ) - HeroStatTile( - title = statAvailable, - value = (connectedCount + disconnectedCount).toString(), - modifier = Modifier.weight(1f) - ) - } - } - } -} - -@Composable -private fun HeroStatTile( +private fun StatTile( title: String, value: String, modifier: Modifier = Modifier @@ -813,7 +772,19 @@ private fun ListenBrainzCardExtras( onToggleJellyfin: (Boolean) -> Unit, onReconnect: () -> Unit ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (model.statsState != ListenBrainzStatsUiState.Unavailable) { + Crossfade(targetState = model.statsState, label = "listenBrainzStats") { state -> + when (state) { + ListenBrainzStatsUiState.Loading -> ListenBrainzStatsSkeleton() + is ListenBrainzStatsUiState.Loaded -> ListenBrainzStatsContent( + stats = state.stats, + pendingCount = model.pendingCount + ) + ListenBrainzStatsUiState.Unavailable -> {} + } + } + } if (model.needsReauth) { Surface( shape = AbsoluteSmoothCornerShape(14.dp, 60), @@ -865,6 +836,176 @@ private fun ListenBrainzCardExtras( } } +@Composable +private fun ListenBrainzStatsContent( + stats: ListenBrainzProfileStats, + pendingCount: Int +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + stats.listenCount?.let { count -> + StatTile( + title = stringResource(R.string.accounts_listenbrainz_stat_listens), + value = NumberFormat.getIntegerInstance().format(count), + modifier = Modifier.weight(1f) + ) + } + StatTile( + title = stringResource(R.string.accounts_listenbrainz_stat_queued), + value = pendingCount.toString(), + modifier = Modifier.weight(1f) + ) + } + if (stats.playingNowAvailable) { + ListenBrainzNowPlayingRow( + track = stats.nowPlayingTrack, + artist = stats.nowPlayingArtist + ) + } + } +} + +@Composable +private fun ListenBrainzStatsSkeleton() { + val shimmer = rememberInfiniteTransition(label = "lbSkeleton") + val progress = shimmer.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)), + label = "lbSkeletonSweep" + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SkeletonBlock( + progress = progress, + shape = AbsoluteSmoothCornerShape(18.dp, 60), + modifier = Modifier.weight(1f).height(70.dp) + ) + SkeletonBlock( + progress = progress, + shape = AbsoluteSmoothCornerShape(18.dp, 60), + modifier = Modifier.weight(1f).height(70.dp) + ) + } + SkeletonBlock( + progress = progress, + shape = AbsoluteSmoothCornerShape(14.dp, 60), + modifier = Modifier.fillMaxWidth().height(44.dp) + ) + } +} + +@Composable +private fun SkeletonBlock( + progress: State, + shape: Shape, + modifier: Modifier = Modifier +) { + val baseColor = MaterialTheme.colorScheme.surfaceContainerLow + val highlightColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + Box( + modifier = modifier + .clip(shape) + .drawBehind { + drawRect(baseColor) + val sweepCenter = size.width * (progress.value * 2f - 0.5f) + val sweepRadius = size.width * 0.35f + drawRect( + brush = Brush.linearGradient( + colors = listOf(Color.Transparent, highlightColor, Color.Transparent), + start = Offset(sweepCenter - sweepRadius, 0f), + end = Offset(sweepCenter + sweepRadius, size.height) + ) + ) + } + ) +} + +@Composable +private fun ListenBrainzNowPlayingRow( + track: String?, + artist: String? +) { + val isPlaying = track != null + val containerColor = if (isPlaying) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerLow + } + val contentColor = if (isPlaying) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + shape = AbsoluteSmoothCornerShape(14.dp, 60), + color = containerColor, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val iconModifier = if (isPlaying) { + val pulse = rememberInfiniteTransition(label = "scrobblePulse") + val pulseAlpha by pulse.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse), + label = "scrobblePulseAlpha" + ) + Modifier.size(16.dp).graphicsLayer { alpha = pulseAlpha } + } else { + Modifier.size(16.dp) + } + Icon( + imageVector = Icons.Rounded.GraphicEq, + contentDescription = null, + tint = contentColor, + modifier = iconModifier + ) + Spacer(modifier = Modifier.size(8.dp)) + if (isPlaying) { + Column { + Text( + text = stringResource(R.string.accounts_listenbrainz_now_playing_label), + style = MaterialTheme.typography.labelSmall, + color = contentColor + ) + Text( + text = if (artist.isNullOrBlank()) { + track.orEmpty() + } else { + stringResource( + R.string.accounts_listenbrainz_now_playing_format, + track.orEmpty(), + artist + ) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Text( + text = stringResource(R.string.accounts_listenbrainz_now_playing_idle), + style = MaterialTheme.typography.bodyMedium, + color = contentColor + ) + } + } + } +} + @Composable private fun ScrobbleSourceToggleRow( label: String, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt index 9928683a..0869a762 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/AccountsViewModel.kt @@ -4,16 +4,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lostf1sh.pixelplayeross.data.jellyfin.JellyfinRepository import com.lostf1sh.pixelplayeross.data.listenbrainz.InvalidServerUrlException +import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzProfileStats import com.lostf1sh.pixelplayeross.data.listenbrainz.ListenBrainzRepository import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository import com.lostf1sh.pixelplayeross.data.preferences.ListenBrainzPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -22,6 +29,8 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +private const val PROFILE_STATS_POLL_MS = 20_000L + enum class ExternalServiceAccount { NAVIDROME, JELLYFIN, @@ -41,11 +50,22 @@ data class ListenBrainzUiModel( val pendingCount: Int, val needsReauth: Boolean, val serverUrl: String?, + val statsState: ListenBrainzStatsUiState, val scrobbleLocal: Boolean, val scrobbleNavidrome: Boolean, val scrobbleJellyfin: Boolean ) +sealed interface ListenBrainzStatsUiState { + /** First fetch is in flight — show skeleton placeholders. */ + data object Loading : ListenBrainzStatsUiState + + /** The connected server doesn't expose profile endpoints (Maloja) — show nothing. */ + data object Unavailable : ListenBrainzStatsUiState + + data class Loaded(val stats: ListenBrainzProfileStats) : ListenBrainzStatsUiState +} + sealed interface ListenBrainzConnectState { data object Idle : ListenBrainzConnectState data object Connecting : ListenBrainzConnectState @@ -83,22 +103,57 @@ class AccountsViewModel @Inject constructor( connected to playlistCount } + /** + * Re-fetched on a fixed cadence, but only while something downstream collects [uiState], + * so the polling stops as soon as the Accounts screen goes away. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val listenBrainzStatsFlow = listenBrainzRepository.accountState + .map { it.isConnected to it.userName } + .distinctUntilChanged() + .flatMapLatest { (connected, userName) -> + if (!connected || userName.isNullOrBlank()) { + flowOf(ListenBrainzStatsUiState.Unavailable) + } else { + flow { + emit(ListenBrainzStatsUiState.Loading) + var lastLoaded: ListenBrainzProfileStats? = null + while (true) { + // A transient poll failure keeps showing the last good numbers + // instead of collapsing the section. + val stats = listenBrainzRepository.fetchProfileStats() ?: lastLoaded + if (stats != null) { + lastLoaded = stats + emit(ListenBrainzStatsUiState.Loaded(stats)) + } else { + emit(ListenBrainzStatsUiState.Unavailable) + } + delay(PROFILE_STATS_POLL_MS) + } + } + } + } + private val listenBrainzStateFlow = combine( listenBrainzRepository.accountState, listenBrainzRepository.pendingListenCount, + listenBrainzStatsFlow, listenBrainzPreferences.scrobbleLocalFlow, - listenBrainzPreferences.scrobbleNavidromeFlow, - listenBrainzPreferences.scrobbleJellyfinFlow - ) { account, pendingCount, local, navidrome, jellyfin -> + combine( + listenBrainzPreferences.scrobbleNavidromeFlow, + listenBrainzPreferences.scrobbleJellyfinFlow + ) { navidrome, jellyfin -> navidrome to jellyfin } + ) { account, pendingCount, statsState, local, remoteToggles -> if (account.isConnected) { ListenBrainzUiModel( userName = account.userName, pendingCount = pendingCount, needsReauth = account.needsReauth, serverUrl = account.serverUrl, + statsState = statsState, scrobbleLocal = local, - scrobbleNavidrome = navidrome, - scrobbleJellyfin = jellyfin + scrobbleNavidrome = remoteToggles.first, + scrobbleJellyfin = remoteToggles.second ) } else { null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 503dcbfa..156dfd0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,11 @@ Scrobble Subsonic streaming Scrobble Jellyfin streaming If your server already scrobbles to ListenBrainz, turn that source off here to avoid duplicate listens. + Total listens + Queued + Scrobbling now + %1$s — %2$s + Nothing playing right now Available services Import Search diff --git a/app/src/main/res/values/strings_presentation_batch_b.xml b/app/src/main/res/values/strings_presentation_batch_b.xml index 1c58916a..0d097eae 100644 --- a/app/src/main/res/values/strings_presentation_batch_b.xml +++ b/app/src/main/res/values/strings_presentation_batch_b.xml @@ -1,10 +1,6 @@ Linked Services - Connected Accounts - Manage linked providers and keep each integration under your control. - Active - Available Connected Open Service Logging out… diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzProfileStatsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzProfileStatsTest.kt new file mode 100644 index 00000000..1d567e69 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/listenbrainz/ListenBrainzProfileStatsTest.kt @@ -0,0 +1,63 @@ +package com.lostf1sh.pixelplayeross.data.listenbrainz + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ListenBrainzProfileStatsTest { + + @Test + fun nullWhenServerExposesNeitherEndpoint() { + val stats = buildProfileStats( + listenCount = null, + playingNowAvailable = false, + nowPlaying = null + ) + + assertThat(stats).isNull() + } + + @Test + fun carriesListenCountWithoutPlayingNowSupport() { + val stats = buildProfileStats( + listenCount = 1234, + playingNowAvailable = false, + nowPlaying = null + ) + + assertThat(stats).isEqualTo( + ListenBrainzProfileStats( + listenCount = 1234, + playingNowAvailable = false, + nowPlayingTrack = null, + nowPlayingArtist = null + ) + ) + } + + @Test + fun supportedButIdlePlayingNowKeepsNullTrack() { + val stats = buildProfileStats( + listenCount = 42, + playingNowAvailable = true, + nowPlaying = null + ) + + assertThat(stats?.playingNowAvailable).isTrue() + assertThat(stats?.nowPlayingTrack).isNull() + } + + @Test + fun mapsNowPlayingTrackAndArtist() { + val stats = buildProfileStats( + listenCount = 42, + playingNowAvailable = true, + nowPlaying = ListenBrainzTrackMetadata( + artistName = "Rick Astley", + trackName = "Never Gonna Give You Up" + ) + ) + + assertThat(stats?.nowPlayingTrack).isEqualTo("Never Gonna Give You Up") + assertThat(stats?.nowPlayingArtist).isEqualTo("Rick Astley") + } +}