From d9b665cdca5dce9f47eec6e7a5dfa297d5d9a6c8 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Thu, 30 Jul 2026 20:13:32 -0400 Subject: [PATCH] feat: add server update and scan functionality --- .../kotlin/ca/ksamad/encore/mpd/MpdClient.kt | 15 ++++- .../ca/ksamad/encore/mpd/MpdCommands.kt | 15 +++++ .../ca/ksamad/encore/mpd/model/MpdStatus.kt | 3 + .../encore/playback/MpdConnectionManager.kt | 6 ++ .../ca/ksamad/encore/ui/PlayerViewModel.kt | 4 ++ .../ca/ksamad/encore/ui/SettingsScreen.kt | 63 ++++++++++++++++++- .../ksamad/encore/mpd/model/MpdModelTest.kt | 7 +++ 7 files changed, 110 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt index 2fe6741..1bb3d49 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt @@ -231,6 +231,12 @@ class MpdClient( MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums())) } + /** Trigger a database scan for new/changed/removed files (whole library). */ + suspend fun updateDatabase() = run(MpdCommands.update()) + + /** Trigger a full re-read of the database, including files with an unchanged mtime. */ + suspend fun rescanDatabase() = run(MpdCommands.rescan()) + /** Replace the queue with an album and start playing it. */ suspend fun playAlbum( album: String, @@ -533,8 +539,13 @@ class MpdClient( private const val RECONNECT_BACKOFF_MS = 1_000L private const val MAX_RECONNECT_ATTEMPTS = 5 - /** Idle subsystems that change something [status]/[currentSong] reflects. */ - private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist") + /** + * Idle subsystems that change something [status]/[currentSong] reflects. `update` fires when + * a database scan starts and finishes (toggling `status.updating`); `database` fires when the + * scan actually changed the library. + */ + private val REFRESHING_SUBSYSTEMS = + setOf("player", "mixer", "options", "playlist", "update", "database") } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt index 6933f60..219fab7 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdCommands.kt @@ -94,6 +94,21 @@ object MpdCommands { // --- Database / library ------------------------------------------------- + /** + * Scan the music database for new, modified, and removed files (whole library when [path] is + * null). Respects file modification times, so unchanged files are skipped. Returns immediately + * with `updating_db: `; the scan runs in the background. + */ + fun update(path: String? = null) = + if (path == null) MpdProtocol.command("update") else MpdProtocol.command("update", path) + + /** + * Like [update], but also re-reads files whose modification time is unchanged — the "hard" + * refresh, for when tags changed without touching mtime. Heavier on the server. + */ + fun rescan(path: String? = null) = + if (path == null) MpdProtocol.command("rescan") else MpdProtocol.command("rescan", path) + /** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */ fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist") diff --git a/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt index 21ec015..a7e8150 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdStatus.kt @@ -43,6 +43,7 @@ data class MpdStatus( val bitrate: Int?, // instantaneous kbps val audio: String?, // e.g. "44100:16:2" val error: String?, // last player error, if any + val updating: Boolean = false, // true while the server is scanning its database (`updating_db`) ) { companion object { fun from(values: Map): MpdStatus = @@ -64,6 +65,8 @@ data class MpdStatus( bitrate = values["bitrate"]?.toIntOrNull(), audio = values["audio"], error = values["error"], + // Present (a job id) only while a database update/rescan is running. + updating = values["updating_db"] != null, ) } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt index 40f13b1..e8a5392 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt @@ -208,6 +208,12 @@ class MpdConnectionManager(context: Context) { suspend fun loadAlbums(): List = runCatching { client.albums() }.getOrDefault(emptyList()) + /** Ask the server to scan for new/changed/removed files. */ + fun updateDatabase() = fire { updateDatabase() } + + /** Ask the server to fully re-read the database, including unchanged files. */ + fun rescanDatabase() = fire { rescanDatabase() } + /** One-shot play-queue fetch; returns empty on any failure. */ suspend fun loadQueue(): List = runCatching { client.queue() }.getOrDefault(emptyList()) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt index 4b94961..5d54acc 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt @@ -117,6 +117,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) suspend fun loadAlbums() = manager.loadAlbums() + fun updateDatabase() = manager.updateDatabase() + + fun rescanDatabase() = manager.rescanDatabase() + fun playQueueItem(songId: Int) = manager.playQueueItem(songId) fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt index aaaef93..7a223ed 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt @@ -1,6 +1,7 @@ package ca.ksamad.encore.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,10 +13,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,6 +28,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch @@ -35,6 +41,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -44,6 +51,7 @@ import ca.ksamad.encore.mpd.model.MpdStatistics import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.launch /** * Settings: shows the current server and offers to switch servers or reset all saved settings. @@ -58,6 +66,15 @@ fun SettingsScreen( val settings by vm.settings.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle() var confirmReset by remember { mutableStateOf(false) } + var refreshMenuOpen by remember { mutableStateOf(false) } + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + fun flash(message: String) { + snackbarHostState.currentSnackbarData?.dismiss() + scope.launch { snackbarHostState.showSnackbar(message) } + } // Server statistics (`stats`), fetched once when the screen opens. `null` // means either still loading or unavailable — [statsLoaded] disambiguates. @@ -78,7 +95,8 @@ fun SettingsScreen( } }, ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column( modifier = @@ -158,6 +176,49 @@ fun SettingsScreen( }, ) + // Ask the server to rescan its music sources. Tap runs a normal (mtime-based) + // update; the overflow offers a full rescan that also re-reads unchanged files. + // While a scan runs, `status.updating` drives the "Refreshing…" state. + val refreshing = status?.updating == true + ListItem( + modifier = + Modifier.clickable(enabled = !refreshing) { + vm.updateDatabase() + flash("Library refresh started") + }, + headlineContent = { Text("Refresh library") }, + supportingContent = { + Text(if (refreshing) "Refreshing…" else "Scan for new and changed files") + }, + trailingContent = { + if (refreshing) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Box { + IconButton(onClick = { refreshMenuOpen = true }) { + Icon( + Icons.Filled.MoreVert, + contentDescription = "More refresh options", + ) + } + DropdownMenu( + expanded = refreshMenuOpen, + onDismissRequest = { refreshMenuOpen = false }, + ) { + DropdownMenuItem( + text = { Text("Full rescan") }, + onClick = { + refreshMenuOpen = false + vm.rescanDatabase() + flash("Full rescan started") + }, + ) + } + } + } + }, + ) + Text( "Server statistics", style = MaterialTheme.typography.titleSmall, diff --git a/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt b/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt index 0e1766c..2b1ad08 100644 --- a/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt +++ b/app/src/test/kotlin/ca/ksamad/encore/mpd/model/MpdModelTest.kt @@ -37,6 +37,7 @@ class MpdModelTest { assertEquals(3, s.song) assertEquals(42.5, s.elapsed!!, 0.0001) assertEquals(215.3, s.duration!!, 0.0001) + assertFalse(s.updating) // no updating_db field → not scanning } @Test @@ -47,6 +48,12 @@ class MpdModelTest { assertEquals(0, s.playlistLength) } + @Test + fun status_updatingDbFlagsScanInProgress() { + val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3")) + assertTrue(s.updating) + } + @Test fun song_parsesTagsAndDuration() { val song =