@@ -4,6 +4,7 @@ import android.content.Context
44import android.content.SharedPreferences
55import androidx.security.crypto.EncryptedSharedPreferences
66import androidx.security.crypto.MasterKey
7+ import com.theveloper.pixelplay.R
78import com.theveloper.pixelplay.data.database.AlbumEntity
89import com.theveloper.pixelplay.data.database.ArtistEntity
910import com.theveloper.pixelplay.data.database.MusicDao
@@ -25,18 +26,25 @@ import com.theveloper.pixelplay.data.stream.BulkSyncResult
2526import com.theveloper.pixelplay.data.stream.CloudMusicUtils
2627import dagger.hilt.android.qualifiers.ApplicationContext
2728import kotlinx.coroutines.Dispatchers
29+ import kotlinx.coroutines.async
30+ import kotlinx.coroutines.awaitAll
31+ import kotlinx.coroutines.coroutineScope
2832import kotlinx.coroutines.flow.Flow
2933import kotlinx.coroutines.flow.MutableStateFlow
3034import kotlinx.coroutines.flow.StateFlow
3135import kotlinx.coroutines.flow.asStateFlow
3236import kotlinx.coroutines.flow.first
3337import kotlinx.coroutines.flow.map
38+ import kotlinx.coroutines.sync.Semaphore
39+ import kotlinx.coroutines.sync.withPermit
3440import kotlinx.coroutines.withContext
3541import org.json.JSONObject
3642import timber.log.Timber
43+ import java.util.concurrent.atomic.AtomicInteger
3744import javax.inject.Inject
3845import javax.inject.Singleton
3946import kotlin.math.absoluteValue
47+ import androidx.core.content.edit
4048
4149/* *
4250 * Repository for Navidrome/Subsonic music service.
@@ -51,12 +59,14 @@ class NavidromeRepository @Inject constructor(
5159 private val playlistPreferencesRepository : PlaylistPreferencesRepository ,
5260 @ApplicationContext private val context : Context
5361) {
54- private companion object {
62+ companion object {
63+ const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours
5564 private const val TAG = " NavidromeRepo"
5665 private const val PREFS_NAME = " navidrome_prefs"
5766 private const val KEY_SERVER_URL = " server_url"
5867 private const val KEY_USERNAME = " username"
5968 private const val KEY_PASSWORD = " password"
69+ private const val KEY_LAST_FULL_SYNC = " last_full_sync"
6070
6171 // ID offsets for unified library (following Netease: 3-5, QQ: 6-8)
6272 // Using negative offsets to prevent collisions with MediaStore IDs
@@ -135,6 +145,10 @@ class NavidromeRepository @Inject constructor(
135145 val username: String?
136146 get() = prefs.getString(KEY_USERNAME , null )
137147
148+ var lastFullSyncTime: Long
149+ get() = prefs.getLong(KEY_LAST_FULL_SYNC , 0L )
150+ set(value) = prefs.edit { putLong(KEY_LAST_FULL_SYNC , value) }
151+
138152 /* *
139153 * Login to Navidrome server with credentials.
140154 *
@@ -166,11 +180,11 @@ class NavidromeRepository @Inject constructor(
166180 }
167181
168182 // Save credentials
169- prefs.edit()
170- . putString(KEY_SERVER_URL , credentials.normalizedServerUrl)
171- .putString(KEY_USERNAME , username)
172- .putString(KEY_PASSWORD , password)
173- . apply ()
183+ prefs.edit {
184+ putString(KEY_SERVER_URL , credentials.normalizedServerUrl)
185+ .putString(KEY_USERNAME , username)
186+ .putString(KEY_PASSWORD , password)
187+ }
174188
175189 _isLoggedInFlow .value = true
176190 Timber .d(" $TAG : Login successful for $username @$serverUrl " )
@@ -190,7 +204,7 @@ class NavidromeRepository @Inject constructor(
190204 suspend fun logout () {
191205 Timber .d(" $TAG : Logging out" )
192206 api.clearCredentials()
193- prefs.edit(). clear(). apply ()
207+ prefs.edit { clear() }
194208
195209 // Delete all Navidrome playlists from database
196210 val playlistsToDelete = dao.getAllPlaylistsList()
@@ -363,7 +377,9 @@ class NavidromeRepository @Inject constructor(
363377 /* *
364378 * Sync all songs from the server library by fetching all albums.
365379 */
366- suspend fun syncLibrarySongs (): Result <Int > {
380+ suspend fun syncLibrarySongs (
381+ onProgress : ((Float , String ) -> Unit )? = null
382+ ): Result <Int > {
367383 if (! isLoggedIn) {
368384 return Result .failure(Exception (" Not logged in" ))
369385 }
@@ -373,30 +389,59 @@ class NavidromeRepository @Inject constructor(
373389 Timber .d(" $TAG : Syncing library songs from server" )
374390 val allSongs = mutableListOf<NavidromeSong >()
375391 val pageSize = 500
392+
393+ onProgress?.invoke(0.1f , context.getString(R .string.dash_status_fetching_albums))
376394 val fetchedAlbums = fetchAllAlbums(pageSize)
377395
378- // Fetch songs for each album
379- for (albumJson in fetchedAlbums) {
380- val albumId = albumJson.optString(" id" , " " )
381- if (albumId.isBlank()) continue
382-
383- val songsResult = api.getAlbum(albumId)
384- songsResult.fold(
385- onSuccess = { songJsons ->
386- val songs = NavidromeResponseParser .parseSongs(songJsons)
387- allSongs.addAll(songs)
388- },
389- onFailure = {
390- Timber .w(it, " $TAG : Failed to fetch songs for album $albumId " )
396+ // Fetch songs for each album in parallel
397+ val totalAlbums = fetchedAlbums.size
398+ val concurrencyLimit = 5
399+ val semaphore = Semaphore (concurrencyLimit)
400+ val processedCount = AtomicInteger (0 )
401+
402+ val albumSongLists = coroutineScope {
403+ fetchedAlbums.map { albumJson ->
404+ async {
405+ semaphore.withPermit {
406+ val albumId = albumJson.optString(" id" , " " )
407+ val albumTitle = albumJson.optString(" title" , " Unknown Album" )
408+ if (albumId.isBlank()) return @withPermit emptyList()
409+
410+ val songsResult = api.getAlbum(albumId)
411+ val currentProcessed = processedCount.incrementAndGet()
412+
413+ val progress = 0.1f + (currentProcessed.toFloat() / totalAlbums.coerceAtLeast(1 ) * 0.8f )
414+ onProgress?.invoke(
415+ progress,
416+ context.getString(R .string.dash_status_fetching_songs_from_format, albumTitle)
417+ )
418+
419+ songsResult.fold(
420+ onSuccess = { songJsons ->
421+ NavidromeResponseParser .parseSongs(songJsons)
422+ },
423+ onFailure = {
424+ Timber .w(it, " $TAG : Failed to fetch songs for album $albumId " )
425+ emptyList()
426+ }
427+ )
428+ }
391429 }
392- )
430+ }.awaitAll( )
393431 }
394432
433+ allSongs.addAll(albumSongLists.flatten())
434+
395435 if (allSongs.isEmpty()) {
396436 Timber .d(" $TAG : No library songs found on server" )
437+ onProgress?.invoke(1f , context.getString(R .string.dash_status_no_songs_found))
397438 return @withContext Result .success(0 )
398439 }
399440
441+ onProgress?.invoke(
442+ 0.95f ,
443+ context.getString(R .string.dash_status_saving_songs_format, allSongs.size)
444+ )
400445 // Deduplicate by song ID
401446 val uniqueSongs = allSongs.distinctBy { it.id }
402447
@@ -409,6 +454,7 @@ class NavidromeRepository @Inject constructor(
409454 dao.insertSongs(entities)
410455
411456 Timber .d(" $TAG : Synced ${entities.size} library songs from ${fetchedAlbums.size} albums" )
457+ onProgress?.invoke(1f , context.getString(R .string.dash_status_library_sync_complete))
412458 Result .success(entities.size)
413459 } catch (e: Exception ) {
414460 Timber .e(e, " $TAG : Failed to sync library songs" )
@@ -445,18 +491,25 @@ class NavidromeRepository @Inject constructor(
445491 /* *
446492 * Sync all playlists and their songs, plus library songs.
447493 */
448- suspend fun syncAllPlaylistsAndSongs (): Result <BulkSyncResult > {
494+ suspend fun syncAllPlaylistsAndSongs (
495+ onProgress : ((Float , String ) -> Unit )? = null
496+ ): Result <BulkSyncResult > {
449497 return withContext(Dispatchers .IO ) {
450498 var syncedSongCount = 0
451499 var failedPlaylistCount = 0
452500
501+ onProgress?.invoke(0.05f , context.getString(R .string.dash_status_syncing_library))
453502 // Sync library songs (all albums)
454- val libResult = syncLibrarySongs()
503+ val libResult = syncLibrarySongs { progress, message ->
504+ // Map library sync progress (0-1) to 0.05-0.4 range
505+ onProgress?.invoke(0.05f + (progress * 0.35f ), message)
506+ }
455507 libResult.fold(
456508 onSuccess = { count -> syncedSongCount + = count },
457509 onFailure = { Timber .w(it, " $TAG : Failed syncing library songs" ) }
458510 )
459511
512+ onProgress?.invoke(0.4f , context.getString(R .string.dash_status_fetching_playlists))
460513 // Sync playlists
461514 val playlistResult = syncPlaylists().getOrElse {
462515 // Playlists failed but library songs may have synced
@@ -474,7 +527,17 @@ class NavidromeRepository @Inject constructor(
474527 )
475528 }
476529
477- playlistResult.forEach { playlist ->
530+ val totalPlaylists = playlistResult.size
531+ playlistResult.forEachIndexed { index, playlist ->
532+ val progressBase = 0.4f
533+ val progressStep = 0.5f / totalPlaylists.coerceAtLeast(1 )
534+ val currentProgress = progressBase + (index * progressStep)
535+
536+ onProgress?.invoke(
537+ currentProgress,
538+ context.getString(R .string.dash_status_syncing_playlist_format, playlist.name)
539+ )
540+
478541 val songSyncResult = syncPlaylistSongs(playlist.id)
479542 songSyncResult.fold(
480543 onSuccess = { count -> syncedSongCount + = count },
@@ -485,13 +548,20 @@ class NavidromeRepository @Inject constructor(
485548 )
486549 }
487550
551+ onProgress?.invoke(0.95f , context.getString(R .string.dash_status_updating_local))
488552 // Sync to unified library once after everything is synced
489553 try {
490554 syncUnifiedLibrarySongsFromNavidrome()
491555 } catch (e: Exception ) {
492556 Timber .e(e, " $TAG : Failed to sync unified library" )
493557 }
494558
559+ onProgress?.invoke(1f , context.getString(R .string.dash_status_sync_complete))
560+
561+ if (failedPlaylistCount == 0 ) {
562+ lastFullSyncTime = System .currentTimeMillis()
563+ }
564+
495565 Result .success(
496566 BulkSyncResult (
497567 playlistCount = playlistResult.size,
@@ -758,7 +828,7 @@ class NavidromeRepository @Inject constructor(
758828
759829 // ─── App Playlist Management ───────────────────────────────────────────
760830
761- private suspend fun getAppPlaylistIdForNavidrome (navidromePlaylistId : String ): String {
831+ private fun getAppPlaylistIdForNavidrome (navidromePlaylistId : String ): String {
762832 return " $NAVIDROME_PLAYLIST_PREFIX$navidromePlaylistId "
763833 }
764834
0 commit comments