Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

### Initial release
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<ListenBrainzPendingListenEntity>

@Query("DELETE FROM listenbrainz_pending_listens WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<Long>)

@Query("UPDATE listenbrainz_pending_listens SET attempts = attempts + 1 WHERE id IN (:ids)")
suspend fun incrementAttempts(ids: List<Long>)

@Query("SELECT COUNT(*) FROM listenbrainz_pending_listens")
suspend fun count(): Int

@Query("SELECT COUNT(*) FROM listenbrainz_pending_listens")
fun countFlow(): Flow<Int>

/** 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()
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArtistRef>): Song {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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
import retrofit2.http.Path

/**
* Retrofit interface for the ListenBrainz API.
* Authorization header format: `Token <user token>`.
*/
interface ListenBrainzApiService {

@POST("1/submit-listens")
suspend fun submitListens(
@Header("Authorization") authorization: String,
@Body submission: ListenBrainzSubmission
): Response<Unit>

@GET("1/validate-token")
suspend fun validateToken(
@Header("Authorization") authorization: String
): Response<ListenBrainzTokenValidation>

@GET("1/user/{userName}/listen-count")
suspend fun getListenCount(
@Header("Authorization") authorization: String,
@Path("userName") userName: String
): Response<ListenBrainzListenCountResponse>

@GET("1/user/{userName}/playing-now")
suspend fun getPlayingNow(
@Header("Authorization") authorization: String,
@Path("userName") userName: String
): Response<ListenBrainzPlayingNowResponse>
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading