feat: scan media store for pairing

This commit is contained in:
2026-05-24 06:47:10 +02:00
parent eb61437f34
commit 4315944733
8 changed files with 327 additions and 135 deletions

View File

@@ -13,6 +13,7 @@ import at.lockstep.player.util.AudioUriValidator
import at.lockstep.player.playback.TrackBoundaryEvent
import at.lockstep.player.util.BeatAnnotationStorage
import at.lockstep.player.util.FolderMp3Scanner
import at.lockstep.player.util.MediaStoreMp3Scanner
import at.lockstep.player.util.Mp3EmbeddedMetadata
import at.lockstep.player.util.Mp3FolderCandidate
import at.lockstep.player.util.TrackFileMatching
@@ -293,25 +294,7 @@ class LockstepViewModel(
}
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
val withId = tracks.count { !it.trackId.isNullOrBlank() }
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
Log.d(
TAG,
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
"withTitle=$withTitle withArtist=$withArtist",
)
tracks.take(12).forEach { row ->
Log.d(
TAG,
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
"artist='${row.artistName}' durationMs=${row.durationMs}",
)
}
if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
}
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
@@ -328,75 +311,117 @@ class LockstepViewModel(
}.toMutableList()
Pair(mp3s.size, p)
}
if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
onFinished(
FolderPairingResult(
paired = 0,
failed = 0,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0,
),
)
return@launch
}
Log.d(
TAG,
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
"files have title or artist from tags",
)
var paired = 0
var failed = 0
var skippedNoSpotifyTrackId = 0
for (track in tracks.sortedBy { it.position }) {
val tid = track.trackId
if (tid.isNullOrBlank()) {
skippedNoSpotifyTrackId++
failed++
Log.d(
TAG,
"skip row pos=${track.position}: no Spotify track id " +
"(title='${track.trackName}' artist='${track.artistName}')",
)
continue
}
val title = track.trackName.orEmpty()
val artist = track.artistName.orEmpty()
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
if (pick == null) {
failed++
continue
}
val err =
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
if (err != null) {
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
upsertPairing(playlistId, tid, null, err)
failed++
} else {
upsertPairing(playlistId, tid, pick.toString(), null)
paired++
pool.removeAll { it.uri == pick }
}
}
Log.d(
TAG,
"pairPlaylistFromFolder done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
)
onFinished(
FolderPairingResult(
paired = paired,
failed = failed,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
),
)
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
/** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */
fun pairPlaylistFromDeviceAudio(
playlistId: String,
onFinished: (FolderPairingResult) -> Unit,
) {
viewModelScope.launch {
Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId")
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
logJukeboxTracks(tracks)
val (mp3Total, pool) =
withContext(Dispatchers.IO) {
val candidates = MediaStoreMp3Scanner.listMp3Candidates(context)
Pair(candidates.size, candidates.toMutableList())
}
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
}
}
private fun logJukeboxTracks(tracks: List<TrackRow>) {
val withId = tracks.count { !it.trackId.isNullOrBlank() }
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
Log.d(
TAG,
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
"withTitle=$withTitle withArtist=$withArtist",
)
tracks.take(12).forEach { row ->
Log.d(
TAG,
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
"artist='${row.artistName}' durationMs=${row.durationMs}",
)
}
if (tracks.size > 12) {
Log.d(TAG, "${tracks.size - 12} more rows omitted from log")
}
}
private suspend fun matchPlaylistTracks(
playlistId: String,
tracks: List<TrackRow>,
pool: MutableList<Mp3FolderCandidate>,
mp3Total: Int,
): FolderPairingResult {
if (pool.isEmpty()) {
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
return FolderPairingResult(
paired = 0,
failed = 0,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = 0,
)
}
Log.d(
TAG,
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
"files have title or artist from tags",
)
var paired = 0
var failed = 0
var skippedNoSpotifyTrackId = 0
for (track in tracks.sortedBy { it.position }) {
val tid = track.trackId
if (tid.isNullOrBlank()) {
skippedNoSpotifyTrackId++
failed++
Log.d(
TAG,
"skip row pos=${track.position}: no Spotify track id " +
"(title='${track.trackName}' artist='${track.artistName}')",
)
continue
}
val title = track.trackName.orEmpty()
val artist = track.artistName.orEmpty()
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
if (pick == null) {
failed++
continue
}
val err =
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
if (err != null) {
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
upsertPairing(playlistId, tid, null, err)
failed++
} else {
upsertPairing(playlistId, tid, pick.toString(), null)
paired++
pool.removeAll { it.uri == pick }
}
}
Log.d(
TAG,
"playlist pairing done paired=$paired failed=$failed " +
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
)
return FolderPairingResult(
paired = paired,
failed = failed,
jukeboxRowCount = tracks.size,
mp3Count = mp3Total,
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
)
}
}

View File

