Skip to content
Open
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
1,365 changes: 1,365 additions & 0 deletions PLAYLIST_SYNC_ARCHITECTURE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import com.simplecityapps.localmediaprovider.local.repository.LocalAlbumReposito
import com.simplecityapps.localmediaprovider.local.repository.LocalGenreRepository
import com.simplecityapps.localmediaprovider.local.repository.LocalPlaylistRepository
import com.simplecityapps.localmediaprovider.local.repository.LocalSongRepository
import com.simplecityapps.localmediaprovider.local.repository.LocalSyncQueueRepository
import com.simplecityapps.mediaprovider.MediaImporter
import com.simplecityapps.mediaprovider.repository.albums.AlbumRepository
import com.simplecityapps.mediaprovider.repository.artists.AlbumArtistRepository
import com.simplecityapps.mediaprovider.repository.genres.GenreRepository
import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
import com.simplecityapps.mediaprovider.sync.SyncQueueRepository
import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager
import dagger.Module
import dagger.Provides
Expand Down Expand Up @@ -76,4 +78,10 @@ class RepositoryModule {
songRepository: SongRepository,
@AppCoroutineScope appCoroutineScope: CoroutineScope
): GenreRepository = LocalGenreRepository(appCoroutineScope, songRepository)

@Provides
@Singleton
fun provideSyncQueueRepository(
database: MediaDatabase
): SyncQueueRepository = LocalSyncQueueRepository(database.syncOperationDao())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.simplecityapps.mediaprovider.sync

import com.simplecityapps.networking.retrofit.NetworkResult
import com.simplecityapps.shuttle.model.MediaProviderType
import kotlinx.datetime.Instant

/**
* Interface for bidirectional playlist synchronization with media servers.
* Implementations provide server-specific APIs for creating, updating, and deleting playlists.
*/
interface PlaylistSyncProvider {
val type: MediaProviderType

/**
* Create a new playlist on the remote server.
*
* @param name The playlist name
* @param songExternalIds External IDs of songs to add to the playlist
* @return The external ID of the created playlist
*/
suspend fun createPlaylist(
name: String,
songExternalIds: List<String>
): NetworkResult<String>

/**
* Update playlist metadata (name, description, etc.).
*
* @param externalId Remote playlist ID
* @param name New playlist name
*/
suspend fun updatePlaylistMetadata(
externalId: String,
name: String
): NetworkResult<Unit>

/**
* Add songs to an existing playlist.
*
* @param externalId Remote playlist ID
* @param songExternalIds External IDs of songs to add
*/
suspend fun addSongsToPlaylist(
externalId: String,
songExternalIds: List<String>
): NetworkResult<Unit>

/**
* Remove songs from a playlist.
* Note: Some servers require playlist-item-specific IDs (not song IDs).
*
* @param externalId Remote playlist ID
* @param playlistItemIds Server-specific playlist item IDs
*/
suspend fun removeSongsFromPlaylist(
externalId: String,
playlistItemIds: List<String>
): NetworkResult<Unit>

/**
* Reorder songs in a playlist.
*
* @param externalId Remote playlist ID
* @param orderedSongExternalIds Song external IDs in the new order
*/
suspend fun reorderPlaylistSongs(
externalId: String,
orderedSongExternalIds: List<String>
): NetworkResult<Unit>

/**
* Delete a playlist from the remote server.
*
* @param externalId Remote playlist ID
*/
suspend fun deletePlaylist(externalId: String): NetworkResult<Unit>

/**
* Get the current state of a playlist for conflict detection.
*
* @param externalId Remote playlist ID
* @return Current playlist state including songs and last modified timestamp
*/
suspend fun getPlaylistState(
externalId: String
): NetworkResult<PlaylistRemoteState>
}

