feat: add server update and scan functionality
This commit is contained in:
@@ -231,6 +231,12 @@ class MpdClient(
|
|||||||
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
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. */
|
/** Replace the queue with an album and start playing it. */
|
||||||
suspend fun playAlbum(
|
suspend fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
@@ -533,8 +539,13 @@ class MpdClient(
|
|||||||
private const val RECONNECT_BACKOFF_MS = 1_000L
|
private const val RECONNECT_BACKOFF_MS = 1_000L
|
||||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
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 -------------------------------------------------
|
// --- 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). */
|
/** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */
|
||||||
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
|
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ data class MpdStatus(
|
|||||||
val bitrate: Int?, // instantaneous kbps
|
val bitrate: Int?, // instantaneous kbps
|
||||||
val audio: String?, // e.g. "44100:16:2"
|
val audio: String?, // e.g. "44100:16:2"
|
||||||
val error: String?, // last player error, if any
|
val error: String?, // last player error, if any
|
||||||
|
val updating: Boolean = false, // true while the server is scanning its database (`updating_db`)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(values: Map<String, String>): MpdStatus =
|
fun from(values: Map<String, String>): MpdStatus =
|
||||||
@@ -64,6 +65,8 @@ data class MpdStatus(
|
|||||||
bitrate = values["bitrate"]?.toIntOrNull(),
|
bitrate = values["bitrate"]?.toIntOrNull(),
|
||||||
audio = values["audio"],
|
audio = values["audio"],
|
||||||
error = values["error"],
|
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> =
|
suspend fun loadAlbums(): List<MpdAlbum> =
|
||||||
runCatching { client.albums() }.getOrDefault(emptyList())
|
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. */
|
/** One-shot play-queue fetch; returns empty on any failure. */
|
||||||
suspend fun loadQueue(): List<MpdSong> =
|
suspend fun loadQueue(): List<MpdSong> =
|
||||||
runCatching { client.queue() }.getOrDefault(emptyList())
|
runCatching { client.queue() }.getOrDefault(emptyList())
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
suspend fun loadAlbums() = manager.loadAlbums()
|
suspend fun loadAlbums() = manager.loadAlbums()
|
||||||
|
|
||||||
|
fun updateDatabase() = manager.updateDatabase()
|
||||||
|
|
||||||
|
fun rescanDatabase() = manager.rescanDatabase()
|
||||||
|
|
||||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||||
|
|
||||||
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,10 +13,13 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -24,6 +28,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
@@ -35,6 +41,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -44,6 +51,7 @@ import ca.ksamad.encore.mpd.model.MpdStatistics
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings: shows the current server and offers to switch servers or reset all saved settings.
|
* 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 settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
var confirmReset by remember { mutableStateOf(false) }
|
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`
|
// Server statistics (`stats`), fetched once when the screen opens. `null`
|
||||||
// means either still loading or unavailable — [statsLoaded] disambiguates.
|
// means either still loading or unavailable — [statsLoaded] disambiguates.
|
||||||
@@ -78,7 +95,8 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
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(
|
Text(
|
||||||
"Server statistics",
|
"Server statistics",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class MpdModelTest {
|
|||||||
assertEquals(3, s.song)
|
assertEquals(3, s.song)
|
||||||
assertEquals(42.5, s.elapsed!!, 0.0001)
|
assertEquals(42.5, s.elapsed!!, 0.0001)
|
||||||
assertEquals(215.3, s.duration!!, 0.0001)
|
assertEquals(215.3, s.duration!!, 0.0001)
|
||||||
|
assertFalse(s.updating) // no updating_db field → not scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -47,6 +48,12 @@ class MpdModelTest {
|
|||||||
assertEquals(0, s.playlistLength)
|
assertEquals(0, s.playlistLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun status_updatingDbFlagsScanInProgress() {
|
||||||
|
val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3"))
|
||||||
|
assertTrue(s.updating)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun song_parsesTagsAndDuration() {
|
fun song_parsesTagsAndDuration() {
|
||||||
val song =
|
val song =
|
||||||
|
|||||||
Reference in New Issue
Block a user