Skip to content

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ interface FavoritesDao {
@Query("SELECT * FROM favorites")
suspend fun getAllFavoritesOnce(): List<FavoritesEntity>

@Query(
"SELECT f.songId FROM favorites f INNER JOIN songs s ON f.songId = s.id " +
"WHERE s.source_type = :sourceType AND f.isFavorite = 1"
)
suspend fun getFavoriteSongIdsBySourceOnce(sourceType: Int): List<Long>

@Query("DELETE FROM favorites")
suspend fun clearAll()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,24 @@ 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: outbox table for two-way Navidrome favorites sync.
* The DDL must match the Room-generated schema exactly (no SQL DEFAULTs:
* Kotlin default values do not produce SQL defaults).
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `navidrome_pending_favorites` (
`navidromeSongId` TEXT NOT NULL,
`isStar` INTEGER NOT NULL,
`createdAt` INTEGER NOT NULL,
`attempts` INTEGER NOT NULL,
PRIMARY KEY(`navidromeSongId`)
)
""".trimIndent()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,18 @@ interface MusicDao {
refreshArtistTrackCounts()
}

@Query("SELECT id FROM songs WHERE id IN (:ids)")
suspend fun getExistingSongIds(ids: List<Long>): List<Long>

@Query(
"SELECT s.content_uri_string FROM songs s INNER JOIN favorites f ON s.id = f.songId " +
"WHERE s.source_type = :sourceType AND f.isFavorite = 1"
)
suspend fun getFavoriteContentUrisBySource(sourceType: Int): List<String>

@Query("SELECT content_uri_string FROM songs WHERE id = :id")
suspend fun getContentUriStringOnce(id: Long): String?

companion object {
/**
* SQLite has a limit on the number of variables per statement (default 999, higher in newer versions).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,21 @@ interface NavidromeDao {

@Query("DELETE FROM navidrome_playlists")
suspend fun clearAllPlaylists()

// ─── Pending favorite ops (outbox) ───────────────────────────────────

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertPendingFavorite(op: NavidromePendingFavoriteEntity)

@Query("SELECT * FROM navidrome_pending_favorites ORDER BY createdAt")
suspend fun getPendingFavoritesOnce(): List<NavidromePendingFavoriteEntity>

@Query("DELETE FROM navidrome_pending_favorites WHERE navidromeSongId = :id")
suspend fun deletePendingFavorite(id: String)

@Query("UPDATE navidrome_pending_favorites SET attempts = attempts + 1 WHERE navidromeSongId = :id")
suspend fun incrementPendingFavoriteAttempts(id: String)

@Query("DELETE FROM navidrome_pending_favorites")
suspend fun clearPendingFavorites()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.lostf1sh.pixelplayeross.data.database

import androidx.room.Entity
import androidx.room.PrimaryKey

/**
* Outbox of favorite changes on Navidrome songs awaiting push to the server.
* One row per song: a later toggle replaces the pending op (latest intent wins).
*/
@Entity(tableName = "navidrome_pending_favorites")
data class NavidromePendingFavoriteEntity(
@PrimaryKey val navidromeSongId: String,
val isStar: Boolean,
val createdAt: Long = System.currentTimeMillis(),
val attempts: Int = 0
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PlaylistSongEntity::class,
NavidromeSongEntity::class,
NavidromePlaylistEntity::class,
NavidromePendingFavoriteEntity::class,
JellyfinSongEntity::class,
JellyfinPlaylistEntity::class
],
version = 2,
version = 3,
exportSchema = true
)
abstract class PixelPlayerDatabase : RoomDatabase() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.lostf1sh.pixelplayeross.data.navidrome

import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity

data class NavidromeFavoritesReconciliation(
val toFavorite: List<Long>,
val toUnfavorite: List<Long>
)

/**
* Computes local favorites changes for Navidrome-sourced songs.
* Server starred state wins, except songs with a pending un-pushed local op,
* where the pending action wins.
*/
internal fun reconcileNavidromeFavorites(
serverStarredIds: Set<String>,
pendingOps: List<NavidromePendingFavoriteEntity>,
localFavoriteIds: Set<Long>,
toUnifiedSongId: (String) -> Long
): NavidromeFavoritesReconciliation {
val desired = serverStarredIds.mapTo(mutableSetOf(), toUnifiedSongId)
pendingOps.forEach { op ->
val unifiedId = toUnifiedSongId(op.navidromeSongId)
if (op.isStar) desired.add(unifiedId) else desired.remove(unifiedId)
}

return NavidromeFavoritesReconciliation(
toFavorite = (desired - localFavoriteIds).toList(),
toUnfavorite = (localFavoriteIds - desired).toList()
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.lostf1sh.pixelplayeross.data.navidrome

import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.lostf1sh.pixelplayeross.data.database.NavidromeDao
import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity
import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService
import com.lostf1sh.pixelplayeross.data.worker.NavidromeFavoritesPushWorker
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CancellationException
import timber.log.Timber

/**
* Outbox for favorite changes on Navidrome songs: records pending star/unstar
* ops and pushes them to the server when connectivity allows.
*/
@Singleton
class NavidromeFavoritesSyncManager @Inject constructor(
private val api: NavidromeApiService,
private val navidromeDao: NavidromeDao,
private val workManager: WorkManager
) {
companion object {
private const val TAG = "NavidromeFavSync"
const val PUSH_WORK_NAME = "navidrome_favorites_push"
const val MAX_ATTEMPTS = 8
}

suspend fun onFavoriteToggled(navidromeSongId: String, favorite: Boolean) {
// Toggles only happen on Navidrome songs while their rows exist in the unified
// library, which requires being logged in, so we enqueue unconditionally here.
// (Gating on api.hasCredentials() previously silently dropped ops on a cold
// start where credentials had not yet been restored into the in-memory API
// client. Logout already clears the outbox via dao.clearPendingFavorites().)
navidromeDao.upsertPendingFavorite(
NavidromePendingFavoriteEntity(
navidromeSongId = navidromeSongId,
isStar = favorite
)
)
schedulePush()
}

fun schedulePush() {
val request = OneTimeWorkRequestBuilder<NavidromeFavoritesPushWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(PUSH_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}

/**
* Pushes all pending ops. Returns true when the queue is fully drained.
* Ops are deleted on success and dropped after MAX_ATTEMPTS failures so a
* permanently failing song (e.g. deleted server-side) cannot block the queue.
*/
suspend fun drainPendingFavorites(): Boolean {
if (!api.hasCredentials()) {
// Do NOT clear the outbox here: on a WorkManager cold start this worker's
// Hilt graph never constructs NavidromeRepository, so credentials may simply
// not have been restored into the shared NavidromeApiService yet even though
// the user is logged in. Clearing would silently drop pending ops and report
// success. Stale-op cleanup after an actual logout is already handled by
// NavidromeRepository.logout() calling dao.clearPendingFavorites().
return true
}
var queueDrained = true
navidromeDao.getPendingFavoritesOnce().forEach { op ->
val result = if (op.isStar) api.star(id = op.navidromeSongId) else api.unstar(id = op.navidromeSongId)
result.fold(
onSuccess = { navidromeDao.deletePendingFavorite(op.navidromeSongId) },
onFailure = { error ->
// A REPLACE-cancelled in-flight run must not consume this op's retry budget.
if (error is CancellationException) throw error
Timber.w("$TAG: push failed for ${op.navidromeSongId} (attempt ${op.attempts + 1}): ${error.message}")
if (op.attempts + 1 >= MAX_ATTEMPTS) {
navidromeDao.deletePendingFavorite(op.navidromeSongId)
} else {
navidromeDao.incrementPendingFavoriteAttempts(op.navidromeSongId)
queueDrained = false
}
}
)
}
return queueDrained
}
}
Loading