diff --git a/AGENTS.md b/AGENTS.md index 5e3b046a..78e5539a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand. - Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default. - Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table. - The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected. +- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running. - Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL. - UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests. - UI and playback preferences go in DataStore (`UserPreferences`), not Room. diff --git a/app/src/main/kotlin/com/subtracks/MainActivity.kt b/app/src/main/kotlin/com/subtracks/MainActivity.kt index e94fddf1..f86fab49 100644 --- a/app/src/main/kotlin/com/subtracks/MainActivity.kt +++ b/app/src/main/kotlin/com/subtracks/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.content.ContextCompat +import com.subtracks.data.sync.AutoSync import com.subtracks.ui.NowPlayingLauncher import com.subtracks.ui.SubtracksRoot import com.subtracks.ui.theme.SubtracksTheme @@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext class MainActivity : ComponentActivity() { private val launchViewModel: NowPlayingLaunchViewModel by viewModels() private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() } + private val autoSync: AutoSync by lazy { GlobalContext.get().get() } private val requestNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + override fun onStart() { + super.onStart() + autoSync.setForeground(true) + } + + override fun onStop() { + super.onStop() + autoSync.setForeground(false) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestNotificationPermission() diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 4caf84a0..407aa0b7 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.subtracks.data.net.NetworkMode @@ -48,6 +49,10 @@ data class StreamQuality( private val Context.preferences: DataStore by preferencesDataStore("user_prefs") +const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60 + +enum class SyncMode { Off, WifiOnly, Always } + data class ArtworkSeedValue( val cacheKey: String, val primary: Int, @@ -105,6 +110,34 @@ class UserPreferences( private val syncConcurrencyKey = intPreferencesKey("sync_concurrency") + fun syncMode(): Flow = + store.data.map { prefs -> + prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly + } + + suspend fun setSyncMode(mode: SyncMode) { + store.edit { prefs -> prefs[syncModeKey] = mode.name } + } + + private val syncModeKey = stringPreferencesKey("sync_mode") + + fun lastSyncAt(): Flow = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L } + + suspend fun setLastSyncAt(value: Long) { + store.edit { prefs -> prefs[lastSyncAtKey] = value } + } + + private val lastSyncAtKey = longPreferencesKey("last_sync_at") + + fun syncIntervalMinutes(): Flow = + store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) } + + suspend fun setSyncIntervalMinutes(value: Int) { + store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) } + } + + private val syncIntervalKey = intPreferencesKey("sync_interval_minutes") + fun scrobbling(): Flow = store.data.map { prefs -> prefs[scrobblingKey] ?: true } suspend fun setScrobbling(enabled: Boolean) { diff --git a/app/src/main/kotlin/com/subtracks/data/sync/AutoSync.kt b/app/src/main/kotlin/com/subtracks/data/sync/AutoSync.kt new file mode 100644 index 00000000..8d53b102 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/sync/AutoSync.kt @@ -0,0 +1,108 @@ +package com.subtracks.data.sync + +import com.subtracks.data.net.NetworkMode +import com.subtracks.data.prefs.SyncMode +import com.subtracks.data.prefs.UserPreferences +import com.subtracks.log.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000 + +internal fun shouldAutoSync( + mode: SyncMode, + network: NetworkMode, + offline: Boolean, + hasSource: Boolean, + lastSyncAt: Long, + now: Long, + intervalMs: Long, +): Boolean { + val allowed = + when (mode) { + SyncMode.Off -> false + SyncMode.WifiOnly -> network == NetworkMode.Wifi + SyncMode.Always -> true + } + return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs) +} + +class AutoSync( + private val preferences: UserPreferences, + private val networkMode: Flow, + private val hasSource: suspend () -> Boolean, + private val requestSync: () -> Unit, + private val scope: CoroutineScope, + private val now: () -> Long = System::currentTimeMillis, + private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS, +) { + @Volatile + private var lastNetwork: NetworkMode? = null + private var loop: Job? = null + + init { + scope.launch { networkMode.collect { lastNetwork = it } } + } + + fun setForeground(active: Boolean) { + if (active) start() else stop() + } + + private fun start() { + if (loop?.isActive == true) return + loop = + scope.launch { + while (isActive) { + val intervalMs = + try { + checkAndRequest() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Exception) { + Log.w("sync", "auto-sync check failed", failure) + maxPollMs + } + delay(intervalMs.coerceAtMost(maxPollMs)) + } + } + } + + private fun stop() { + loop?.cancel() + loop = null + } + + private suspend fun checkAndRequest(): Long { + val mode = preferences.syncMode().first() + val network = lastNetwork ?: networkMode.first() + val offline = preferences.offlineMode().first() + val hasSource = hasSource() + val lastSyncAt = preferences.lastSyncAt().first() + val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L + val time = now() + val due = + shouldAutoSync( + mode = mode, + network = network, + offline = offline, + hasSource = hasSource, + lastSyncAt = lastSyncAt, + now = time, + intervalMs = intervalMs, + ) + Log.d( + "sync", + "auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " + + "sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)", + ) + if (due && currentCoroutineContext().isActive) requestSync() + return intervalMs + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt index a0f19b57..5ea1e779 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -3,6 +3,7 @@ package com.subtracks.data.sync import com.subtracks.R import com.subtracks.UiMessage import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.data.source.ServerActionSink @@ -13,14 +14,18 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch sealed interface SyncStatus { data object Idle : SyncStatus - data object Running : SyncStatus + data class Running( + val silent: Boolean, + ) : SyncStatus data object Success : SyncStatus @@ -34,31 +39,47 @@ class SyncManager( private val sourceRepository: SourceRepository, private val queueRepository: QueueRepository, private val serverActions: ServerActionSink, + private val preferences: UserPreferences, private val showMessage: (UiMessage) -> Unit = {}, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { - private val requests = Channel(Channel.CONFLATED) + private val requests = Channel(Channel.CONFLATED) private val _status = MutableStateFlow(SyncStatus.Idle) val status: StateFlow = _status init { scope.launch { - for (ignored in requests) runSync() + for (silent in requests) { + try { + runSync(silent) + } catch (cancellation: CancellationException) { + if (!currentCoroutineContext().isActive) throw cancellation + _status.value = SyncStatus.Idle + Log.w("sync", "sync interrupted", cancellation) + } catch (failure: Throwable) { + _status.value = SyncStatus.Idle + Log.w("sync", "unexpected failure", failure) + } + } } } - fun requestSync() { - requests.trySend(Unit) + fun requestSync(silent: Boolean = false) { + requests.trySend(silent) } - private suspend fun runSync() { + private suspend fun runSync(silent: Boolean) { if (sourceRepository.offline.value) { - val offline = UiMessage(R.string.sync_offline) - showMessage(offline) - _status.value = SyncStatus.Failed(offline) + if (!silent) { + val offline = UiMessage(R.string.sync_offline) + showMessage(offline) + _status.value = SyncStatus.Failed(offline) + } return } - _status.value = SyncStatus.Running + _status.value = SyncStatus.Running(silent) + runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) } + .onFailure { Log.w("sync", "could not record the sync attempt", it) } val startedAt = System.nanoTime() val result = try { @@ -88,7 +109,7 @@ class SyncManager( ?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: "")) } } - showMessage(message) + if (!silent) showMessage(message) SyncStatus.Failed(message) } queueRepository.invalidateLibraryCache() diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncNotifier.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncNotifier.kt new file mode 100644 index 00000000..87c965e7 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncNotifier.kt @@ -0,0 +1,72 @@ +package com.subtracks.data.sync + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.subtracks.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class SyncNotifier( + private val context: Context, + private val status: Flow, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + private val manager = NotificationManagerCompat.from(context) + + fun start() { + createChannel() + scope.launch { + status.collect { syncStatus -> + if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) { + manager.notify(ID, build()) + } else { + manager.cancel(ID) + } + } + } + } + + private fun build(): Notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle(context.getString(R.string.sync_notification_title)) + .setContentText(context.getString(R.string.sync_notification_text)) + .setOnlyAlertOnce(true) + .setSilent(true) + .setProgress(0, 0, true) + .setContentIntent(launchIntent()) + .build() + + private fun launchIntent(): PendingIntent? = + context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.sync_notification_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + } + + companion object { + private const val ID = 2 + private const val CHANNEL_ID = "sync" + } +} diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 84856cb0..ce15c517 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -27,7 +27,9 @@ import com.subtracks.data.repo.PendingServerActionSink import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.data.source.ServerActionSink +import com.subtracks.data.sync.AutoSync import com.subtracks.data.sync.SyncManager +import com.subtracks.data.sync.SyncNotifier import com.subtracks.log.Log import com.subtracks.playback.MediaSessionConnection import com.subtracks.playback.PlaybackController @@ -96,7 +98,16 @@ fun appModule( } single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) } single { ArtworkSeedRepository(get()) } - single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) } + single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) } + single { + AutoSync( + preferences = get(), + networkMode = networkMode(context.applicationContext), + hasSource = { get().activeSourceIdOnce() != null }, + requestSync = { get().requestSync(silent = true) }, + scope = get(), + ) + } single { QueueRepository(get()) } single { ArtworkStore(downloadsRoot(context)) } single { OkHttpArtworkFetcher(get()) } @@ -115,6 +126,7 @@ fun appModule( ).also { it.start() } } single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } } + single(createdAtStart = true) { SyncNotifier(context.applicationContext, get().status, scope = get()).also { it.start() } } single { MediaSessionConnection(context.applicationContext, get(), get()) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) } single(createdAtStart = true) { diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index 74548806..c1ae8d12 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -53,11 +52,6 @@ class HomeViewModel( private val syncManager: SyncManager, private val playbackController: PlaybackController, ) : ViewModel() { - val syncing: StateFlow = - syncManager.status - .map { it == SyncStatus.Running } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - val offline: StateFlow = sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) @@ -141,7 +135,9 @@ class HomeViewModel( viewModelScope.playDownloadedList(libraryRepository, playbackController, song) } - fun sync() = syncManager.requestSync() + fun sync() { + if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync() + } private data class PlayRows( val recentAlbums: List, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 1a50a15c..533c0542 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Remove import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -243,6 +244,7 @@ fun LibraryRoute( var previousTabName by remember { mutableStateOf(selectedTab.name) } val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums val syncing by viewModel.syncing.collectAsStateWithLifecycle() + val refreshing by viewModel.refreshing.collectAsStateWithLifecycle() val listTab = selectedTab.listTab() LaunchedEffect(selectedTab) { if (previousTab != selectedTab) { @@ -296,6 +298,7 @@ fun LibraryRoute( selectedTab = selectedTab, onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) }, syncing = syncing, + refreshing = refreshing, bottomInset = bottomInset, artwork = artwork, albums = viewModel.albums, @@ -392,6 +395,7 @@ fun LibraryScreen( onExitOffline: () -> Unit = {}, onItemLongClick: (MenuTarget) -> Unit = {}, syncing: Boolean = false, + refreshing: Boolean = false, bottomInset: Dp = 0.dp, listQuery: ListQuery = ListQuery(""), resetKeys: Map = emptyMap(), @@ -474,13 +478,13 @@ fun LibraryScreen( val scrollToTopKey = if (pageTab == selectedTab) topRequest else null val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( - isRefreshing = syncing, + isRefreshing = refreshing, onRefresh = onSync, state = pullToRefreshState, indicator = { PullToRefreshDefaults.Indicator( state = pullToRefreshState, - isRefreshing = syncing, + isRefreshing = refreshing, modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(), containerColor = headerColor, color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary, @@ -582,6 +586,7 @@ fun LibraryScreen( offline = offline, onExitOffline = onExitOffline, artwork = artwork, + syncing = syncing, ) } } @@ -848,6 +853,7 @@ internal fun LibraryTabs( offline: Boolean = false, onExitOffline: () -> Unit = {}, artwork: ArtworkColors?, + syncing: Boolean = false, ) { val indicatorStretch = 18.dp val density = LocalDensity.current @@ -939,11 +945,20 @@ internal fun LibraryTabs( } } IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.navigation_tabs_settings), - tint = MaterialTheme.colorScheme.onBackground, - ) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = stringResource(R.string.navigation_tabs_settings), + tint = MaterialTheme.colorScheme.onBackground, + ) + if (syncing) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 7e723bce..56cd504f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -117,7 +117,12 @@ class LibraryViewModel( val syncing: StateFlow = syncManager.status - .map { it == SyncStatus.Running } + .map { it is SyncStatus.Running } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + val refreshing: StateFlow = + syncManager.status + .map { it is SyncStatus.Running && !it.silent } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) private val activeSourceId = libraryRepository.activeSourceId.filterNotNull() @@ -206,7 +211,9 @@ class LibraryViewModel( thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) - fun sync() = syncManager.requestSync() + fun sync() { + if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync() + } } internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 607a0f6e..58b95948 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -65,7 +65,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.BuildConfig import com.subtracks.R import com.subtracks.data.model.Source +import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.StreamQuality +import com.subtracks.data.prefs.SyncMode import com.subtracks.log.Log import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.rememberViewportFill @@ -82,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks" private const val SUPPORT_URL = "https://ko-fi.com/austinried" -private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency } +private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode } @Composable fun SettingsRoute( @@ -99,6 +101,8 @@ fun SettingsRoute( val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle() val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle() val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle() + val syncMode by viewModel.syncMode.collectAsStateWithLifecycle() + val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle() val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle() val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle() val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle() @@ -112,6 +116,8 @@ fun SettingsRoute( wifiQuality = wifiQuality, mobileQuality = mobileQuality, syncConcurrency = syncConcurrency, + syncMode = syncMode, + syncInterval = syncIntervalMinutes, downloadQuality = downloadQuality, downloadOverMetered = downloadOverMetered, scrobbling = scrobbling, @@ -122,6 +128,8 @@ fun SettingsRoute( onWifiQualityChange = viewModel::setWifiQuality, onMobileQualityChange = viewModel::setMobileQuality, onSyncConcurrencyChange = viewModel::setSyncConcurrency, + onSyncModeChange = viewModel::setSyncMode, + onSyncIntervalChange = viewModel::setSyncIntervalMinutes, onDownloadQualityChange = viewModel::setDownloadQuality, onDownloadOverMeteredChange = viewModel::setDownloadOverMetered, onScrobblingChange = viewModel::setScrobbling, @@ -166,11 +174,15 @@ fun SettingsScreen( modifier: Modifier = Modifier, offline: Boolean = false, verboseLogging: Boolean = false, + syncMode: SyncMode = SyncMode.WifiOnly, + syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES, onSelectSource: (Long) -> Unit, onEditServer: (Long) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit, onMobileQualityChange: (StreamQuality) -> Unit, onSyncConcurrencyChange: (Int) -> Unit, + onSyncModeChange: (SyncMode) -> Unit = {}, + onSyncIntervalChange: (Int) -> Unit = {}, onDownloadQualityChange: (StreamQuality) -> Unit, onDownloadOverMeteredChange: (Boolean) -> Unit, onScrobblingChange: (Boolean) -> Unit, @@ -288,6 +300,22 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) }, + supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.sync_interval)) }, + supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { SectionHeader(stringResource(R.string.settings_music_name)) } item { ListItem( @@ -475,6 +503,22 @@ fun SettingsScreen( ) } + SettingsDialog.SyncInterval -> { + SyncIntervalDialog( + selected = syncInterval, + onSelect = onSyncIntervalChange, + onDismiss = { dialog = null }, + ) + } + + SettingsDialog.SyncMode -> { + SyncModeDialog( + selected = syncMode, + onSelect = onSyncModeChange, + onDismiss = { dialog = null }, + ) + } + null -> { Unit } @@ -500,11 +544,56 @@ private fun bitrateLabel( private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16) +private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES) + private fun concurrencyLabel( resources: Resources, value: Int, ): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString() +private fun syncIntervalLabel( + resources: Resources, + minutes: Int, +): String = + if (minutes >= 60 && minutes % 60 == 0) { + resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60) + } else { + resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes) + } + +@Composable +private fun SyncIntervalDialog( + selected: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableIntStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sync_interval)) }, + text = { + ChoiceGroup( + header = stringResource(R.string.sync_interval_prompt), + options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) }, + selected = draft, + onSelect = { draft = it }, + ) + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + @Composable private fun ConcurrencyDialog( selected: Int, @@ -538,6 +627,51 @@ private fun ConcurrencyDialog( ) } +private val syncModeOptions = SyncMode.entries + +private fun syncModeLabel( + resources: Resources, + mode: SyncMode, +): String = + when (mode) { + SyncMode.Off -> resources.getString(R.string.sync_mode_off) + SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi) + SyncMode.Always -> resources.getString(R.string.sync_mode_always) + } + +@Composable +private fun SyncModeDialog( + selected: SyncMode, + onSelect: (SyncMode) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_sync_auto_title)) }, + text = { + ChoiceGroup( + header = stringResource(R.string.sync_mode_prompt), + options = syncModeOptions.map { it to syncModeLabel(resources, it) }, + selected = draft, + onSelect = { draft = it }, + ) + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + @Composable private fun QualityDialog( title: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index 401d7497..dfdfffb9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode +import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.StreamQuality +import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.repo.SourceRepository import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY @@ -48,6 +50,16 @@ class SettingsViewModel( .syncConcurrency() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY) + val syncMode: StateFlow = + userPreferences + .syncMode() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly) + + val syncIntervalMinutes: StateFlow = + userPreferences + .syncIntervalMinutes() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES) + val scrobbling: StateFlow = userPreferences .scrobbling() @@ -92,6 +104,14 @@ class SettingsViewModel( viewModelScope.launch { userPreferences.setSyncConcurrency(value) } } + fun setSyncMode(mode: SyncMode) { + viewModelScope.launch { userPreferences.setSyncMode(mode) } + } + + fun setSyncIntervalMinutes(value: Int) { + viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) } + } + fun setScrobbling(enabled: Boolean) { viewModelScope.launch { userPreferences.setScrobbling(enabled) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb8ed4a4..544a2d98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,9 @@ subtracks Progress while songs download + Sync + Syncing library + Keeping your library up to date Cancel Delete @@ -201,6 +204,21 @@ Sync concurrency Parallel server requests while syncing 1 (sequential) + Sync automatically + Off + Wi-Fi only + Always + When to sync automatically + Automatic sync interval + How often to sync automatically + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + Download quality Download over mobile data Wi-Fi and mobile diff --git a/app/src/test/kotlin/com/subtracks/data/sync/AutoSyncTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/AutoSyncTest.kt new file mode 100644 index 00000000..fc727026 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/sync/AutoSyncTest.kt @@ -0,0 +1,146 @@ +package com.subtracks.data.sync + +import com.subtracks.awaitUntil +import com.subtracks.cancelAndJoinBlocking +import com.subtracks.data.net.NetworkMode +import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES +import com.subtracks.data.prefs.SyncMode +import com.subtracks.data.prefs.UserPreferences +import com.subtracks.data.prefs.fakeUserPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class AutoSyncTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + @After + fun tearDown() { + cancelAndJoinBlocking(scope) + } + + @Test + fun decidesWhetherASyncIsDue() { + assertTrue(shouldSync()) + assertTrue(shouldSync(mode = SyncMode.WifiOnly)) + assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile)) + assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS)) + + assertFalse(shouldSync(mode = SyncMode.Off)) + assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile)) + assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS)) + assertFalse(shouldSync(offline = true)) + assertFalse(shouldSync(hasSource = false)) + } + + @Test + fun syncsWhenTheAppIsActiveAndDue() { + val preferences = fakeUserPreferences() + runBlocking { + preferences.setSyncMode(SyncMode.WifiOnly) + preferences.setLastSyncAt(0L) + } + val requested = AtomicBoolean(false) + val autoSync = autoSync(preferences, requestSync = { requested.set(true) }) + + autoSync.setForeground(true) + + awaitUntil("auto sync requested") { requested.get() } + autoSync.setForeground(false) + } + + @Test + fun doesNotSyncWhenOff() { + val preferences = fakeUserPreferences() + runBlocking { + preferences.setSyncMode(SyncMode.Off) + preferences.setLastSyncAt(0L) + } + val requested = AtomicBoolean(false) + val autoSync = autoSync(preferences, requestSync = { requested.set(true) }) + + autoSync.setForeground(true) + runBlocking { delay(300) } + autoSync.setForeground(false) + + assertFalse(requested.get()) + } + + @Test + fun stopsSyncingWhenBackgrounded() { + val preferences = fakeUserPreferences() + runBlocking { + preferences.setSyncMode(SyncMode.Always) + preferences.setLastSyncAt(0L) + } + val count = AtomicInteger(0) + val autoSync = + AutoSync( + preferences = preferences, + networkMode = flowOf(NetworkMode.Wifi), + hasSource = { true }, + requestSync = { count.incrementAndGet() }, + scope = scope, + now = { INTERVAL_MS + 1 }, + maxPollMs = 50L, + ) + + autoSync.setForeground(true) + awaitUntil("first sync") { count.get() >= 1 } + autoSync.setForeground(false) + + runBlocking { delay(200) } + val afterStop = count.get() + runBlocking { delay(300) } + + assertEquals(afterStop, count.get()) + } + + private fun autoSync( + preferences: UserPreferences, + requestSync: () -> Unit, + now: () -> Long = { INTERVAL_MS + 1 }, + ): AutoSync = + AutoSync( + preferences = preferences, + networkMode = flowOf(NetworkMode.Wifi), + hasSource = { true }, + requestSync = requestSync, + scope = scope, + now = now, + maxPollMs = 10_000L, + ) + + private fun shouldSync( + mode: SyncMode = SyncMode.WifiOnly, + network: NetworkMode = NetworkMode.Wifi, + offline: Boolean = false, + hasSource: Boolean = true, + lastSyncAt: Long = 0L, + now: Long = INTERVAL_MS, + intervalMs: Long = INTERVAL_MS, + ): Boolean = + shouldAutoSync( + mode = mode, + network = network, + offline = offline, + hasSource = hasSource, + lastSyncAt = lastSyncAt, + now = now, + intervalMs = intervalMs, + ) + + private companion object { + val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt index 8a1b1097..81c53d7d 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS import com.subtracks.cancelAndJoinBlocking import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.download.ArtworkStore +import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.repo.NetworkServerActionSink import com.subtracks.data.repo.QueueRepository @@ -44,6 +45,7 @@ class SyncManagerTest { private lateinit var sourceRepository: SourceRepository private lateinit var queueRepository: QueueRepository private lateinit var manager: SyncManager + private lateinit var preferences: UserPreferences private lateinit var scope: CoroutineScope private val messages = CopyOnWriteArrayList() private lateinit var server: MockWebServer @@ -62,12 +64,14 @@ class SyncManagerTest { sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope) queueRepository = QueueRepository(db) + preferences = fakeUserPreferences() manager = SyncManager( db, sourceRepository, queueRepository, NetworkServerActionSink(sourceRepository), + preferences = preferences, showMessage = { messages += it.resolve(resources) }, scope = scope, ) @@ -89,6 +93,16 @@ class SyncManagerTest { withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } assertEquals(listOf("No server configured"), messages) + assertTrue(preferences.lastSyncAt().first() > 0) + } + + @Test + fun aSilentSyncDoesNotTellTheUser() = + runBlocking { + manager.requestSync(silent = true) + withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } } + + assertTrue(messages.isEmpty()) } @Test @@ -114,6 +128,7 @@ class SyncManagerTest { assertEquals(listOf("ar1"), db.libraryDao().artistIds(1)) assertEquals(listOf("al1"), db.libraryDao().albumIds(1)) + assertTrue(preferences.lastSyncAt().first() > 0) } @Test diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt index a62fca8c..ea30f01a 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/AddSourceViewModelTest.kt @@ -156,7 +156,14 @@ class AddSourceViewModelTest { private fun viewModel(sourceId: Long? = null): AddSourceViewModel { val syncManager = - SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope) + SyncManager( + db, + sourceRepository, + QueueRepository(db), + NetworkServerActionSink(sourceRepository), + preferences = fakeUserPreferences(), + scope = repoScope, + ) val resources = ApplicationProvider.getApplicationContext().resources return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it } } diff --git a/docs/architecture.md b/docs/architecture.md index c30b3973..a7f7f8e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ app/src/main/kotlin/com/subtracks repo/ SourceRepository, LibraryRepository source/ MusicSource abstraction subsonic/ SubsonicClient, SubsonicXml, SubsonicSource - sync/ SyncService (stream + per-batch writes + prune), SyncManager (status) + sync/ SyncService (stream + per-batch writes + prune), SyncManager (status), AutoSync (foreground timer), SyncNotifier ``` ## Layers @@ -65,12 +65,12 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic) - The remote side sits behind `MusicSource` so additional backends can be added later. - `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror. - The local side is Room; reads are `Flow`s, so the UI updates as sync writes. -- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow`; `SyncService` does the actual work. +- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow`; `SyncService` does the actual work. `AutoSync` runs a foreground-only timer that asks `SyncManager` for a silent sync once the configured interval has elapsed. - ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly. ## UI shell -The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition. +The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The library syncs on pull-to-refresh; the pull indicator holds only while a manual sync runs, and a spinner rings the Settings action whenever any sync is running (an automatic sync shows only the spinner, plus a notification). There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition. The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync. @@ -116,7 +116,9 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det ## Sync -`SyncService.sync()` is serialized by `SyncManager`, which coalesces duplicate requests. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against. +`SyncService.sync()` is serialized by `SyncManager`, which coalesces a request that arrives while one is already running into a single follow-up. The library and Home sync actions are no-ops while a sync runs (so repeated pull-to-refresh cannot pile up runs), while a programmatic request such as adding a server still gets its follow-up. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against. + +A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast. Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.