EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LocalMediaScanner.kt

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,
)