Skip to content

Commit b279ce7

Browse files
committed
Merge remote-tracking branch 'origin/master'
2 parents b59fa33 + 1f25e89 commit b279ce7

8 files changed

Lines changed: 348 additions & 95 deletions

File tree

app/src/main/java/com/theveloper/pixelplay/data/navidrome/NavidromeRepository.kt

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import android.content.SharedPreferences
55
import androidx.security.crypto.EncryptedSharedPreferences
66
import androidx.security.crypto.MasterKey
7+
import com.theveloper.pixelplay.R
78
import com.theveloper.pixelplay.data.database.AlbumEntity
89
import com.theveloper.pixelplay.data.database.ArtistEntity
910
import com.theveloper.pixelplay.data.database.MusicDao
@@ -25,18 +26,25 @@ import com.theveloper.pixelplay.data.stream.BulkSyncResult
2526
import com.theveloper.pixelplay.data.stream.CloudMusicUtils
2627
import dagger.hilt.android.qualifiers.ApplicationContext
2728
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.async
30+
import kotlinx.coroutines.awaitAll
31+
import kotlinx.coroutines.coroutineScope
2832
import kotlinx.coroutines.flow.Flow
2933
import kotlinx.coroutines.flow.MutableStateFlow
3034
import kotlinx.coroutines.flow.StateFlow
3135
import kotlinx.coroutines.flow.asStateFlow
3236
import kotlinx.coroutines.flow.first
3337
import kotlinx.coroutines.flow.map
38+
import kotlinx.coroutines.sync.Semaphore
39+
import kotlinx.coroutines.sync.withPermit
3440
import kotlinx.coroutines.withContext
3541
import org.json.JSONObject
3642
import timber.log.Timber
43+
import java.util.concurrent.atomic.AtomicInteger
3744
import javax.inject.Inject
3845
import javax.inject.Singleton
3946
import 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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.theveloper.pixelplay.data.worker
2+
3+
import android.content.Context
4+
import androidx.hilt.work.HiltWorker
5+
import androidx.work.CoroutineWorker
6+
import androidx.work.OneTimeWorkRequestBuilder
7+
import androidx.work.WorkerParameters
8+
import androidx.work.workDataOf
9+
import com.theveloper.pixelplay.data.navidrome.NavidromeRepository
10+
import dagger.assisted.Assisted
11+
import dagger.assisted.AssistedInject
12+
import timber.log.Timber
13+
14+
@HiltWorker
15+
class NavidromeSyncWorker @AssistedInject constructor(
16+
@Assisted appContext: Context,
17+
@Assisted workerParams: WorkerParameters,
18+
private val repository: NavidromeRepository
19+
) : CoroutineWorker(appContext, workerParams) {
20+
21+
override suspend fun doWork(): Result {
22+
val syncType = inputData.getString(KEY_SYNC_TYPE) ?: SYNC_TYPE_ALL
23+
val playlistId = inputData.getString(KEY_PLAYLIST_ID)
24+
25+
Timber.d("NavidromeSyncWorker: Starting sync (type=$syncType, playlistId=$playlistId)")
26+
27+
return try {
28+
when (syncType) {
29+
SYNC_TYPE_ALL -> {
30+
repository.syncAllPlaylistsAndSongs { progress, message ->
31+
setProgressAsync(
32+
workDataOf(
33+
PROGRESS_VALUE to progress,
34+
PROGRESS_MESSAGE to message
35+
)
36+
)
37+
}
38+
}
39+
SYNC_TYPE_PLAYLISTS -> {
40+
repository.syncPlaylists()
41+
}
42+
SYNC_TYPE_PLAYLIST_SONGS -> {
43+
if (playlistId != null) {
44+
repository.syncPlaylistSongs(playlistId)
45+
repository.syncUnifiedLibrarySongsFromNavidrome()
46+
}
47+
}
48+
}
49+
Result.success()
50+
} catch (e: Exception) {
51+
Timber.e(e, "NavidromeSyncWorker: Sync failed")
52+
Result.failure(workDataOf(ERROR_MESSAGE to e.message))
53+
}
54+
}
55+
56+
companion object {
57+
const val KEY_SYNC_TYPE = "sync_type"
58+
const val KEY_PLAYLIST_ID = "playlist_id"
59+
60+
const val SYNC_TYPE_ALL = "all"
61+
const val SYNC_TYPE_PLAYLISTS = "playlists"
62+
const val SYNC_TYPE_PLAYLIST_SONGS = "playlist_songs"
63+
64+
const val PROGRESS_VALUE = "progress_value"
65+
const val PROGRESS_MESSAGE = "progress_message"
66+
const val ERROR_MESSAGE = "error_message"
67+
68+
fun startAllSync() = OneTimeWorkRequestBuilder<NavidromeSyncWorker>()
69+
.setInputData(workDataOf(KEY_SYNC_TYPE to SYNC_TYPE_ALL))
70+
.build()
71+
72+
fun startPlaylistSync(playlistId: String) = OneTimeWorkRequestBuilder<NavidromeSyncWorker>()
73+
.setInputData(
74+
workDataOf(
75+
KEY_SYNC_TYPE to SYNC_TYPE_PLAYLIST_SONGS,
76+
KEY_PLAYLIST_ID to playlistId
77+
)
78+
)
79+
.build()
80+
}
81+
}

0 commit comments

Comments
 (0)