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? = 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 { 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 = 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 = (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): List>> { val groups = LinkedHashMap, MutableList>() for (t in tracks) { groups.getOrPut(albumGroupKey(t)) { mutableListOf() }.add(t) } val usedIds = HashSet() 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 = 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 = 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> = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = withContext(Dispatchers.IO) { scanAlbums().shuffled().take(count) } /** Random sample of [count] tracks from the local library. */ suspend fun randomTracks(count: Int): List = withContext(Dispatchers.IO) { loadTracksCached().shuffled().take(count) } private fun queryTracks( selection: String?, selectionArgs: Array?, sortOrder: String?, ): List { val tracks = mutableListOf() 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 != "" }, albumArtist = if (colAlbumArtist >= 0) cursor.getString(colAlbumArtist)?.takeIf { it != "" } else null, album = cursor.getString(colAlbum)?.takeIf { it != "" }, 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, )