package dev.stave.local
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.model.DidlItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Scans the device's audio MediaStore for locally-stored music files.
* Returns [DidlItem] trees identical to what the UPnP ContentDirectory
* produces, so Browse/Detail/NowPlaying consume them identically.
*
* Used for:
* - Android Auto when no UPnP server is reachable (primary use case)
* - TV UI "Local Music" entry point (secondary)
*
* All IDs are prefixed with [LOCAL_PREFIX] so the PlaybackService browse
* callback can distinguish local items from UPnP items.
*/
@Singleton
class LocalMediaScanner @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** In-memory cache of the last full-library track scan. Built once per
* process lifetime (or after [invalidateCache]). All three derived
* views — albums, artists, songs — read from this list, so a single
* scan covers them all. The cost: ~1–3 seconds on first access for a
* multi-thousand-track library; sub-second on subsequent reads. */
@Volatile
private var cachedTracks: List<LocalTrack>? = null
/** Force a full rescan on next access. Call after a permission grant
* (the scanner returns nothing without permission) or if the user
* triggers a "rescan library" action. */
fun invalidateCache() {
cachedTracks = null
}
private suspend fun loadTracksCached(): List<LocalTrack> {
cachedTracks?.let { return it }
val fresh = queryTracks(
selection = null,
selectionArgs = null,
sortOrder = "${MediaStore.Audio.Media.TITLE} ASC",
)
// Maintainer, 2026-10-29: only cache a non-empty scan. A pre-warm that ran before
// the media permission was granted cached [] forever, leaving local random/Auto
// empty after the grant. invalidateCache() still forces a rescan as before.
if (fresh.isNotEmpty()) cachedTracks = fresh
return fresh
}
data class LocalAlbum(
val id: Long,
val title: String,
val artist: String?,
val artUri: Uri?,
val trackCount: Int,
)
data class LocalTrack(
val id: Long,
val title: String,
val artist: String?,
val albumArtist: String?,
val album: String?,
val albumId: Long,
val trackNumber: Int?,
val discNumber: Int?,
val duration: Long, // ms
val mimeType: String?,
val genre: String?,
val contentUri: Uri,
val artUri: Uri?,
)
/** All albums on the device, sorted alphabetically.
*
* Aggregated from the ID3-corrected track list rather than from
* MediaStore.Audio.Albums. The Albums table is built by Android from
* the same scanner that falls back to folder names when a track lacks
* an ALBUM tag — so reading from it would re-introduce the very
* folder-name leakage we're trying to avoid. Building from the
* cached track list gives one consistent view of the library. (The
* per-file tag reader this once referred to was removed — see the
* note in queryTracks.) */
suspend fun scanAlbums(): List<LocalAlbum> = withContext(Dispatchers.IO) {
// Maintainer, 2026-10-29: grouping moved to groupAlbums() — the old (title,
// albumArtist ?: artist) key split an untagged compilation into one entry per
// track artist, all sharing one ALBUM_ID → duplicate list keys (crash).
groupAlbums(loadTracksCached())
.map { it.first }
.sortedBy { it.title.lowercase() }
}
// Maintainer, 2026-10-29: album grouping key = (album title, album-artist tag, or the
// MediaStore ALBUM_ID when there is no album-artist tag), so all tracks of one
// MediaStore album without an album-artist stay ONE album.
private fun albumGroupKey(t: LocalTrack): Pair<String, String> =
(t.album ?: "Unknown Album") to (t.albumArtist ?: "albumId:${t.albumId}")
// Maintainer, 2026-10-29: shared by scanAlbums / tracksForAlbum / albumsForArtist /
// albumsByGenre so every consumer of LocalAlbum.id sees the same groups. Each
// group's id is the first of its tracks' ALBUM_IDs not already taken by an earlier
// group (falls back to the first) so ids stay unique where at all possible. Display
// artist = album-artist tag, else the one track artist if all share it, else
// "Various Artists".
private fun groupAlbums(tracks: List<LocalTrack>): List<Pair<LocalAlbum, List<LocalTrack>>> {
val groups = LinkedHashMap<Pair<String, String>, MutableList<LocalTrack>>()
for (t in tracks) {
groups.getOrPut(albumGroupKey(t)) { mutableListOf() }.add(t)
}
val usedIds = HashSet<Long>()
return groups.entries.map { (key, list) ->
val repId = list.asSequence().map { it.albumId }.firstOrNull { it !in usedIds }
?: list.first().albumId
usedIds.add(repId)
val trackArtists = list.map { it.artist }.distinct()
val displayArtist = list.first().albumArtist
?: if (trackArtists.size == 1) trackArtists[0] else "Various Artists"
LocalAlbum(
id = repId,
title = key.first,
artist = displayArtist,
artUri = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"), repId
),
trackCount = list.size,
) to list
}
}
/** All tracks in a given album, ordered by disc then track number.
*
* The albumId argument here is the MediaStore ALBUM_ID we picked
* (somewhat arbitrarily) as the representative when bucketing in
* [scanAlbums]. To handle partially-tagged libraries correctly, we
* resolve the (title, artist) pair from the cached track list using
* that album-id as a starting point, then return ALL tracks matching
* that tuple — which may be more than what MediaStore associated
* with the album-id alone. */
suspend fun tracksForAlbum(albumId: Long): List<LocalTrack> = withContext(Dispatchers.IO) {
val all = loadTracksCached()
// Maintainer, 2026-10-29: resolve via the same groups scanAlbums() builds (album
// id → its group); if the id is some track's own ALBUM_ID that isn't a group
// representative (Auto car screen, track parentId), use that track's group.
val groupTracks = groupAlbums(all).firstOrNull { it.first.id == albumId }?.second
?: all.firstOrNull { it.albumId == albumId }?.let { seed ->
val key = albumGroupKey(seed)
all.filter { albumGroupKey(it) == key }
}
?: return@withContext emptyList()
groupTracks
.sortedWith(
compareBy(
{ it.discNumber ?: 0 },
{ it.trackNumber ?: 0 },
{ it.title.lowercase() },
)
)
}
/** Maintainer, 2026-10-29: the album a specific track belongs to, by that
* track's own group — exact even when one MediaStore ALBUM_ID is split
* across groups (mixed or missing album-artist tags), where looking up
* by album id could return a sibling group. Used by the car's album
* screen, which knows the seed track. */
suspend fun tracksForTrackAlbum(trackId: Long): List<LocalTrack> = withContext(Dispatchers.IO) {
val all = loadTracksCached()
val seed = all.firstOrNull { it.id == trackId } ?: return@withContext emptyList()
val key = albumGroupKey(seed)
all.filter { albumGroupKey(it) == key }
.sortedWith(
compareBy(
{ it.discNumber ?: 0 },
{ it.trackNumber ?: 0 },
{ it.title.lowercase() },
)
)
}
/** All artists on the device.
*
* Aggregated from ID3-corrected track data for the same reason
* [scanAlbums] is — MediaStore.Audio.Artists has the same folder-name
* leakage when tag data is missing or partial. We pick the most-common
* ID3 artist per track and dedupe.
*
* The Long ID returned in each pair is *synthetic* — a stable hash of
* the artist name, NOT a MediaStore.Audio.Artists row ID. It exists so
* navigation arguments can carry the artist as a Long and we can resolve
* it back to a name in [LocalArtistScreen]. The actual artist lookup
* uses the name string. */
suspend fun scanArtists(): List<Pair<Long, String>> = withContext(Dispatchers.IO) {
loadTracksCached()
.mapNotNull { it.albumArtist ?: it.artist }
.distinct()
.sortedBy { it.lowercase() }
.map { name -> name.hashCode().toLong() to name }
}
/** Albums by a specific artist. Built from the ID3-corrected track list,
* matching either the album-artist tag or the per-track artist. */
suspend fun albumsForArtist(artistName: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
// Maintainer, 2026-10-29: albums no longer split per track artist, so also match
// albums (e.g. "Various Artists" compilations) containing a track by this artist.
groupAlbums(loadTracksCached())
.filter { (album, list) ->
album.artist == artistName || list.any { (it.albumArtist ?: it.artist) == artistName }
}
.map { it.first }
.sortedBy { it.title.lowercase() }
}
/** All tracks by a single artist, ordered by album then disc/track. */
suspend fun tracksForArtist(artistName: String): List<LocalTrack> = withContext(Dispatchers.IO) {
loadTracksCached()
.filter { (it.albumArtist ?: it.artist) == artistName }
.sortedWith(
compareBy(
{ it.album ?: "" },
{ it.discNumber ?: 0 },
{ it.trackNumber ?: 0 },
{ it.title.lowercase() },
)
)
}
/** All tracks on the device (for random/shuffle). Routes through the
* shared cache so repeated calls in a session don't re-read every file. */
suspend fun allTracks(): List<LocalTrack> = withContext(Dispatchers.IO) {
loadTracksCached()
}
/** Search tracks by title. Substring match against the ID3-corrected
* cached track list (case-insensitive). */
suspend fun searchTracks(query: String): List<LocalTrack> = withContext(Dispatchers.IO) {
val q = query.trim().lowercase()
if (q.isBlank()) return@withContext emptyList()
// Cache self-heal: if the cache is non-null but empty, and the
// device has READ_MEDIA_AUDIO permission now, invalidate so
// the next load reads MediaStore again. Covers the cold-start
// case where Auto bound the service before the user granted
// permission via MainActivity, the first scan returned 0 rows,
// and the cache is stuck at empty even though tracks now exist.
maybeInvalidateEmptyCache()
loadTracksCached().filter {
it.title.lowercase().contains(q) ||
it.artist?.lowercase()?.contains(q) == true ||
it.albumArtist?.lowercase()?.contains(q) == true ||
it.album?.lowercase()?.contains(q) == true
}
}
/** Search albums by title or artist. Returns browsable results for Auto. */
suspend fun searchAlbums(query: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
val q = query.trim().lowercase()
if (q.isBlank()) return@withContext emptyList()
maybeInvalidateEmptyCache()
scanAlbums().filter {
it.title.lowercase().contains(q) ||
it.artist?.lowercase()?.contains(q) == true
}
}
/** If the cache is currently an empty list (first scan ran with no
* permission, or the device truly had no audio at scan time),
* invalidate so the next load re-queries. Cheap: only triggers
* when `cachedTracks.isEmpty()`, never reloads a populated cache. */
private fun maybeInvalidateEmptyCache() {
if (cachedTracks?.isEmpty() == true) {
cachedTracks = null
}
}
/** All local tracks matching a genre name (case-insensitive contains). */
suspend fun tracksByGenre(genre: String): List<LocalTrack> = withContext(Dispatchers.IO) {
val g = genre.trim().lowercase()
if (g.isBlank()) emptyList()
else loadTracksCached().filter {
it.genre?.lowercase()?.contains(g) == true
}
}
/** Albums that have at least one track matching the genre. */
suspend fun albumsByGenre(genre: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
val genreTracks = tracksByGenre(genre)
// Maintainer, 2026-10-29: match by group membership (track ids) — a group's id is
// only one of its tracks' ALBUM_IDs, so matching on ALBUM_ID could miss albums.
val genreTrackIds = genreTracks.map { it.id }.toSet()
groupAlbums(loadTracksCached())
.filter { (_, list) -> list.any { it.id in genreTrackIds } }
.map { it.first }
.sortedBy { it.title.lowercase() }
}
/** Random sample of [count] albums from the local library. */
suspend fun randomAlbums(count: Int): List<LocalAlbum> = withContext(Dispatchers.IO) {
scanAlbums().shuffled().take(count)
}
/** Random sample of [count] tracks from the local library. */
suspend fun randomTracks(count: Int): List<LocalTrack> = withContext(Dispatchers.IO) {
loadTracksCached().shuffled().take(count)
}
private fun queryTracks(
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?,
): List<LocalTrack> {
val tracks = mutableListOf<LocalTrack>()
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val projection = buildList {
add(MediaStore.Audio.Media._ID)
add(MediaStore.Audio.Media.TITLE)
add(MediaStore.Audio.Media.ARTIST)
add(MediaStore.Audio.Media.ALBUM)
add(MediaStore.Audio.Media.ALBUM_ID)
add(MediaStore.Audio.Media.TRACK)
add(MediaStore.Audio.Media.DURATION)
add(MediaStore.Audio.Media.MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
add(MediaStore.Audio.Media.DISC_NUMBER)
add(MediaStore.Audio.Media.ALBUM_ARTIST)
add(MediaStore.Audio.Media.GENRE)
}
}.toTypedArray()
val fullSelection = if (selection != null) {
"${MediaStore.Audio.Media.IS_MUSIC} = 1 AND $selection"
} else {
"${MediaStore.Audio.Media.IS_MUSIC} = 1"
}
// Maintainer, 2026-10-29: on API 26–29 also ask for the pre-R "album_artist" column
// (public constant is API 30+), so album grouping has the tag there too. If an
// older provider rejects the column, query again with the base projection.
val legacyProjection = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
projection + LEGACY_ALBUM_ARTIST else null
runCatching {
val opened = legacyProjection?.let { lp ->
runCatching {
context.contentResolver.query(uri, lp, fullSelection, selectionArgs, sortOrder)
}.getOrNull()
} ?: context.contentResolver.query(uri, projection, fullSelection, selectionArgs, sortOrder)
opened?.use { cursor ->
val colId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val colTitle = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val colArtist = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val colAlbum = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val colAlbumId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val colTrack = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
val colDuration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val colMime = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
val colDisc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
cursor.getColumnIndex(MediaStore.Audio.Media.DISC_NUMBER) else -1
// Maintainer, 2026-10-29: pre-R reads the legacy column (-1 if absent).
val colAlbumArtist = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ARTIST)
else cursor.getColumnIndex(LEGACY_ALBUM_ARTIST)
val colGenre = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
cursor.getColumnIndex(MediaStore.Audio.Media.GENRE) else -1
while (cursor.moveToNext()) {
val trackId = cursor.getLong(colId)
val albumId = cursor.getLong(colAlbumId)
val contentUri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, trackId
)
val artUri = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"), albumId
)
// ── MediaStore is the source of truth here ─────────────
// We previously enriched each row via MediaMetadataRetriever
// to read ID3 tags directly. Reverted: MMR can crash the
// process *natively* (SIGSEGV) on malformed audio files —
// unrecoverable, no Java exception, no try/catch protection.
// For libraries containing even one bad file it makes the
// app dead on launch.
//
// MediaStore.Audio is itself populated from ID3 tags by
// Android's system MediaScanner — for well-tagged files
// it reads the same TIT2 / TPE1 / TALB frames MMR does.
// The system scanner only falls back to filename / parent-
// folder when a tag is missing or the file is malformed.
// We accept that fallback: a few albums showing folder
// names is strictly better than the app crashing.
//
// Per-track MMR can be reintroduced later as on-demand
// enrichment for a *single* user-selected track, where
// a native crash would only kill that one operation
// (caller can guard the launch).
tracks.add(
LocalTrack(
id = trackId,
title = cursor.getString(colTitle) ?: "Unknown",
artist = cursor.getString(colArtist)?.takeIf { it != "<unknown>" },
albumArtist = if (colAlbumArtist >= 0)
cursor.getString(colAlbumArtist)?.takeIf { it != "<unknown>" }
else null,
album = cursor.getString(colAlbum)?.takeIf { it != "<unknown>" },
albumId = albumId,
// MediaStore packs disc × 1000 + track into TRACK
// (1005 = disc 1, track 5); show the track, and take
// the disc from it when the DISC column is absent
// (design decision 2026-09-02, I.70).
trackNumber = cursor.getInt(colTrack).let { raw ->
(if (raw >= 1000) raw % 1000 else raw).takeIf { it > 0 }
},
discNumber = (if (colDisc >= 0) cursor.getInt(colDisc).takeIf { it > 0 } else null)
?: cursor.getInt(colTrack).let { raw -> if (raw >= 1000) raw / 1000 else null },
duration = cursor.getLong(colDuration),
mimeType = cursor.getString(colMime),
genre = if (colGenre >= 0)
cursor.getString(colGenre)?.takeIf { it.isNotBlank() }
else null,
contentUri = contentUri,
artUri = artUri,
)
)
}
}
}
return tracks
}
companion object {
/** Prefix for all local media IDs in the browse tree. */
const val LOCAL_PREFIX = "earhorn_local:"
const val LOCAL_ROOT = "${LOCAL_PREFIX}root"
const val LOCAL_ALBUMS = "${LOCAL_PREFIX}albums"
const val LOCAL_ARTISTS = "${LOCAL_PREFIX}artists"
const val LOCAL_ALBUM = "${LOCAL_PREFIX}album:"
const val LOCAL_ARTIST = "${LOCAL_PREFIX}artist:"
// Maintainer, 2026-10-29: pre-API-30 name of the audio album-artist column.
private const val LEGACY_ALBUM_ARTIST = "album_artist"
}
}
// ── Conversion helpers: LocalTrack → DidlItem.Track ─────────────────
fun LocalMediaScanner.LocalTrack.toDidlTrack(): DidlItem.Track = DidlItem.Track(
id = "${LocalMediaScanner.LOCAL_PREFIX}track:$id",
parentId = "${LocalMediaScanner.LOCAL_ALBUM}$albumId",
title = title,
artist = artist,
albumArtist = albumArtist,
album = album,
composer = null,
genre = genre,
date = null,
trackNumber = trackNumber,
discNumber = discNumber,
duration = duration.milliseconds,
albumArtUri = artUri?.toString(),
resourceUrl = contentUri.toString(),
mimeType = mimeType,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)