@@ -1,5 +1,6 @@
package at.lockstep.player.ui.pairing
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -33,10 +35,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.FolderPairingResult
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R
import at.lockstep.player.util.AudioReadPermission
import at.lockstep.player.util.SafInitialUris
@OptIn(ExperimentalMaterial3Api::class)
@@ -79,54 +84,44 @@ fun PairingScreen(
onResult = { tree ->
if (tree != null) {
viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
when {
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
showPairingResultToast(context, r)
}
}
},
)
val runDeviceScan = {
viewModel.pairPlaylistFromDeviceAudio(playlistId) { r ->
showPairingResultToast(context, r)
}
}
val audioPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (granted) {
runDeviceScan()
} else {
Toast
.makeText(
context,
context.getString(R.string.pairing_audio_permission_denied),
Toast.LENGTH_LONG,
).show()
}
},
)
fun scanDeviceAudio() {
val permission = AudioReadPermission.permissionName()
when {
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ->
runDeviceScan()
else -> audioPermissionLauncher.launch(permission)
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -150,7 +145,22 @@ fun PairingScreen(
) {
item {
Button(
onClick = { openTree.launch(SafInitialUris.internalDocuments()) },
onClick = { scanDeviceAudio() },
modifier = Modifier.fillMaxWidth(),
) {
Text(context.getString(R.string.pairing_scan_device))
}
}
item {
Text(
text = context.getString(R.string.pairing_scan_device_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
item {
OutlinedButton(
onClick = { openTree.launch(SafInitialUris.internalDocuments(context)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(context.getString(R.string.pairing_choose_folder))
@@ -216,3 +226,52 @@ fun PairingScreen(
}
}
}
private fun showPairingResultToast(
context: android.content.Context,
r: FolderPairingResult,
) {
when {
r.mp3Count == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.jukeboxRowCount == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_jukebox_empty),
Toast.LENGTH_LONG,
).show()
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_all_missing_spotify_id),
Toast.LENGTH_LONG,
).show()
r.paired == 0 && r.failed == 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_no_mp3_in_folder),
Toast.LENGTH_SHORT,
).show()
r.failed > 0 ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
Toast.LENGTH_LONG,
).show()
else ->
Toast.makeText(
context,
context.getString(R.string.pairing_folder_ok, r.paired),
Toast.LENGTH_SHORT,
).show()
}
}

View File

@@ -0,0 +1,13 @@
package at.lockstep.player.util
import android.Manifest
import android.os.Build
object AudioReadPermission {
fun permissionName(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}

View File

@@ -0,0 +1,60 @@
package at.lockstep.player.util
import android.content.ContentUris
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import android.util.Log
object MediaStoreMp3Scanner {
private const val TAG = "LockstepPairing"
fun listMp3Candidates(context: Context): List<Mp3FolderCandidate> {
val resolver = context.applicationContext.contentResolver
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
@Suppress("DEPRECATION")
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
val projection =
arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
)
val selection =
"(${MediaStore.Audio.Media.MIME_TYPE} = ? OR " +
"${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?)"
val selectionArgs = arrayOf("audio/mpeg", "%.mp3")
val out = mutableListOf<Mp3FolderCandidate>()
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val displayName = cursor.getString(nameCol).orEmpty()
val title = cursor.getString(titleCol)?.trim()?.takeIf { it.isNotEmpty() }
val artist = cursor.getString(artistCol)?.trim()?.takeIf { it.isNotEmpty() }
out.add(
Mp3FolderCandidate(
uri = ContentUris.withAppendedId(collection, id),
fileBaseName = displayName.substringBeforeLast('.'),
id3Title = title,
id3Artist = artist,
),
)
}
} ?: Log.w(TAG, "MediaStore audio query returned null")
Log.d(TAG, "MediaStoreMp3Scanner found mp3Count=${out.size}")
return out.distinctBy { it.uri }.sortedBy { it.fileBaseName.lowercase() }
}
}

View File

@@ -1,15 +1,41 @@
package at.lockstep.player.util
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
object SafInitialUris {
private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
private const val INITIAL_URI_EXTRA = "android.provider.extra.INITIAL_URI"
/** Internal-storage Documents — avoids the blocked volume root shown on Pixel devices. */
fun internalDocuments(): Uri =
DocumentsContract.buildDocumentUri(
/**
* Internal-storage Documents. Uses [StorageManager] on Android 10+ so the system picker
* lands in a choosable folder instead of the blocked volume root on Pixel devices.
*/
fun internalDocuments(context: Context): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val storageManager = context.getSystemService(StorageManager::class.java)
val intent = storageManager.primaryStorageVolume.createOpenDocumentTreeIntent()
val root =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(INITIAL_URI_EXTRA, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(INITIAL_URI_EXTRA)
}
if (root != null) {
val scheme =
root
.toString()
.replace("/root/", "/document/") + "%3A" + "Documents"
return Uri.parse(scheme)
}
}
return DocumentsContract.buildDocumentUri(
EXTERNAL_STORAGE_AUTHORITY,
"primary:Documents",
)
}
}