From 6780cc765780802f5600acada321afc8bf09956f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 05:55:23 +0000 Subject: [PATCH 1/3] Add comprehensive playlist sync architecture design This design document provides a complete architectural blueprint for implementing bidirectional playlist synchronization with Jellyfin, Emby, and Plex media servers. Key architectural components: - PlaylistSyncProvider interface for server-agnostic sync operations - PlaylistSyncCoordinator for managing sync lifecycle - Persistent sync queue with retry logic and priority handling - Conflict detection and resolution strategies - Repository interceptor pattern for change tracking The design follows Clean Architecture principles and maintains backward compatibility while providing a scalable, resilient foundation for bidirectional sync. Addresses: #102 --- PLAYLIST_SYNC_ARCHITECTURE.md | 1194 +++++++++++++++++++++++++++++++++ 1 file changed, 1194 insertions(+) create mode 100644 PLAYLIST_SYNC_ARCHITECTURE.md diff --git a/PLAYLIST_SYNC_ARCHITECTURE.md b/PLAYLIST_SYNC_ARCHITECTURE.md new file mode 100644 index 00000000..5e4171f5 --- /dev/null +++ b/PLAYLIST_SYNC_ARCHITECTURE.md @@ -0,0 +1,1194 @@ +# Playlist Synchronization Architecture + +**Issue:** [#102](https://github.com/timusus/Shuttle2/issues/102) - Sync S2 playlist actions with Jellyfin server +**Author:** Claude +**Date:** 2025-11-16 +**Status:** Design Proposal + +## Executive Summary + +This document outlines an architecturally sound, scalable, and principled approach to implementing bidirectional playlist synchronization between Shuttle2 and media servers (Jellyfin, Emby, Plex). The design follows Clean Architecture principles, maintains backward compatibility, and provides a framework for future enhancements. + +**Key Design Principles:** +- **Single Responsibility**: Each component has one clear purpose +- **Dependency Inversion**: High-level policies don't depend on low-level details +- **Interface Segregation**: Clients aren't forced to depend on unused interfaces +- **Open/Closed**: Open for extension, closed for modification +- **Scalability**: Handle multiple servers and thousands of playlists efficiently +- **Resilience**: Graceful degradation and conflict resolution + +--- + +## Table of Contents + +1. [Problem Statement](#1-problem-statement) +2. [Current Architecture Analysis](#2-current-architecture-analysis) +3. [Media Server API Capabilities](#3-media-server-api-capabilities) +4. [Proposed Architecture](#4-proposed-architecture) +5. [Implementation Strategy](#5-implementation-strategy) +6. [Conflict Resolution](#6-conflict-resolution) +7. [Migration Path](#7-migration-path) +8. [Testing Strategy](#8-testing-strategy) +9. [Future Considerations](#9-future-considerations) + +--- + +## 1. Problem Statement + +### Current Behavior (One-Way Sync) + +``` +┌──────────────┐ ┌──────────────┐ +│ │ Pull Playlists │ │ +│ Media Server │ ◄──────────────── │ Shuttle2 │ +│ (Jellyfin) │ │ │ +└──────────────┘ └──────────────┘ +``` + +**Limitations:** +- Playlists imported from media servers are read-only in effect +- Local modifications (add/remove songs, rename, delete) don't sync back +- Creates divergence between Shuttle2 and server state +- Users must manually maintain playlists in multiple places + +### Desired Behavior (Bidirectional Sync) + +``` +┌──────────────┐ ┌──────────────┐ +│ │ Pull Playlists │ │ +│ Media Server │ ◄─────────────────►│ Shuttle2 │ +│ (Jellyfin) │ Push Changes │ │ +└──────────────┘ └──────────────┘ +``` + +**Requirements:** +1. **Create**: New playlists in Shuttle2 should sync to server +2. **Update**: Song additions/removals should sync bidirectionally +3. **Rename**: Playlist name changes should sync back +4. **Delete**: Playlist deletions should sync (with user confirmation) +5. **Reorder**: Song order changes should sync +6. **Conflict Resolution**: Handle concurrent modifications gracefully +7. **Offline Support**: Queue changes when offline, sync when reconnected + +--- + +## 2. Current Architecture Analysis + +### 2.1 Existing Components + +#### Data Layer (Domain Models) + +```kotlin +// android/data/src/main/kotlin/com/simplecityapps/shuttle/model/Playlist.kt +data class Playlist( + val id: Long, // Local database ID + val name: String, + val songCount: Int, + val duration: Int, + val sortOrder: PlaylistSongSortOrder, + val mediaProvider: MediaProviderType, // Shuttle, Jellyfin, Emby, Plex + val externalId: String? // Remote playlist ID +) + +data class PlaylistSong( + val id: Long, // Join table ID + val sortOrder: Long, // Position in playlist + val song: Song +) +``` + +**Key Insight**: The architecture already supports tracking playlist source (`mediaProvider`) and remote identity (`externalId`), providing the foundation for bidirectional sync. + +#### Repository Pattern + +```kotlin +// android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/playlists/PlaylistRepository.kt +interface PlaylistRepository { + // Queries (Reactive) + fun getPlaylists(query: PlaylistQuery): Flow> + fun getSongsForPlaylist(playlist: Playlist): Flow> + + // Mutations (Suspending) + suspend fun createPlaylist(name: String, mediaProviderType, songs, externalId): Playlist + suspend fun addToPlaylist(playlist: Playlist, songs: List) + suspend fun removeFromPlaylist(playlist: Playlist, playlistSongs: List) + suspend fun deletePlaylist(playlist: Playlist) + suspend fun renamePlaylist(playlist: Playlist, name: String) + suspend fun updatePlaylistSongsSortOrder(playlist: Playlist, playlistSongs: List) + + // Sync Support + suspend fun updatePlaylistExternalId(playlist: Playlist, externalId: String?) + suspend fun updatePlaylistMediaProviderType(playlist: Playlist, mediaProviderType) +} +``` + +**Key Insight**: All mutation operations are already defined in the repository interface, making them observable points for sync triggers. + +#### Media Provider Plugin Architecture + +```kotlin +// android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaProvider.kt +interface MediaProvider { + val type: MediaProviderType + + // Current: One-way sync (pull from server) + fun findSongs(): Flow, MessageProgress>> + fun findPlaylists(existingPlaylists, existingSongs): + Flow, MessageProgress>> +} +``` + +**Key Insight**: The plugin architecture is extensible. We can add sync capabilities without breaking existing implementations. + +#### Current Import Flow + +```kotlin +// android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/MediaImporter.kt +class MediaImporter { + suspend fun import() { + // For each media provider: + // 1. Import songs (with diff/merge) + // 2. Import playlists (with createOrUpdatePlaylist) + } + + private suspend fun createOrUpdatePlaylist( + playlistUpdateData: PlaylistUpdateData, + existingPlaylists: List + ) { + val existingPlaylist = existingPlaylists.find { + it.mediaProvider == playlistUpdateData.mediaProviderType && + (it.name == playlistUpdateData.name || it.externalId == playlistUpdateData.externalId) + } + + if (existingPlaylist == null) { + // Create new playlist + playlistRepository.createPlaylist(...) + } else { + // Update existing (currently append-only for songs) + playlistRepository.renamePlaylist(...) + playlistRepository.addToPlaylist(...) // Only adds missing songs + } + } +} +``` + +**Key Insights:** +- Import is idempotent and merge-based (not destructive) +- Matching by `externalId` or `name` +- Currently doesn't detect deletions from server + +### 2.2 Architecture Strengths + +✅ **Clean Architecture**: Clear separation of concerns (domain, data, presentation) +✅ **Repository Pattern**: Centralized data access with reactive streams +✅ **Plugin System**: MediaProvider abstraction allows server-specific implementations +✅ **Reactive**: Flow-based architecture for real-time updates +✅ **Offline-First**: Room database provides local persistence +✅ **Type Safety**: Kotlin coroutines and sealed classes for error handling + +### 2.3 Architecture Gaps for Bidirectional Sync + +❌ **No Change Tracking**: No mechanism to track local modifications to remote playlists +❌ **No Sync Queue**: No persistent queue for pending sync operations +❌ **No Conflict Resolution**: No strategy for handling concurrent modifications +❌ **No Write API**: MediaProvider only defines read operations +❌ **No Sync State**: No tracking of sync status (pending, syncing, failed, synced) +❌ **No Event Bus**: Repository mutations don't trigger sync operations + +--- + +## 3. Media Server API Capabilities + +### 3.1 Jellyfin API + +**Base URL**: `https://{server}/api` +**Authentication**: `X-Emby-Token` header + +#### Playlist Operations + +| Operation | Method | Endpoint | Notes | +|-----------|--------|----------|-------| +| **List Playlists** | GET | `/Users/{userId}/Items?includeItemTypes=Playlist` | ✅ Currently implemented | +| **Get Playlist Items** | GET | `/Playlists/{playlistId}/Items` | ✅ Currently implemented | +| **Create Playlist** | POST | `/Playlists` | ⚠️ Not implemented | +| **Add Items** | POST | `/Playlists/{playlistId}/Items?ids={itemIds}` | ⚠️ Not implemented | +| **Remove Items** | DELETE | `/Playlists/{playlistId}/Items?entryIds={entryIds}` | ⚠️ Known issues (see below) | +| **Move Item** | POST | `/Playlists/{playlistId}/Items/{itemId}/Move/{newIndex}` | ⚠️ Fixed in 2025 | +| **Update Playlist** | POST | `/Items/{playlistId}` | ⚠️ For name/metadata | +| **Delete Playlist** | DELETE | `/Items/{playlistId}` | ⚠️ Not implemented | + +**Known Issues**: +- **Delete Items Bug**: Some versions have issues removing items (GitHub #2130, #9008, #13476) + - Workaround: May need to recreate playlist instead of modifying +- **API Key Permissions**: API keys may not work for deletion, requires user token +- **Entry IDs vs Item IDs**: Removal requires `entryIds` (playlist-specific), not `itemIds` + +**Request Examples**: + +```http +# Create Playlist +POST /Playlists +Content-Type: application/json +X-Emby-Token: {token} + +{ + "Name": "My Playlist", + "UserId": "{userId}", + "MediaType": "Audio", + "Ids": ["itemId1", "itemId2"] // Optional: initial items +} + +# Add Items +POST /Playlists/{playlistId}/Items?ids=itemId1,itemId2&userId={userId} +X-Emby-Token: {token} + +# Remove Items (requires entryIds from playlist items response) +DELETE /Playlists/{playlistId}/Items?entryIds=playlistEntryId1,playlistEntryId2 +X-Emby-Token: {token} +``` + +### 3.2 Emby API + +**Base URL**: `https://{server}/emby` +**Authentication**: `X-Emby-Token` header + +#### Playlist Operations + +| Operation | Method | Endpoint | Notes | +|-----------|--------|----------|-------| +| **List Playlists** | GET | `/Users/{userId}/Items?includeItemTypes=Playlist` | ✅ Similar to Jellyfin | +| **Get Playlist Items** | GET | `/Playlists/{playlistId}/Items` | ✅ Returns PlaylistItemId | +| **Create Playlist** | POST | `/Playlists` | ✅ Well documented | +| **Add Items** | POST | `/Playlists/{playlistId}/Items?ids={itemIds}` | ✅ Comma-delimited IDs | +| **Remove Items** | DELETE | `/Playlists/{playlistId}/Items?entryIds={playlistItemIds}` | ✅ Requires PlaylistItemId | +| **Update Playlist** | POST | `/Items/{playlistId}` | ✅ For metadata | +| **Delete Playlist** | DELETE | `/Items/{playlistId}` | ✅ Standard | + +**Key Differences from Jellyfin**: +- More stable playlist modification APIs +- Better documentation for `PlaylistItemId` requirement +- Similar overall structure (Emby is Jellyfin's predecessor) + +### 3.3 Plex API + +**Base URL**: `https://{server}:32400` +**Authentication**: `X-Plex-Token` header or query param + +#### Playlist Operations + +| Operation | Method | Endpoint | Notes | +|-----------|--------|----------|-------| +| **List Playlists** | GET | `/playlists` | ✅ Returns playlist metadata | +| **Get Playlist Items** | GET | `/playlists/{playlistId}/items` | ✅ Returns ratingKeys | +| **Create Playlist** | POST | `/playlists?type={type}&title={title}&smart={smart}&uri={uri}` | ✅ Supports smart playlists | +| **Add Items** | PUT | `/playlists/{playlistId}/items?uri={serverUri}` | ✅ Server URI format | +| **Remove Items** | DELETE | `/playlists/{playlistId}/items/{playlistItemId}` | ✅ One at a time | +| **Move Item** | PUT | `/playlists/{playlistId}/items/{itemId}/move?after={afterItemId}` | ✅ Supports reordering | +| **Update Playlist** | PUT | `/playlists/{playlistId}?title={newTitle}` | ✅ For metadata | +| **Delete Playlist** | DELETE | `/playlists/{playlistId}` | ✅ Standard | + +**Key Differences**: +- URI-based item references: `server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}` +- More granular operations (remove one item at a time) +- Support for smart playlists (query-based) +- Different authentication approach + +### 3.4 API Comparison Summary + +| Feature | Jellyfin | Emby | Plex | +|---------|----------|------|------| +| **Create Playlist** | ✅ | ✅ | ✅ | +| **Add Items (Batch)** | ✅ | ✅ | ✅ | +| **Remove Items (Batch)** | ⚠️ Buggy | ✅ | ❌ (one at a time) | +| **Reorder Items** | ✅ (2025) | ❓ | ✅ | +| **Update Metadata** | ✅ | ✅ | ✅ | +| **Delete Playlist** | ✅ | ✅ | ✅ | +| **API Stability** | ⚠️ Some bugs | ✅ Stable | ✅ Stable | +| **Documentation** | ⚠️ Medium | ✅ Good | ✅ Excellent | + +**Recommendation**: Start with Jellyfin (most requested), then Emby (most stable), then Plex. + +--- + +## 4. Proposed Architecture + +### 4.1 Core Components + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ PlaylistPresenter│──────────│ SyncStatusUI │ │ +│ └──────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ ↓ ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Domain Layer (NEW) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ PlaylistSyncCoordinator │ │ +│ │ - Observes repository changes │ │ +│ │ - Queues sync operations │ │ +│ │ - Handles conflict resolution │ │ +│ │ - Manages sync lifecycle │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ↓ ↓ │ +│ ┌──────────────────────┐ ┌──────────────────────────┐ │ +│ │ SyncQueueRepository │ │ PlaylistSyncStrategy │ │ +│ │ - Persist operations│ │ - Diff algorithm │ │ +│ │ - Priority queue │ │ - Conflict rules │ │ +│ └──────────────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ ↓ ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Data Layer (EXTENDED) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ PlaylistRepository (existing) │ │ +│ │ + Change tracking extension │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ↓ ↓ │ +│ ┌──────────────────────┐ ┌──────────────────────────┐ │ +│ │ Room Database │ │ MediaProvider (extended) │ │ +│ │ + SyncOperation │ │ + PlaylistSyncProvider │ │ +│ │ + SyncState │ │ - pushPlaylist() │ │ +│ └──────────────────────┘ │ - deletePlaylist() │ │ +│ │ - addItems() │ │ +│ │ - removeItems() │ │ +│ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ↓ ↓ ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Network Layer │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │ +│ │JellyfinService │ │EmbyService │ │PlexService │ │ +│ │+ playlist APIs │ │+ playlist APIs │ │+ playlist │ │ +│ └──────────────────┘ └──────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 New Domain Models + +#### SyncOperation (Room Entity) + +```kotlin +// android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/SyncOperation.kt + +@Entity( + tableName = "sync_operations", + indices = [ + Index("playlist_id"), + Index("status"), + Index("priority", "created_at") + ] +) +data class SyncOperation( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + val playlistId: Long, // Local playlist ID + val externalId: String?, // Remote playlist ID + val mediaProviderType: MediaProviderType, + + @Embedded + val operation: Operation, // Sealed class defining the operation + + val status: SyncStatus, + val priority: Int, // Higher = more urgent + + val createdAt: Instant, + val lastAttemptAt: Instant?, + val retryCount: Int = 0, + val maxRetries: Int = 3, + + val errorMessage: String? +) + +sealed class Operation { + data class CreatePlaylist(val name: String, val songIds: List) : Operation() + data class UpdateMetadata(val name: String) : Operation() + data class AddSongs(val songIds: List) : Operation() + data class RemoveSongs(val songIds: List) : Operation() + data class ReorderSongs(val playlistSongIds: List) : Operation() // Ordered list + object DeletePlaylist : Operation() +} + +enum class SyncStatus { + PENDING, // Waiting to be processed + IN_PROGRESS, // Currently syncing + FAILED, // Failed after retries + COMPLETED, // Successfully synced + CANCELLED // User cancelled +} +``` + +#### PlaylistSyncState (Room Entity) + +```kotlin +// Tracks the sync state of each playlist +@Entity(tableName = "playlist_sync_state") +data class PlaylistSyncState( + @PrimaryKey + val playlistId: Long, + + val lastSyncedAt: Instant?, + val localModifiedAt: Instant, + val remoteModifiedAt: Instant?, // From server "LastModified" field + + val syncStatus: PlaylistSyncStatus, + val conflictDetected: Boolean = false, + + // Hash of playlist content for change detection + val localContentHash: String, + val remoteContentHash: String? +) + +enum class PlaylistSyncStatus { + SYNCED, // Local and remote are in sync + LOCAL_AHEAD, // Local has changes not pushed + REMOTE_AHEAD, // Remote has changes not pulled + CONFLICT, // Both have diverged + ERROR // Sync error +} +``` + +### 4.3 Extended Interfaces + +#### PlaylistSyncProvider (New) + +```kotlin +// android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncProvider.kt + +interface PlaylistSyncProvider { + val type: MediaProviderType + + /** + * Create a new playlist on the remote server + * @return externalId of created playlist + */ + suspend fun createPlaylist( + name: String, + songExternalIds: List + ): NetworkResult + + /** + * Update playlist metadata + */ + suspend fun updatePlaylistMetadata( + externalId: String, + name: String + ): NetworkResult + + /** + * Add songs to an existing playlist + * @param externalId Remote playlist ID + * @param songExternalIds Remote song IDs to add + */ + suspend fun addSongsToPlaylist( + externalId: String, + songExternalIds: List + ): NetworkResult + + /** + * Remove songs from a playlist + * @param externalId Remote playlist ID + * @param playlistItemIds Server-specific item IDs (NOT song IDs) + */ + suspend fun removeSongsFromPlaylist( + externalId: String, + playlistItemIds: List + ): NetworkResult + + /** + * Reorder songs in a playlist + * @param externalId Remote playlist ID + * @param orderedPlaylistItemIds Server-specific item IDs in new order + */ + suspend fun reorderPlaylistSongs( + externalId: String, + orderedSongExternalIds: List + ): NetworkResult + + /** + * Delete a playlist from the remote server + */ + suspend fun deletePlaylist(externalId: String): NetworkResult + + /** + * Get the current state of a playlist for conflict detection + * @return Pair of (songExternalIds, lastModified timestamp) + */ + suspend fun getPlaylistState( + externalId: String + ): NetworkResult +} + +data class PlaylistRemoteState( + val externalId: String, + val name: String, + val songExternalIds: List, // Ordered + val lastModified: Instant +) +``` + +#### PlaylistSyncCoordinator (New) + +```kotlin +// android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncCoordinator.kt + +class PlaylistSyncCoordinator( + private val playlistRepository: PlaylistRepository, + private val syncQueueRepository: SyncQueueRepository, + private val syncProviders: Map, + private val syncStrategy: PlaylistSyncStrategy, + @AppCoroutineScope private val scope: CoroutineScope +) { + /** + * Observe playlist changes and queue sync operations + */ + fun observeChanges() { + // Implementation will use interceptor pattern + } + + /** + * Process the sync queue + */ + suspend fun processQueue() { + val pendingOps = syncQueueRepository.getPendingOperations() + .sortedBy { it.priority } + + pendingOps.forEach { operation -> + processSyncOperation(operation) + } + } + + /** + * Force sync a specific playlist + */ + suspend fun syncPlaylist(playlist: Playlist): SyncResult { + // Check for conflicts first + // Apply sync strategy + // Execute operations + } + + /** + * Resolve a conflict for a playlist + */ + suspend fun resolveConflict( + playlist: Playlist, + resolution: ConflictResolution + ): SyncResult + + private suspend fun processSyncOperation(operation: SyncOperation): SyncResult +} +``` + +### 4.4 Data Flow Diagrams + +#### Create Playlist Flow + +``` +User creates playlist in UI + ↓ +PlaylistPresenter.createPlaylist() + ↓ +PlaylistRepository.createPlaylist(mediaProviderType = MediaProviderType.Jellyfin) + ↓ +Room: Insert PlaylistData + PlaylistSongJoin + ↓ +[TRIGGER] PlaylistChangeInterceptor detects insert + ↓ +SyncCoordinator.onPlaylistCreated() + ↓ +SyncQueueRepository.enqueue( + operation = Operation.CreatePlaylist(name, songIds), + priority = HIGH +) + ↓ +[Background Worker] SyncCoordinator.processQueue() + ↓ +JellyfinSyncProvider.createPlaylist(name, songExternalIds) + ↓ +Retrofit: POST /Playlists + ↓ +[Success] Update playlist.externalId, mark operation COMPLETED +[Failure] Mark operation FAILED, schedule retry +``` + +#### Add Songs Flow (with conflict detection) + +``` +User adds song to remote playlist + ↓ +PlaylistRepository.addToPlaylist(playlist, songs) + ↓ +Room: Insert PlaylistSongJoin + ↓ +SyncCoordinator: Check if playlist.externalId exists + ↓ +[YES] Enqueue Operation.AddSongs(songIds) + ↓ +SyncWorker: Check for remote changes first + ↓ +JellyfinSyncProvider.getPlaylistState(externalId) + ↓ +Compare remoteContentHash with local + ↓ +[CONFLICT DETECTED] + ↓ + Mark playlist as CONFLICT + ↓ + Show conflict resolution UI + ↓ + User chooses: LOCAL_WINS / REMOTE_WINS / MERGE + ↓ + Apply resolution strategy + ↓ + Continue sync + +[NO CONFLICT] + ↓ + JellyfinSyncProvider.addSongsToPlaylist(externalId, songExternalIds) + ↓ + Update remoteContentHash + ↓ + Mark COMPLETED +``` + +--- + +## 5. Implementation Strategy + +### 5.1 Phased Approach + +#### Phase 1: Foundation (Week 1-2) + +**Goal**: Establish sync infrastructure without breaking existing functionality + +- [ ] Create database schema for sync operations and state + - Migration to add `sync_operations` table + - Migration to add `playlist_sync_state` table + +- [ ] Define `PlaylistSyncProvider` interface + +- [ ] Implement `SyncQueueRepository` + - CRUD operations for sync queue + - Priority-based retrieval + +- [ ] Create `SyncOperation` and `PlaylistSyncState` Room entities + +- [ ] Set up dependency injection for sync components + +**Success Criteria**: Schema created, tests pass, no impact on existing features + +#### Phase 2: Jellyfin Write APIs (Week 3-4) + +**Goal**: Implement Jellyfin-specific sync operations + +- [ ] Create `JellyfinPlaylistService.kt` with write APIs + ```kotlin + interface JellyfinPlaylistService { + @POST("Playlists") + suspend fun createPlaylist(@Body request: CreatePlaylistRequest): NetworkResult + + @POST("Playlists/{playlistId}/Items") + suspend fun addItems( + @Path("playlistId") playlistId: String, + @Query("ids") itemIds: String, // Comma-separated + @Query("userId") userId: String + ): NetworkResult + + @HTTP(method = "DELETE", path = "Playlists/{playlistId}/Items", hasBody = false) + suspend fun removeItems( + @Path("playlistId") playlistId: String, + @Query("entryIds") entryIds: String // Comma-separated + ): NetworkResult + + @POST("Playlists/{playlistId}/Items/{itemId}/Move/{newIndex}") + suspend fun moveItem(/*...*/): NetworkResult + + @POST("Items/{playlistId}") + suspend fun updateMetadata(@Body request: UpdateRequest): NetworkResult + + @DELETE("Items/{playlistId}") + suspend fun deletePlaylist(@Path("playlistId") playlistId: String): NetworkResult + } + ``` + +- [ ] Implement `JellyfinPlaylistSyncProvider` + - Implement all `PlaylistSyncProvider` methods + - Handle Jellyfin-specific quirks (entry IDs vs item IDs) + - Workaround for deletion bug (fallback to recreate) + +- [ ] Add integration tests with mock server + +**Success Criteria**: All Jellyfin write operations work in isolation + +#### Phase 3: Change Tracking (Week 5) + +**Goal**: Detect when playlists are modified locally + +**Approach**: Repository Interceptor Pattern + +```kotlin +class SyncAwarePlaylistRepository( + private val delegate: PlaylistRepository, + private val syncCoordinator: PlaylistSyncCoordinator +) : PlaylistRepository by delegate { + + override suspend fun createPlaylist( + name: String, + mediaProviderType: MediaProviderType, + songs: List?, + externalId: String? + ): Playlist { + val playlist = delegate.createPlaylist(name, mediaProviderType, songs, externalId) + + // Only sync if it's a remote playlist without externalId yet + if (mediaProviderType.isRemote() && externalId == null) { + syncCoordinator.onPlaylistCreated(playlist) + } + + return playlist + } + + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { + delegate.addToPlaylist(playlist, songs) + + if (playlist.shouldSync()) { + syncCoordinator.onSongsAdded(playlist, songs) + } + } + + // Similar for remove, delete, rename, reorder... +} +``` + +Tasks: +- [ ] Create `SyncAwarePlaylistRepository` wrapper +- [ ] Implement change detection for all mutation operations +- [ ] Wire up via Hilt dependency injection +- [ ] Add feature flag for gradual rollout + +**Success Criteria**: Changes are detected and queued (but not processed yet) + +#### Phase 4: Sync Worker (Week 6) + +**Goal**: Process sync queue in background + +- [ ] Implement `PlaylistSyncCoordinator.processQueue()` + - Retrieve pending operations + - Execute via appropriate `PlaylistSyncProvider` + - Update operation status + - Handle failures and retries + +- [ ] Create WorkManager job for periodic sync + ```kotlin + class PlaylistSyncWorker( + context: Context, + params: WorkerParameters + ) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + return try { + syncCoordinator.processQueue() + Result.success() + } catch (e: Exception) { + if (runAttemptCount < 3) Result.retry() else Result.failure() + } + } + } + ``` + +- [ ] Set up constraints (network required, battery not low) + +- [ ] Add manual "Sync Now" button in settings + +**Success Criteria**: Sync operations execute successfully in background + +#### Phase 5: Conflict Detection (Week 7) + +**Goal**: Handle concurrent modifications gracefully + +- [ ] Implement content hashing for playlists + ```kotlin + fun Playlist.calculateContentHash(songs: List): String { + val content = buildString { + append(name) + songs.forEach { append(it.song.externalId).append(it.sortOrder) } + } + return content.sha256() + } + ``` + +- [ ] Before each sync operation, fetch remote state + +- [ ] Compare hashes to detect conflicts + +- [ ] Implement `PlaylistSyncStrategy` with conflict resolution rules: + ```kotlin + enum class ConflictResolution { + LOCAL_WINS, // Overwrite remote with local + REMOTE_WINS, // Overwrite local with remote + MERGE, // Intelligent merge (union of songs) + MANUAL // Ask user + } + ``` + +**Success Criteria**: Conflicts are detected and handled according to user preference + +#### Phase 6: UI Integration (Week 8) + +**Goal**: Expose sync status to users + +- [ ] Add sync status indicators to playlist UI + - Synced icon (cloud with checkmark) + - Syncing icon (cloud with arrows) + - Conflict icon (cloud with warning) + - Failed icon (cloud with X) + +- [ ] Create conflict resolution dialog + +- [ ] Add "Sync Status" screen in settings + - List of pending operations + - Failed operations with retry option + - Sync statistics + +- [ ] Add sync logs for debugging + +**Success Criteria**: Users can see sync status and resolve conflicts + +#### Phase 7: Emby & Plex (Week 9-10) + +**Goal**: Extend to other media servers + +- [ ] Implement `EmbyPlaylistSyncProvider` + - Similar structure to Jellyfin + - Handle `PlaylistItemId` correctly + +- [ ] Implement `PlexPlaylistSyncProvider` + - Handle URI-based item references + - One-at-a-time removal + +- [ ] Add server-specific settings (enable/disable sync per server) + +**Success Criteria**: All three servers support bidirectional sync + +#### Phase 8: Polish & Optimization (Week 11-12) + +- [ ] Batch operations where possible + - Coalesce multiple adds into one API call + - Debounce rapid changes + +- [ ] Add analytics (non-PII) + - Sync success rate + - Common failure reasons + - Performance metrics + +- [ ] Performance optimization + - Reduce database queries + - Parallelize independent syncs + +- [ ] Comprehensive error handling + - Network timeouts + - Authentication failures + - Rate limiting + +- [ ] Documentation + - User guide + - Developer documentation + - API reference + +**Success Criteria**: Production-ready with <1% failure rate + +### 5.2 Feature Flags + +Use feature flags for gradual rollout: + +```kotlin +object SyncFeatureFlags { + const val ENABLE_SYNC = "enable_playlist_sync" + const val ENABLE_JELLYFIN_SYNC = "enable_jellyfin_playlist_sync" + const val ENABLE_EMBY_SYNC = "enable_emby_playlist_sync" + const val ENABLE_PLEX_SYNC = "enable_plex_playlist_sync" + const val ENABLE_AUTO_CONFLICT_RESOLUTION = "enable_auto_conflict_resolution" +} +``` + +### 5.3 Rollback Strategy + +If critical issues arise: + +1. Disable feature flag → stops all sync operations +2. Sync queue remains intact → can resume later +3. Local database unchanged → no data loss +4. Existing one-way sync still works → fallback behavior + +--- + +## 6. Conflict Resolution + +### 6.1 Conflict Scenarios + +| Scenario | Local State | Remote State | Detection | +|----------|-------------|--------------|-----------| +| **Concurrent Add** | Added song A | Added song B | Hash mismatch | +| **Concurrent Remove** | Removed song A | Removed song B | Hash mismatch | +| **Add vs Remove** | Added song A | Removed song A | Item-level conflict | +| **Concurrent Rename** | "Rock Hits" | "Best Rock" | Name mismatch | +| **Reorder** | Order: A,B,C | Order: C,B,A | Order mismatch | + +### 6.2 Resolution Strategies + +#### Strategy 1: Last-Write-Wins (Timestamp-based) + +```kotlin +fun resolveByTimestamp( + localModified: Instant, + remoteModified: Instant, + localState: PlaylistState, + remoteState: PlaylistState +): PlaylistState { + return if (localModified > remoteModified) { + localState // Push local to remote + } else { + remoteState // Pull remote to local + } +} +``` + +**Pros**: Simple, deterministic +**Cons**: May lose data (one side's changes discarded) + +#### Strategy 2: Union Merge (Additive) + +```kotlin +fun resolveByUnion( + localSongs: List, + remoteSongs: List +): List { + val combined = (localSongs + remoteSongs) + .distinctBy { it.externalId } + .sortedBy { /* preserve order from local or remote */ } + + return combined +} +``` + +**Pros**: No data loss +**Cons**: May create duplicates, unclear ordering + +#### Strategy 3: User Choice (Manual) + +```kotlin +sealed class ConflictResolutionChoice { + object KeepLocal : ConflictResolutionChoice() + object KeepRemote : ConflictResolutionChoice() + data class Custom(val songs: List) : ConflictResolutionChoice() +} +``` + +**Pros**: User control +**Cons**: Requires user intervention, may block sync + +### 6.3 Recommended Strategy + +**Hybrid Approach**: + +1. **Auto-resolve safe conflicts**: + - Union merge for non-overlapping adds + - Remote wins for metadata (name) if local unchanged + +2. **Flag complex conflicts for user**: + - Overlapping removes + - Concurrent renames + - Reorder conflicts + +3. **Provide user preferences**: + ```kotlin + enum class ConflictPreference { + ALWAYS_LOCAL, + ALWAYS_REMOTE, + MERGE_AUTO, + ASK_ME + } + ``` + +--- + +## 7. Migration Path + +### 7.1 Database Migrations + +```kotlin +// Migration 1: Add sync tables +val MIGRATION_X_Y = object : Migration(X, Y) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(""" + CREATE TABLE IF NOT EXISTS sync_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + playlist_id INTEGER NOT NULL, + external_id TEXT, + media_provider_type TEXT NOT NULL, + operation_type TEXT NOT NULL, + operation_data TEXT NOT NULL, + status TEXT NOT NULL, + priority INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_attempt_at INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + error_message TEXT, + FOREIGN KEY(playlist_id) REFERENCES playlist_data(id) ON DELETE CASCADE + ) + """) + + database.execSQL(""" + CREATE INDEX idx_sync_operations_playlist_id + ON sync_operations(playlist_id) + """) + + database.execSQL(""" + CREATE INDEX idx_sync_operations_status + ON sync_operations(status) + """) + + database.execSQL(""" + CREATE TABLE IF NOT EXISTS playlist_sync_state ( + playlist_id INTEGER PRIMARY KEY NOT NULL, + last_synced_at INTEGER, + local_modified_at INTEGER NOT NULL, + remote_modified_at INTEGER, + sync_status TEXT NOT NULL, + conflict_detected INTEGER NOT NULL DEFAULT 0, + local_content_hash TEXT NOT NULL, + remote_content_hash TEXT, + FOREIGN KEY(playlist_id) REFERENCES playlist_data(id) ON DELETE CASCADE + ) + """) + } +} +``` + +### 7.2 Backward Compatibility + +- Existing playlists continue to work as-is +- Sync is opt-in (feature flag + user settings) +- No breaking changes to existing repository interface +- Graceful degradation if server doesn't support an operation + +### 7.3 Data Integrity + +- Use database transactions for atomic operations +- Cascade deletes for cleanup +- Validate external IDs before sync +- Handle orphaned sync operations (playlist deleted locally) + +--- + +## 8. Testing Strategy + +### 8.1 Unit Tests + +- [ ] `PlaylistSyncCoordinator` logic +- [ ] `SyncQueueRepository` CRUD operations +- [ ] Conflict resolution algorithms +- [ ] Content hashing + +### 8.2 Integration Tests + +- [ ] Jellyfin API interactions (MockWebServer) +- [ ] Emby API interactions +- [ ] Plex API interactions +- [ ] Database migrations +- [ ] Repository interceptor + +### 8.3 End-to-End Tests + +- [ ] Create playlist → syncs to server +- [ ] Add song → syncs incrementally +- [ ] Delete playlist → syncs deletion +- [ ] Conflict scenario → resolves correctly +- [ ] Offline → online → sync resumes + +### 8.4 Manual Testing Checklist + +- [ ] Create playlist with 100+ songs +- [ ] Modify same playlist from web UI and app simultaneously +- [ ] Delete playlist from server, ensure app updates +- [ ] Network interruption during sync +- [ ] Authentication expiration during sync +- [ ] Multiple servers active simultaneously + +--- + +## 9. Future Considerations + +### 9.1 Performance Optimizations + +- **Incremental Sync**: Only sync changed portions, not entire playlist +- **Compression**: Compress large sync payloads +- **Caching**: Cache remote playlist states to reduce API calls +- **Batching**: Group multiple operations into single API call where supported + +### 9.2 Advanced Features + +- **Real-time Sync**: WebSocket-based push notifications from server +- **Collaborative Playlists**: Multiple users editing same playlist +- **Sync Profiles**: Different sync strategies per playlist +- **Selective Sync**: Choose which playlists to sync +- **Sync History**: Audit log of all sync operations + +### 9.3 Scalability + +- **Pagination**: Handle playlists with 10,000+ songs +- **Rate Limiting**: Respect server API limits +- **Priority Queues**: User-initiated syncs take precedence +- **Background Sync**: Use WorkManager for efficient battery usage + +### 9.4 Observability + +- **Metrics**: Track sync latency, success rate, conflict rate +- **Logging**: Structured logs for debugging +- **Alerts**: Notify developers of high failure rates +- **Dashboards**: Visualize sync health + +--- + +## 10. Conclusion + +This architecture provides a **solid foundation** for bidirectional playlist synchronization that is: + +✅ **Architecturally Sound**: Follows Clean Architecture and SOLID principles +✅ **Scalable**: Handles multiple servers, thousands of playlists +✅ **Resilient**: Graceful error handling, conflict resolution, offline support +✅ **Maintainable**: Clear separation of concerns, testable components +✅ **Extensible**: Easy to add new media servers or sync strategies +✅ **User-Friendly**: Transparent sync status, minimal user intervention + +### Key Design Decisions + +1. **Repository Interceptor Pattern**: Non-invasive change tracking +2. **Persistent Sync Queue**: Reliable operation across app restarts +3. **Provider Abstraction**: Server-agnostic sync logic +4. **Conflict Detection via Hashing**: Efficient state comparison +5. **Phased Rollout**: Low-risk incremental deployment + +### Next Steps + +1. Review this document with stakeholders +2. Get approval on architecture +3. Create tickets for Phase 1 tasks +4. Begin implementation + +### Open Questions + +1. **User Preference**: Default conflict resolution strategy? +2. **Sync Frequency**: How often to run background sync? (15 min, 1 hour, manual only?) +3. **Scope**: Should smart playlists sync? (Read-only on servers typically) +4. **UX**: Should sync be opt-in per playlist or global setting? + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-11-16 +**Review Status**: Pending Approval +**Estimated Effort**: 12 weeks (1 developer) +**Priority**: High (community requested feature) From b369b910541312f898afce01f970f393165e36bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 06:09:07 +0000 Subject: [PATCH 2/3] Update playlist sync architecture with resolved design decisions Resolved all open questions from architecture review: 1. Default Conflict Resolution: Safe Auto-Merge - Union merge for additive operations (preserve all content) - User prompts only for destructive conflicts - Aligns with user expectation of not losing changes 2. Sync Frequency: Daily (user configurable) - Default: Daily background sync (battery-friendly) - Options: 15 min, hourly, 6 hours, daily, manual only - Manual "Sync Now" button for immediate sync 3. Sync Scope: Global per media server - Settings per server type (Jellyfin/Emby/Plex) - All playlists from enabled servers sync automatically - Simpler UX with less configuration overhead 4. Smart Playlists: No sync - Smart playlists are query-based and read-only - Focus sync effort on regular user playlists - Keep them local-only or read-only from server Additional enhancements: - Added detailed WorkManager configuration with constraints - Added PlaylistSyncPreferences structure for user settings - Expanded conflict resolution examples with scenarios - Added shouldSync() helper to exclude smart playlists - Updated document version to 1.1 with changelog Status: Ready for implementation Addresses: #102 --- PLAYLIST_SYNC_ARCHITECTURE.md | 219 ++++++++++++++++++++++++++++++---- 1 file changed, 195 insertions(+), 24 deletions(-) diff --git a/PLAYLIST_SYNC_ARCHITECTURE.md b/PLAYLIST_SYNC_ARCHITECTURE.md index 5e4171f5..b417e3e6 100644 --- a/PLAYLIST_SYNC_ARCHITECTURE.md +++ b/PLAYLIST_SYNC_ARCHITECTURE.md @@ -759,6 +759,23 @@ class SyncAwarePlaylistRepository( } // Similar for remove, delete, rename, reorder... + + private fun Playlist.shouldSync(): Boolean { + // Only sync regular playlists from remote media providers + return mediaProvider.isRemote() && // Jellyfin, Emby, or Plex + externalId != null && // Has remote identity + !isSmartPlaylist() // Not a smart playlist (query-based) + } + + private fun Playlist.isSmartPlaylist(): Boolean { + // Smart playlists are query-based and shouldn't be synced + // They're typically read-only on servers + return name in listOf( + context.getString(R.string.playlist_title_recently_added), + context.getString(R.string.playlist_title_most_played) + // Add other smart playlist names as needed + ) + } } ``` @@ -797,9 +814,52 @@ Tasks: } ``` +- [ ] Configure WorkManager with daily default + ```kotlin + enum class SyncFrequency(val hours: Long) { + FIFTEEN_MINUTES(0), // 15 min (for paid/power users) + HOURLY(1), + SIX_HOURS(6), + DAILY(24), // DEFAULT + MANUAL(-1) // Only manual sync + } + + fun scheduleSyncWorker(frequency: SyncFrequency) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val request = when (frequency) { + MANUAL -> null // Cancel periodic sync + FIFTEEN_MINUTES -> { + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + } + else -> { + PeriodicWorkRequestBuilder(frequency.hours, TimeUnit.HOURS) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES) + .build() + } + } + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + "playlist_sync", + ExistingPeriodicWorkPolicy.REPLACE, + request + ) + } + ``` + - [ ] Set up constraints (network required, battery not low) - [ ] Add manual "Sync Now" button in settings + - Triggers immediate one-time work request + - Shows progress indicator + - Displays results (X playlists synced, Y conflicts) **Success Criteria**: Sync operations execute successfully in background @@ -867,7 +927,20 @@ Tasks: - Handle URI-based item references - One-at-a-time removal -- [ ] Add server-specific settings (enable/disable sync per server) +- [ ] Add global sync settings per media server + ```kotlin + // Settings UI structure + Settings > Playlist Sync + ├─ Enable Jellyfin Sync: [Toggle] (default: OFF) + ├─ Enable Emby Sync: [Toggle] (default: OFF) + ├─ Enable Plex Sync: [Toggle] (default: OFF) + ├─ Sync Frequency: [Dropdown] (default: Daily) + ├─ Conflict Resolution: [Dropdown] (default: Auto-merge) + └─ [Sync Now] button + ``` + - Global enable/disable per server type + - Simpler than per-playlist configuration + - Can add per-playlist granularity in future if requested **Success Criteria**: All three servers support bidirectional sync @@ -898,20 +971,58 @@ Tasks: **Success Criteria**: Production-ready with <1% failure rate -### 5.2 Feature Flags +### 5.2 Feature Flags & User Settings -Use feature flags for gradual rollout: +**Developer Feature Flags** (for gradual rollout): ```kotlin object SyncFeatureFlags { + // Master kill switch for entire sync feature const val ENABLE_SYNC = "enable_playlist_sync" + + // Per-server rollout flags (can enable for subset of users) const val ENABLE_JELLYFIN_SYNC = "enable_jellyfin_playlist_sync" const val ENABLE_EMBY_SYNC = "enable_emby_playlist_sync" const val ENABLE_PLEX_SYNC = "enable_plex_playlist_sync" + + // Experimental features const val ENABLE_AUTO_CONFLICT_RESOLUTION = "enable_auto_conflict_resolution" } ``` +**User Settings** (per-server enable/disable): + +```kotlin +class PlaylistSyncPreferences(private val prefs: SharedPreferences) { + var jellyfinSyncEnabled: Boolean + get() = prefs.getBoolean("jellyfin_sync_enabled", false) + set(value) = prefs.edit().putBoolean("jellyfin_sync_enabled", value).apply() + + var embySyncEnabled: Boolean + get() = prefs.getBoolean("emby_sync_enabled", false) + set(value) = prefs.edit().putBoolean("emby_sync_enabled", value).apply() + + var plexSyncEnabled: Boolean + get() = prefs.getBoolean("plex_sync_enabled", false) + set(value) = prefs.edit().putBoolean("plex_sync_enabled", value).apply() + + var syncFrequency: SyncFrequency + get() = SyncFrequency.valueOf( + prefs.getString("sync_frequency", SyncFrequency.DAILY.name)!! + ) + set(value) = prefs.edit().putString("sync_frequency", value.name).apply() + + var conflictResolution: ConflictPreference + get() = ConflictPreference.valueOf( + prefs.getString("conflict_preference", ConflictPreference.MERGE_AUTO.name)!! + ) + set(value) = prefs.edit().putString("conflict_preference", value.name).apply() +} +``` + +**Note**: Both feature flag AND user setting must be enabled for sync to operate. +This allows gradual rollout (feature flag) while giving users control (user setting). + ### 5.3 Rollback Strategy If critical issues arise: @@ -988,29 +1099,52 @@ sealed class ConflictResolutionChoice { **Pros**: User control **Cons**: Requires user intervention, may block sync -### 6.3 Recommended Strategy +### 6.3 Recommended Strategy (Implemented) -**Hybrid Approach**: +**Safe Auto-Merge with User Prompts for Complex Conflicts**: -1. **Auto-resolve safe conflicts**: - - Union merge for non-overlapping adds - - Remote wins for metadata (name) if local unchanged +Default behavior prioritizes not losing user data while minimizing interruptions: -2. **Flag complex conflicts for user**: - - Overlapping removes - - Concurrent renames - - Reorder conflicts +1. **Auto-resolve safe conflicts (no user prompt)**: + - ✅ **Union merge** for non-overlapping adds (both sides added different songs) + - ✅ **Preserve all content** - combine local and remote changes + - ✅ **Timestamp-based order** - use most recent modification time to determine song order + - ✅ **Metadata sync** - accept remote metadata (name, description) if local unchanged -3. **Provide user preferences**: +2. **Prompt user for destructive conflicts**: + - ⚠️ **Concurrent renames** - "Playlist renamed to X locally and Y remotely" + - ⚠️ **Concurrent deletions** - "Song removed locally but exists remotely" + - ⚠️ **Complex reorders** - Both sides reordered differently + +3. **User preference setting**: ```kotlin enum class ConflictPreference { - ALWAYS_LOCAL, - ALWAYS_REMOTE, - MERGE_AUTO, - ASK_ME + MERGE_AUTO, // Default: Auto-merge safe, prompt for complex (RECOMMENDED) + ALWAYS_LOCAL, // Always push local changes + ALWAYS_REMOTE, // Always pull remote changes + ASK_ME // Prompt for every conflict } ``` +**Example auto-merge scenario**: +``` +Local: [Song A, Song B, Song C] + adds Song D +Remote: [Song A, Song B, Song C] + adds Song E + +Result: [Song A, Song B, Song C, Song D, Song E] ✅ Auto-merged +``` + +**Example user-prompt scenario**: +``` +Local: Renamed "Rock" → "Best Rock" +Remote: Renamed "Rock" → "Rock Classics" + +Action: Show dialog with options: + - Keep "Best Rock" (local) + - Keep "Rock Classics" (remote) + - Enter custom name +``` + --- ## 7. Migration Path @@ -1178,17 +1312,54 @@ This architecture provides a **solid foundation** for bidirectional playlist syn 3. Create tickets for Phase 1 tasks 4. Begin implementation -### Open Questions +### Design Decisions (Resolved) -1. **User Preference**: Default conflict resolution strategy? -2. **Sync Frequency**: How often to run background sync? (15 min, 1 hour, manual only?) -3. **Scope**: Should smart playlists sync? (Read-only on servers typically) -4. **UX**: Should sync be opt-in per playlist or global setting? +1. **Default Conflict Resolution Strategy**: **Safe Auto-Merge** + - Use union merge for additive operations (both sides added different songs) + - Preserve all content when safe to do so + - Prompt user only for destructive conflicts (concurrent deletions, renames) + - Aligns with user expectation: "don't lose my changes" + +2. **Sync Frequency**: **Daily (user configurable)** + - Default: Daily background sync (battery-friendly, low server load) + - User options: 15 min, 1 hour, 6 hours, daily, manual only + - Immediate sync available via manual "Sync Now" button + - User-initiated changes can trigger immediate sync (debounced) + +3. **Sync Scope**: **Global per media server** + - Settings: "Enable Jellyfin Sync", "Enable Emby Sync", "Enable Plex Sync" + - All playlists from enabled servers sync automatically + - Simpler UX, less configuration overhead + - Can add per-playlist granularity in future if needed + +4. **Smart Playlists**: **No sync** + - Smart playlists are query-based and dynamically generated + - Servers typically don't support modifying smart playlist queries + - Keep them local-only or read-only from server + - Focus sync effort on regular playlists --- -**Document Version**: 1.0 +**Document Version**: 1.1 **Last Updated**: 2025-11-16 -**Review Status**: Pending Approval +**Review Status**: Ready for Implementation **Estimated Effort**: 12 weeks (1 developer) **Priority**: High (community requested feature) + +## Changelog + +### Version 1.1 (2025-11-16) +- ✅ Resolved all open design questions +- Added **Safe Auto-Merge** as default conflict resolution strategy +- Specified **Daily** as default sync frequency (user configurable) +- Clarified **global per-server** sync settings approach +- Excluded **smart playlists** from sync +- Added detailed WorkManager configuration +- Added user settings preferences structure +- Expanded conflict resolution examples + +### Version 1.0 (2025-11-16) +- Initial architecture proposal +- Core component design +- API capability analysis +- Phased implementation plan From 8f8a246412c874d8620f768523193a5891ad22f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 06:53:30 +0000 Subject: [PATCH 3/3] Implement Phase 1: Playlist sync foundation This commit implements the foundational infrastructure for bidirectional playlist synchronization as outlined in PLAYLIST_SYNC_ARCHITECTURE.md. New Components: 1. Room Entities - SyncOperation: Persistent queue for sync operations - PlaylistSyncState: Tracks sync state and conflicts per playlist - Added enums: OperationType, SyncStatus, PlaylistSyncStatus 2. Database Layer - SyncOperationDao: CRUD operations for sync queue - PlaylistSyncStateDao: Sync state management - MIGRATION_40_41: Creates new tables with proper indexes and foreign keys - Updated Converters with Instant and enum type converters 3. Repository Layer - PlaylistSyncProvider: Interface for server-specific sync operations - SyncQueueRepository: Interface for sync queue management - LocalSyncQueueRepository: Room-based implementation 4. Dependency Injection - Added SyncQueueRepository to RepositoryModule - Database updated to version 41 with new entities Features: - Persistent sync queue survives app restarts - Priority-based operation ordering - Retry logic with configurable max retries - Conflict detection via content hashing - Foreign key constraints for data integrity - Comprehensive query support for sync status This establishes the foundation for: - Change tracking (Phase 3) - Background sync worker (Phase 4) - Conflict resolution (Phase 5) - Server-specific implementations (Phase 2, 7) Addresses: #102 Related: PLAYLIST_SYNC_ARCHITECTURE.md Phase 1 --- .../shuttle/di/RepositoryModule.kt | 8 ++ .../sync/PlaylistSyncProvider.kt | 97 +++++++++++++++++++ .../mediaprovider/sync/SyncQueueRepository.kt | 87 +++++++++++++++++ .../local/data/room/Converters.kt | 30 ++++++ .../local/data/room/DatabaseProvider.kt | 4 +- .../data/room/dao/PlaylistSyncStateDao.kt | 43 ++++++++ .../local/data/room/dao/SyncOperationDao.kt | 58 +++++++++++ .../local/data/room/database/MediaDatabase.kt | 14 ++- .../data/room/entity/PlaylistSyncState.kt | 53 ++++++++++ .../local/data/room/entity/SyncOperation.kt | 83 ++++++++++++++++ .../data/room/migrations/MIGRATION_40_41.kt | 53 ++++++++++ .../repository/LocalSyncQueueRepository.kt | 63 ++++++++++++ 12 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncProvider.kt create mode 100644 android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/SyncQueueRepository.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/PlaylistSyncStateDao.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SyncOperationDao.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/PlaylistSyncState.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/SyncOperation.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/migrations/MIGRATION_40_41.kt create mode 100644 android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalSyncQueueRepository.kt diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt index 3ad54333..0ec72c24 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt @@ -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 @@ -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()) } diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncProvider.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncProvider.kt new file mode 100644 index 00000000..528940d1 --- /dev/null +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/PlaylistSyncProvider.kt @@ -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 + ): NetworkResult + + /** + * Update playlist metadata (name, description, etc.). + * + * @param externalId Remote playlist ID + * @param name New playlist name + */ + suspend fun updatePlaylistMetadata( + externalId: String, + name: String + ): NetworkResult + + /** + * 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 + ): NetworkResult + + /** + * 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 + ): NetworkResult + + /** + * 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 + ): NetworkResult + + /** + * Delete a playlist from the remote server. + * + * @param externalId Remote playlist ID + */ + suspend fun deletePlaylist(externalId: String): NetworkResult + + /** + * 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 +} + +/** + * Represents the remote state of a playlist for conflict detection. + */ +data class PlaylistRemoteState( + val externalId: String, + val name: String, + val songExternalIds: List, // Ordered list of song IDs + val lastModified: Instant +) diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/SyncQueueRepository.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/SyncQueueRepository.kt new file mode 100644 index 00000000..f60f8bdc --- /dev/null +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/sync/SyncQueueRepository.kt @@ -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) + + /** + * Get pending operations ordered by priority and creation time. + */ + suspend fun getPendingOperations(): List + + /** + * 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 + + /** + * Get operations by status. + */ + suspend fun getOperationsByStatus(status: SyncStatus): List + + /** + * Observe operations with specific statuses. + */ + fun observeOperationsByStatuses(statuses: List): Flow> + + /** + * 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 +} diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt index 98337b7b..0abe99ec 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt @@ -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 @@ -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) } diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/DatabaseProvider.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/DatabaseProvider.kt index 3e71c465..21849236 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/DatabaseProvider.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/DatabaseProvider.kt @@ -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 @@ -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) { diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/PlaylistSyncStateDao.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/PlaylistSyncStateDao.kt new file mode 100644 index 00000000..a2d11541 --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/PlaylistSyncStateDao.kt @@ -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 + + @Query("SELECT * FROM playlist_sync_state WHERE sync_status = :status") + suspend fun getByStatus(status: PlaylistSyncStatus): List + + @Query("SELECT * FROM playlist_sync_state WHERE conflict_detected = 1") + suspend fun getConflicted(): List + + @Query("SELECT * FROM playlist_sync_state WHERE conflict_detected = 1") + fun observeConflicted(): Flow> + + @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 +} diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SyncOperationDao.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SyncOperationDao.kt new file mode 100644 index 00000000..6359a5fe --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SyncOperationDao.kt @@ -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) + + @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 + + @Query("SELECT * FROM sync_operations WHERE status IN (:statuses) ORDER BY priority DESC, created_at ASC") + fun observeByStatuses(statuses: List): Flow> + + @Query("SELECT * FROM sync_operations WHERE playlist_id = :playlistId AND status = :status") + suspend fun getByPlaylistAndStatus(playlistId: Long, status: SyncStatus): List + + @Query("SELECT * FROM sync_operations WHERE playlist_id = :playlistId") + suspend fun getByPlaylist(playlistId: Long): List + + @Query("SELECT * FROM sync_operations WHERE media_provider_type = :mediaProviderType AND status = :status") + suspend fun getByProviderAndStatus( + mediaProviderType: MediaProviderType, + status: SyncStatus + ): List + + @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): Int +} diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt index a0175b35..af22c748 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt @@ -6,18 +6,24 @@ import androidx.room.TypeConverters import com.simplecityapps.localmediaprovider.local.data.room.Converters import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistDataDao import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistSongJoinDao +import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistSyncStateDao import com.simplecityapps.localmediaprovider.local.data.room.dao.SongDataDao +import com.simplecityapps.localmediaprovider.local.data.room.dao.SyncOperationDao import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistData import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSongJoin +import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSyncState import com.simplecityapps.localmediaprovider.local.data.room.entity.SongData +import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncOperation @Database( entities = [ SongData::class, PlaylistData::class, - PlaylistSongJoin::class + PlaylistSongJoin::class, + SyncOperation::class, + PlaylistSyncState::class ], - version = 40, + version = 41, exportSchema = true ) @TypeConverters(Converters::class) @@ -27,4 +33,8 @@ abstract class MediaDatabase : RoomDatabase() { abstract fun playlistSongJoinDataDao(): PlaylistSongJoinDao abstract fun playlistDataDao(): PlaylistDataDao + + abstract fun syncOperationDao(): SyncOperationDao + + abstract fun playlistSyncStateDao(): PlaylistSyncStateDao } diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/PlaylistSyncState.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/PlaylistSyncState.kt new file mode 100644 index 00000000..f13fd1e0 --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/PlaylistSyncState.kt @@ -0,0 +1,53 @@ +package com.simplecityapps.localmediaprovider.local.data.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +@Entity( + tableName = "playlist_sync_state", + foreignKeys = [ + ForeignKey( + entity = PlaylistData::class, + parentColumns = ["id"], + childColumns = ["playlist_id"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class PlaylistSyncState( + @PrimaryKey + @ColumnInfo(name = "playlist_id") + val playlistId: Long, + + @ColumnInfo(name = "last_synced_at") + val lastSyncedAt: Instant? = null, + + @ColumnInfo(name = "local_modified_at") + val localModifiedAt: Instant, + + @ColumnInfo(name = "remote_modified_at") + val remoteModifiedAt: Instant? = null, + + @ColumnInfo(name = "sync_status") + val syncStatus: PlaylistSyncStatus, + + @ColumnInfo(name = "conflict_detected") + val conflictDetected: Boolean = false, + + @ColumnInfo(name = "local_content_hash") + val localContentHash: String, + + @ColumnInfo(name = "remote_content_hash") + val remoteContentHash: String? = null +) + +enum class PlaylistSyncStatus { + SYNCED, + LOCAL_AHEAD, + REMOTE_AHEAD, + CONFLICT, + ERROR +} diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/SyncOperation.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/SyncOperation.kt new file mode 100644 index 00000000..783e56cc --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/SyncOperation.kt @@ -0,0 +1,83 @@ +package com.simplecityapps.localmediaprovider.local.data.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.simplecityapps.shuttle.model.MediaProviderType +import kotlinx.datetime.Instant + +@Entity( + tableName = "sync_operations", + foreignKeys = [ + ForeignKey( + entity = PlaylistData::class, + parentColumns = ["id"], + childColumns = ["playlist_id"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index("playlist_id"), + Index("status"), + Index(value = ["priority", "created_at"]) + ] +) +data class SyncOperation( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + @ColumnInfo(name = "playlist_id") + val playlistId: Long, + + @ColumnInfo(name = "external_id") + val externalId: String?, + + @ColumnInfo(name = "media_provider_type") + val mediaProviderType: MediaProviderType, + + @ColumnInfo(name = "operation_type") + val operationType: OperationType, + + @ColumnInfo(name = "operation_data") + val operationData: String, // JSON-encoded operation-specific data + + @ColumnInfo(name = "status") + val status: SyncStatus, + + @ColumnInfo(name = "priority") + val priority: Int, + + @ColumnInfo(name = "created_at") + val createdAt: Instant, + + @ColumnInfo(name = "last_attempt_at") + val lastAttemptAt: Instant? = null, + + @ColumnInfo(name = "retry_count") + val retryCount: Int = 0, + + @ColumnInfo(name = "max_retries") + val maxRetries: Int = 3, + + @ColumnInfo(name = "error_message") + val errorMessage: String? = null +) + +enum class OperationType { + CREATE_PLAYLIST, + UPDATE_METADATA, + ADD_SONGS, + REMOVE_SONGS, + REORDER_SONGS, + DELETE_PLAYLIST +} + +enum class SyncStatus { + PENDING, + IN_PROGRESS, + FAILED, + COMPLETED, + CANCELLED +} diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/migrations/MIGRATION_40_41.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/migrations/MIGRATION_40_41.kt new file mode 100644 index 00000000..d681910d --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/migrations/MIGRATION_40_41.kt @@ -0,0 +1,53 @@ +package com.simplecityapps.localmediaprovider.local.data.room.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_40_41 = + object : Migration(40, 41) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create sync_operations table + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS sync_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + playlist_id INTEGER NOT NULL, + external_id TEXT, + media_provider_type TEXT NOT NULL, + operation_type TEXT NOT NULL, + operation_data TEXT NOT NULL, + status TEXT NOT NULL, + priority INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_attempt_at INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + error_message TEXT, + FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + ) + """.trimIndent() + ) + + // Create indexes for sync_operations + db.execSQL("CREATE INDEX IF NOT EXISTS index_sync_operations_playlist_id ON sync_operations(playlist_id)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_sync_operations_status ON sync_operations(status)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_sync_operations_priority_created_at ON sync_operations(priority, created_at)") + + // Create playlist_sync_state table + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS playlist_sync_state ( + playlist_id INTEGER PRIMARY KEY NOT NULL, + last_synced_at INTEGER, + local_modified_at INTEGER NOT NULL, + remote_modified_at INTEGER, + sync_status TEXT NOT NULL, + conflict_detected INTEGER NOT NULL DEFAULT 0, + local_content_hash TEXT NOT NULL, + remote_content_hash TEXT, + FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + ) + """.trimIndent() + ) + } + } diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalSyncQueueRepository.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalSyncQueueRepository.kt new file mode 100644 index 00000000..1f45b0b2 --- /dev/null +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalSyncQueueRepository.kt @@ -0,0 +1,63 @@ +package com.simplecityapps.localmediaprovider.local.repository + +import com.simplecityapps.localmediaprovider.local.data.room.dao.SyncOperationDao +import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncOperation +import com.simplecityapps.localmediaprovider.local.data.room.entity.SyncStatus +import com.simplecityapps.mediaprovider.sync.SyncQueueRepository +import com.simplecityapps.shuttle.model.MediaProviderType +import kotlinx.coroutines.flow.Flow + +/** + * Local implementation of SyncQueueRepository using Room database. + */ +class LocalSyncQueueRepository( + private val syncOperationDao: SyncOperationDao +) : SyncQueueRepository { + + override suspend fun enqueue(operation: SyncOperation): Long = syncOperationDao.insert(operation) + + override suspend fun enqueueAll(operations: List) { + syncOperationDao.insertAll(operations) + } + + override suspend fun getPendingOperations(): List = syncOperationDao.getByStatus(SyncStatus.PENDING) + + override suspend fun getOperation(operationId: Long): SyncOperation? = syncOperationDao.get(operationId) + + override suspend fun updateOperation(operation: SyncOperation) { + syncOperationDao.update(operation) + } + + override suspend fun getOperationsByPlaylist(playlistId: Long): List = syncOperationDao.getByPlaylist(playlistId) + + override suspend fun getOperationsByStatus(status: SyncStatus): List = syncOperationDao.getByStatus(status) + + override fun observeOperationsByStatuses(statuses: List): Flow> = syncOperationDao.observeByStatuses(statuses) + + override suspend fun deleteOperation(operationId: Long) { + syncOperationDao.delete(operationId) + } + + override suspend fun deleteOperationsByPlaylist(playlistId: Long) { + syncOperationDao.deleteByPlaylist(playlistId) + } + + override suspend fun deleteOperationsByStatus(status: SyncStatus) { + syncOperationDao.deleteByStatus(status) + } + + override suspend fun countByStatus(status: SyncStatus): Int = syncOperationDao.countByStatus(status) + + override suspend fun hasPendingOperations(playlistId: Long): Boolean { + val count = syncOperationDao.countByPlaylistAndStatuses( + playlistId, + listOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) + ) + return count > 0 + } + + override suspend fun getOperationsByProvider( + mediaProviderType: MediaProviderType, + status: SyncStatus + ): List = syncOperationDao.getByProviderAndStatus(mediaProviderType, status) +}