feat: sync Playlists, wip: pair songs
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
Normal file
118
app/src/main/java/at/lockstep/player/util/FolderMp3Scanner.kt
Normal 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",
|
||||
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
Normal file
124
app/src/main/java/at/lockstep/player/util/TrackFileMatching.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user