feat: add server update and scan functionality

This commit is contained in:
2026-07-30 23:15:53 -04:00
parent 3d57fca003
commit d9b665cdca
7 changed files with 110 additions and 3 deletions
@@ -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")
}
}
@@ -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: <jobid>`; 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")
@@ -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<String, String>): 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,
)
}
}
@@ -208,6 +208,12 @@ class MpdConnectionManager(context: Context) {
suspend fun loadAlbums(): List<MpdAlbum> =
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<MpdSong> =
runCatching { client.queue() }.getOrDefault(emptyList())
@@ -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)
@@ -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,
@@ -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 =