feat: sync Playlists, wip: pair songs

This commit is contained in:
2026-05-14 02:43:49 +02:00
parent 26115f773f
commit e2ab026e84
36 changed files with 2324 additions and 34 deletions

View File

@@ -0,0 +1,26 @@
package at.lockstep.player.util
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
object AudioUriValidator {
/** @return null if readable, or a short error reason for UI / toasts. */
fun validateReadableAudio(
context: Context,
uri: Uri,
): String? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context.applicationContext, uri)
null
} catch (e: RuntimeException) {
e.message?.takeIf { it.isNotBlank() } ?: "Could not read this audio file"
} finally {
try {
retriever.release()
} catch (_: RuntimeException) {
}
}
}
}

View File

@@ -0,0 +1,118 @@
package at.lockstep.player.util
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
object FolderMp3Scanner {
private const val TAG = "LockstepPairing"
fun listMp3Uris(
context: Context,
treeUri: Uri,
): List<Uri> {
Log.d(TAG, "listMp3Uris start treeUri=$treeUri")
val root =
DocumentFile.fromTreeUri(context.applicationContext, treeUri) ?: run {
Log.w(
TAG,
"DocumentFile.fromTreeUri returned null — wrong URI or missing persistable read permission for this tree",
)
return emptyList()
}
Log.d(
TAG,
"root document name=${root.name} uri=${root.uri} isDirectory=${root.isDirectory} canRead=${root.canRead()}",
)
if (!root.isDirectory) {
Log.w(TAG, "root is not a directory — SAF root may be wrong")
}
val out = mutableListOf<Uri>()
val stats = ScanStats()
walk(root, out, stats, depth = 0)
Log.d(
TAG,
"listMp3Uris done mp3Count=${out.size} fileNodes=${stats.fileCount} dirNodes=${stats.dirCount} nonMp3Files=${stats.nonMp3Files}",
)
if (out.isEmpty()) {
when {
stats.fileCount > 0 ->
Log.d(
TAG,
"Found files but no MP3 matches. Sample non-MP3 (name + MIME): ${stats.sampleNames.take(15)}",
)
stats.dirCount > 0 && stats.fileCount == 0 ->
Log.w(
TAG,
"Only subfolders seen under root, no files at any depth — unusual for a music folder",
)
else ->
Log.w(
TAG,
"No files and no subdirs counted — root listFiles empty/null, or permission blocked listing",
)

View File

@@ -0,0 +1,64 @@
package at.lockstep.player.util
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Log
/** Reads common ID3-style tags from audio at a content [Uri] (SAF / MediaStore). */
object Mp3EmbeddedMetadata {
private const val TAG = "LockstepPairing"
/**
* Returns trimmed title and artist when present. Artist falls back to album artist.
* On any failure returns [Pair] of nulls.
*/
fun readTitleAndArtist(
context: Context,
uri: Uri,
): Pair<String?, String?> {
val label = mp3DisplayNameFromUri(uri).substringBeforeLast('.').ifBlank { uri.lastPathSegment ?: uri.toString() }
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context.applicationContext, uri)
val title =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
?.trim()
?.takeIf { it.isNotEmpty() }
val artistFromTrack =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
?.trim()
?.takeIf { it.isNotEmpty() }
val albumArtist =
retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST)
?.trim()
?.takeIf { it.isNotEmpty() }
val artist = artistFromTrack ?: albumArtist
when {
title != null || artist != null ->
Log.d(
TAG,
"id3 ok file=$label title=${title ?: "—"} artist=${artist ?: "—"}",
)
else ->
Log.d(TAG, "id3 empty file=$label (retriever returned no title/artist)")
}
Pair(title, artist)
} catch (e: Exception) {
Log.w(
TAG,
"id3 error file=$label ${e.javaClass.simpleName}: ${e.message}",
)
Pair(null, null)
} finally {
try {
retriever.release()
} catch (_: Exception) {
}
}
}
}

View File

@@ -0,0 +1,124 @@
package at.lockstep.player.util
import android.net.Uri
import android.util.Log
private fun normalizeToken(value: String): String =
value.lowercase().replace(Regex("[^a-z0-9]+"), " ").trim()
fun mp3DisplayNameFromUri(uri: Uri): String {
val last = uri.lastPathSegment.orEmpty()
val noPercent =
try {
java.net.URLDecoder.decode(last, java.nio.charset.StandardCharsets.UTF_8.name())
} catch (_: Exception) {
last
}
return noPercent.substringAfterLast(':').substringAfterLast('/')
}
data class Mp3FolderCandidate(
val uri: Uri,
/** File basename without extension, from the document URI. */
val fileBaseName: String,
val id3Title: String?,
val id3Artist: String?,
)
object TrackFileMatching {
private const val TAG = "LockstepPairing"
/**
* Picks the best-matching local file for a Spotify row using filename and optional ID3
* [Mp3FolderCandidate] hints (whichever scores higher per file).
*/
fun bestMatchForFolder(
title: String,
artist: String,
candidates: List<Mp3FolderCandidate>,
): Uri? {
if (candidates.isEmpty()) return null
val trackHint = "$artist $title"
val scored =
candidates.map { c ->
val s = scoreAgainstLocalHints(trackHint, c)
Triple(c.uri, s, c)
}
val best = scored.maxByOrNull { it.second } ?: return null
val winner = if (best.second > 0) best.first else null
if (winner == null) {
val top = scored.sortedByDescending { it.second }.take(5)
Log.d(
TAG,
"match miss spotifyHint='$trackHint' bestScore=${best.second} topCandidates=" +
top.joinToString { t ->
val c = t.third
"${c.fileBaseName}[${t.second}] id3=${c.id3Title ?: "—"}/${c.id3Artist ?: "—"}"
},
)
} else {
val c = best.third
Log.d(
TAG,
"match hit score=${best.second} file=${c.fileBaseName} id3=${c.id3Title ?: "—"}/${c.id3Artist ?: "—"} " +
"for spotifyHint='$trackHint'",
)
}
return winner
}
/**
* Filename-only matching (no ID3). Prefer [bestMatchForFolder] when tags were read on IO.
*/
fun bestMatchForTrack(
title: String,
artist: String,
candidateUris: List<Uri>,
): Uri? {
val candidates =
candidateUris.map { uri ->
Mp3FolderCandidate(
uri,
mp3DisplayNameFromUri(uri).substringBeforeLast('.'),
null,
null,
)
}
return bestMatchForFolder(title, artist, candidates)
}
private fun scoreAgainstLocalHints(
trackHint: String,
c: Mp3FolderCandidate,
): Int {
var best = score(trackHint, c.fileBaseName)
val t = c.id3Title?.trim()?.takeIf { it.isNotEmpty() }
val a = c.id3Artist?.trim()?.takeIf { it.isNotEmpty() }
if (t != null && a != null) {
best = maxOf(best, score(trackHint, "$a $t"))
}
if (t != null) {
best = maxOf(best, score(trackHint, t))
}
if (a != null) {
best = maxOf(best, score(trackHint, a))
}
return best
}
private fun score(
trackHint: String,
fileBaseName: String,
): Int {
val t = normalizeToken(trackHint)
val f = normalizeToken(fileBaseName)
if (t.isEmpty() || f.isEmpty()) return 0
if (f.contains(t) || t.contains(f)) return 100
var pts = 0
for (word in t.split(' ').filter { it.length > 2 }) {
if (f.contains(word)) pts += 10
}
return pts
}
}