/**
* Represents the remote state of a playlist for conflict detection.
*/
data class PlaylistRemoteState(
val externalId: String,
val name: String,
val songExternalIds: List<String>, // Ordered list of song IDs
val lastModified: Instant
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.simplecityapps.mediaprovider.sync

import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncOperation
import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncStatus
import com.simplecityapps.shuttle.model.MediaProviderType
import kotlinx.coroutines.flow.Flow

/**
* Repository for managing the playlist sync operation queue.
*/
interface SyncQueueRepository {
/**
* Enqueue a new sync operation.
*
* @param operation The sync operation to enqueue
* @return The ID of the enqueued operation
*/
suspend fun enqueue(operation: SyncOperation): Long

/**
* Enqueue multiple sync operations.
*/
suspend fun enqueueAll(operations: List<SyncOperation>)

/**
* Get pending operations ordered by priority and creation time.
*/
suspend fun getPendingOperations(): List<SyncOperation>

/**
* Get operation by ID.
*/
suspend fun getOperation(operationId: Long): SyncOperation?

/**
* Update an existing operation.
*/
suspend fun updateOperation(operation: SyncOperation)

/**
* Get operations for a specific playlist.
*/
suspend fun getOperationsByPlaylist(playlistId: Long): List<SyncOperation>

/**
* Get operations by status.
*/
suspend fun getOperationsByStatus(status: SyncStatus): List<SyncOperation>

/**
* Observe operations with specific statuses.
*/
fun observeOperationsByStatuses(statuses: List<SyncStatus>): Flow<List<SyncOperation>>

/**
* Delete an operation.
*/
suspend fun deleteOperation(operationId: Long)

/**
* Delete all operations for a playlist.
*/
suspend fun deleteOperationsByPlaylist(playlistId: Long)

/**
* Delete all operations with a specific status.
*/
suspend fun deleteOperationsByStatus(status: SyncStatus)

/**
* Count operations by status.
*/
suspend fun countByStatus(status: SyncStatus): Int

/**
* Check if there are pending operations for a playlist.
*/
suspend fun hasPendingOperations(playlistId: Long): Boolean

/**
* Get operations for a specific media provider.
*/
suspend fun getOperationsByProvider(
mediaProviderType: MediaProviderType,
status: SyncStatus
): List<SyncOperation>
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.simplecityapps.localmediaprovider.local.data.room

import androidx.room.TypeConverter
import com.simplecityapps.localmediaprovider.local.data.room.entity.OperationType
import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSyncStatus
import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncStatus
import com.simplecityapps.shuttle.model.MediaProviderType
import com.simplecityapps.shuttle.sorting.SongSortOrder
import java.util.Date
import kotlinx.datetime.Instant

class Converters {
@TypeConverter
Expand Down Expand Up @@ -33,4 +37,30 @@ class Converters {
} catch (e: IllegalArgumentException) {
SongSortOrder.Default
}

// Sync-related type converters

@TypeConverter
fun fromInstant(instant: Instant?): Long? = instant?.toEpochMilliseconds()

@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }

@TypeConverter
fun fromOperationType(type: OperationType): String = type.name

@TypeConverter
fun toOperationType(value: String): OperationType = OperationType.valueOf(value)

@TypeConverter
fun fromSyncStatus(status: SyncStatus): String = status.name

@TypeConverter
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)

@TypeConverter
fun fromPlaylistSyncStatus(status: PlaylistSyncStatus): String = status.name

@TypeConverter
fun toPlaylistSyncStatus(value: String): PlaylistSyncStatus = PlaylistSyncStatus.valueOf(value)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.simplecityapps.localmediaprovider.local.data.room.migrations.MIGRATIO
import com.simplecityapps.localmediaprovider.local.data.room.migrations.MIGRATION_37_38
import com.simplecityapps.localmediaprovider.local.data.room.migrations.MIGRATION_38_39
import com.simplecityapps.localmediaprovider.local.data.room.migrations.MIGRATION_39_40
import com.simplecityapps.localmediaprovider.local.data.room.migrations.MIGRATION_40_41

class DatabaseProvider(
private val context: Context
Expand All @@ -44,7 +45,8 @@ class DatabaseProvider(
MIGRATION_36_37,
MIGRATION_37_38,
MIGRATION_38_39,
MIGRATION_39_40
MIGRATION_39_40,
MIGRATION_40_41
)
.apply {
if (!BuildConfig.DEBUG) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.simplecityapps.localmediaprovider.local.data.room.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSyncState
import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSyncStatus
import kotlinx.coroutines.flow.Flow

@Dao
interface PlaylistSyncStateDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(state: PlaylistSyncState)

@Update
suspend fun update(state: PlaylistSyncState)

@Query("SELECT * FROM playlist_sync_state WHERE playlist_id = :playlistId")
suspend fun get(playlistId: Long): PlaylistSyncState?

@Query("SELECT * FROM playlist_sync_state WHERE playlist_id = :playlistId")
fun observe(playlistId: Long): Flow<PlaylistSyncState?>

@Query("SELECT * FROM playlist_sync_state WHERE sync_status = :status")
suspend fun getByStatus(status: PlaylistSyncStatus): List<PlaylistSyncState>

@Query("SELECT * FROM playlist_sync_state WHERE conflict_detected = 1")
suspend fun getConflicted(): List<PlaylistSyncState>

@Query("SELECT * FROM playlist_sync_state WHERE conflict_detected = 1")
fun observeConflicted(): Flow<List<PlaylistSyncState>>

@Query("DELETE FROM playlist_sync_state WHERE playlist_id = :playlistId")
suspend fun delete(playlistId: Long)

@Query("SELECT COUNT(*) FROM playlist_sync_state WHERE sync_status = :status")
suspend fun countByStatus(status: PlaylistSyncStatus): Int

@Query("SELECT COUNT(*) FROM playlist_sync_state WHERE conflict_detected = 1")
suspend fun countConflicted(): Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.simplecityapps.localmediaprovider.local.data.room.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncOperation
import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncStatus
import com.simplecityapps.shuttle.model.MediaProviderType
import kotlinx.coroutines.flow.Flow

@Dao
interface SyncOperationDao {
@Insert
suspend fun insert(operation: SyncOperation): Long

@Insert
suspend fun insertAll(operations: List<SyncOperation>)

@Update
suspend fun update(operation: SyncOperation)

@Query("SELECT * FROM sync_operations WHERE id = :operationId")
suspend fun get(operationId: Long): SyncOperation?

@Query("SELECT * FROM sync_operations WHERE status = :status ORDER BY priority DESC, created_at ASC")
suspend fun getByStatus(status: SyncStatus): List<SyncOperation>

@Query("SELECT * FROM sync_operations WHERE status IN (:statuses) ORDER BY priority DESC, created_at ASC")
fun observeByStatuses(statuses: List<SyncStatus>): Flow<List<SyncOperation>>

@Query("SELECT * FROM sync_operations WHERE playlist_id = :playlistId AND status = :status")
suspend fun getByPlaylistAndStatus(playlistId: Long, status: SyncStatus): List<SyncOperation>

@Query("SELECT * FROM sync_operations WHERE playlist_id = :playlistId")
suspend fun getByPlaylist(playlistId: Long): List<SyncOperation>

@Query("SELECT * FROM sync_operations WHERE media_provider_type = :mediaProviderType AND status = :status")
suspend fun getByProviderAndStatus(
mediaProviderType: MediaProviderType,
status: SyncStatus
): List<SyncOperation>

@Query("DELETE FROM sync_operations WHERE id = :operationId")
suspend fun delete(operationId: Long)

@Query("DELETE FROM sync_operations WHERE playlist_id = :playlistId")
suspend fun deleteByPlaylist(playlistId: Long)

@Query("DELETE FROM sync_operations WHERE status = :status")
suspend fun deleteByStatus(status: SyncStatus)

@Query("SELECT COUNT(*) FROM sync_operations WHERE status = :status")
suspend fun countByStatus(status: SyncStatus): Int

@Query("SELECT COUNT(*) FROM sync_operations WHERE playlist_id = :playlistId AND status IN (:statuses)")
suspend fun countByPlaylistAndStatuses(playlistId: Long, statuses: List<SyncStatus>): Int
}
Loading
Loading