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

LocalMediaScanner.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.local
2
3import android.content.ContentUris
4import android.content.Context
5import android.net.Uri
6import android.os.Build
7import android.provider.MediaStore
8import dagger.hilt.android.qualifiers.ApplicationContext
9import dev.stave.domain.model.DidlItem
10import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.withContext
12import javax.inject.Inject
13import javax.inject.Singleton
14import kotlin.time.Duration.Companion.milliseconds
15
16/**
17 * Scans the device's audio MediaStore for locally-stored music files.
18 * Returns [DidlItem] trees identical to what the UPnP ContentDirectory
19 * produces, so Browse/Detail/NowPlaying consume them identically.
20 *
21 * Used for:
22 * - Android Auto when no UPnP server is reachable (primary use case)
23 * - TV UI "Local Music" entry point (secondary)
24 *
25 * All IDs are prefixed with [LOCAL_PREFIX] so the PlaybackService browse
26 * callback can distinguish local items from UPnP items.
27 */
28@Singleton
29class LocalMediaScanner @Inject constructor(
30 @ApplicationContext private val context: Context,
31) {
32
33 /** In-memory cache of the last full-library track scan. Built once per
34 * process lifetime (or after [invalidateCache]). All three derived
35 * views — albums, artists, songs — read from this list, so a single
36 * scan covers them all. The cost: ~1–3 seconds on first access for a
37 * multi-thousand-track library; sub-second on subsequent reads. */
38 @Volatile
39 private var cachedTracks: List<LocalTrack>? = null
40
41 /** Force a full rescan on next access. Call after a permission grant
42 * (the scanner returns nothing without permission) or if the user
43 * triggers a "rescan library" action. */
44 fun invalidateCache() {
45 cachedTracks = null
46 }
47
48 private suspend fun loadTracksCached(): List<LocalTrack> {
49 cachedTracks?.let { return it }
50 val fresh = queryTracks(
51 selection = null,
52 selectionArgs = null,
53 sortOrder = "${MediaStore.Audio.Media.TITLE} ASC",
54 )
55 // Maintainer, 2026-10-29: only cache a non-empty scan. A pre-warm that ran before
56 // the media permission was granted cached [] forever, leaving local random/Auto
57 // empty after the grant. invalidateCache() still forces a rescan as before.
58 if (fresh.isNotEmpty()) cachedTracks = fresh
59 return fresh
60 }
61
62 data class LocalAlbum(
63 val id: Long,
64 val title: String,
65 val artist: String?,
66 val artUri: Uri?,
67 val trackCount: Int,
68 )
69
70 data class LocalTrack(
71 val id: Long,
72 val title: String,
73 val artist: String?,
74 val albumArtist: String?,
75 val album: String?,
76 val albumId: Long,
77 val trackNumber: Int?,
78 val discNumber: Int?,
79 val duration: Long, // ms
80 val mimeType: String?,
81 val genre: String?,
82 val contentUri: Uri,
83 val artUri: Uri?,
84 )
85
86 /** All albums on the device, sorted alphabetically.
87 *
88 * Aggregated from the ID3-corrected track list rather than from
89 * MediaStore.Audio.Albums. The Albums table is built by Android from
90 * the same scanner that falls back to folder names when a track lacks
91 * an ALBUM tag — so reading from it would re-introduce the very
92 * folder-name leakage we're trying to avoid. Building from the
93 * cached track list gives one consistent view of the library. (The
94 * per-file tag reader this once referred to was removed — see the
95 * note in queryTracks.) */
96 suspend fun scanAlbums(): List<LocalAlbum> = withContext(Dispatchers.IO) {
97 // Maintainer, 2026-10-29: grouping moved to groupAlbums() — the old (title,
98 // albumArtist ?: artist) key split an untagged compilation into one entry per
99 // track artist, all sharing one ALBUM_ID → duplicate list keys (crash).
100 groupAlbums(loadTracksCached())
101 .map { it.first }
102 .sortedBy { it.title.lowercase() }
103 }
104
105 // Maintainer, 2026-10-29: album grouping key = (album title, album-artist tag, or the
106 // MediaStore ALBUM_ID when there is no album-artist tag), so all tracks of one
107 // MediaStore album without an album-artist stay ONE album.
108 private fun albumGroupKey(t: LocalTrack): Pair<String, String> =
109 (t.album ?: "Unknown Album") to (t.albumArtist ?: "albumId:${t.albumId}")
110
111 // Maintainer, 2026-10-29: shared by scanAlbums / tracksForAlbum / albumsForArtist /
112 // albumsByGenre so every consumer of LocalAlbum.id sees the same groups. Each
113 // group's id is the first of its tracks' ALBUM_IDs not already taken by an earlier
114 // group (falls back to the first) so ids stay unique where at all possible. Display
115 // artist = album-artist tag, else the one track artist if all share it, else
116 // "Various Artists".
117 private fun groupAlbums(tracks: List<LocalTrack>): List<Pair<LocalAlbum, List<LocalTrack>>> {
118 val groups = LinkedHashMap<Pair<String, String>, MutableList<LocalTrack>>()
119 for (t in tracks) {
120 groups.getOrPut(albumGroupKey(t)) { mutableListOf() }.add(t)
121 }
122 val usedIds = HashSet<Long>()
123 return groups.entries.map { (key, list) ->
124 val repId = list.asSequence().map { it.albumId }.firstOrNull { it !in usedIds }
125 ?: list.first().albumId
126 usedIds.add(repId)
127 val trackArtists = list.map { it.artist }.distinct()
128 val displayArtist = list.first().albumArtist
129 ?: if (trackArtists.size == 1) trackArtists[0] else "Various Artists"
130 LocalAlbum(
131 id = repId,
132 title = key.first,
133 artist = displayArtist,
134 artUri = ContentUris.withAppendedId(
135 Uri.parse("content://media/external/audio/albumart"), repId
136 ),
137 trackCount = list.size,
138 ) to list
139 }
140 }
141
142 /** All tracks in a given album, ordered by disc then track number.
143 *
144 * The albumId argument here is the MediaStore ALBUM_ID we picked
145 * (somewhat arbitrarily) as the representative when bucketing in
146 * [scanAlbums]. To handle partially-tagged libraries correctly, we
147 * resolve the (title, artist) pair from the cached track list using
148 * that album-id as a starting point, then return ALL tracks matching
149 * that tuple — which may be more than what MediaStore associated
150 * with the album-id alone. */
151 suspend fun tracksForAlbum(albumId: Long): List<LocalTrack> = withContext(Dispatchers.IO) {
152 val all = loadTracksCached()
153 // Maintainer, 2026-10-29: resolve via the same groups scanAlbums() builds (album
154 // id → its group); if the id is some track's own ALBUM_ID that isn't a group
155 // representative (Auto car screen, track parentId), use that track's group.
156 val groupTracks = groupAlbums(all).firstOrNull { it.first.id == albumId }?.second
157 ?: all.firstOrNull { it.albumId == albumId }?.let { seed ->
158 val key = albumGroupKey(seed)
159 all.filter { albumGroupKey(it) == key }
160 }
161 ?: return@withContext emptyList()
162 groupTracks
163 .sortedWith(
164 compareBy(
165 { it.discNumber ?: 0 },
166 { it.trackNumber ?: 0 },
167 { it.title.lowercase() },
168 )
169 )
170 }
171
172 /** Maintainer, 2026-10-29: the album a specific track belongs to, by that
173 * track's own group — exact even when one MediaStore ALBUM_ID is split
174 * across groups (mixed or missing album-artist tags), where looking up
175 * by album id could return a sibling group. Used by the car's album
176 * screen, which knows the seed track. */
177 suspend fun tracksForTrackAlbum(trackId: Long): List<LocalTrack> = withContext(Dispatchers.IO) {
178 val all = loadTracksCached()
179 val seed = all.firstOrNull { it.id == trackId } ?: return@withContext emptyList()
180 val key = albumGroupKey(seed)
181 all.filter { albumGroupKey(it) == key }
182 .sortedWith(
183 compareBy(
184 { it.discNumber ?: 0 },
185 { it.trackNumber ?: 0 },
186 { it.title.lowercase() },
187 )
188 )
189 }
190
191 /** All artists on the device.
192 *
193 * Aggregated from ID3-corrected track data for the same reason
194 * [scanAlbums] is — MediaStore.Audio.Artists has the same folder-name
195 * leakage when tag data is missing or partial. We pick the most-common
196 * ID3 artist per track and dedupe.
197 *
198 * The Long ID returned in each pair is *synthetic* — a stable hash of
199 * the artist name, NOT a MediaStore.Audio.Artists row ID. It exists so
200 * navigation arguments can carry the artist as a Long and we can resolve
201 * it back to a name in [LocalArtistScreen]. The actual artist lookup
202 * uses the name string. */
203 suspend fun scanArtists(): List<Pair<Long, String>> = withContext(Dispatchers.IO) {
204 loadTracksCached()
205 .mapNotNull { it.albumArtist ?: it.artist }
206 .distinct()
207 .sortedBy { it.lowercase() }
208 .map { name -> name.hashCode().toLong() to name }
209 }
210
211 /** Albums by a specific artist. Built from the ID3-corrected track list,
212 * matching either the album-artist tag or the per-track artist. */
213 suspend fun albumsForArtist(artistName: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
214 // Maintainer, 2026-10-29: albums no longer split per track artist, so also match
215 // albums (e.g. "Various Artists" compilations) containing a track by this artist.
216 groupAlbums(loadTracksCached())
217 .filter { (album, list) ->
218 album.artist == artistName || list.any { (it.albumArtist ?: it.artist) == artistName }
219 }
220 .map { it.first }
221 .sortedBy { it.title.lowercase() }
222 }
223
224 /** All tracks by a single artist, ordered by album then disc/track. */
225 suspend fun tracksForArtist(artistName: String): List<LocalTrack> = withContext(Dispatchers.IO) {
226 loadTracksCached()
227 .filter { (it.albumArtist ?: it.artist) == artistName }
228 .sortedWith(
229 compareBy(
230 { it.album ?: "" },
231 { it.discNumber ?: 0 },
232 { it.trackNumber ?: 0 },
233 { it.title.lowercase() },
234 )
235 )
236 }
237
238 /** All tracks on the device (for random/shuffle). Routes through the
239 * shared cache so repeated calls in a session don't re-read every file. */
240 suspend fun allTracks(): List<LocalTrack> = withContext(Dispatchers.IO) {
241 loadTracksCached()
242 }
243
244 /** Search tracks by title. Substring match against the ID3-corrected
245 * cached track list (case-insensitive). */
246 suspend fun searchTracks(query: String): List<LocalTrack> = withContext(Dispatchers.IO) {
247 val q = query.trim().lowercase()
248 if (q.isBlank()) return@withContext emptyList()
249 // Cache self-heal: if the cache is non-null but empty, and the
250 // device has READ_MEDIA_AUDIO permission now, invalidate so
251 // the next load reads MediaStore again. Covers the cold-start
252 // case where Auto bound the service before the user granted
253 // permission via MainActivity, the first scan returned 0 rows,
254 // and the cache is stuck at empty even though tracks now exist.
255 maybeInvalidateEmptyCache()
256 loadTracksCached().filter {
257 it.title.lowercase().contains(q) ||
258 it.artist?.lowercase()?.contains(q) == true ||
259 it.albumArtist?.lowercase()?.contains(q) == true ||
260 it.album?.lowercase()?.contains(q) == true
261 }
262 }
263
264 /** Search albums by title or artist. Returns browsable results for Auto. */
265 suspend fun searchAlbums(query: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
266 val q = query.trim().lowercase()
267 if (q.isBlank()) return@withContext emptyList()
268 maybeInvalidateEmptyCache()
269 scanAlbums().filter {
270 it.title.lowercase().contains(q) ||
271 it.artist?.lowercase()?.contains(q) == true
272 }
273 }
274
275 /** If the cache is currently an empty list (first scan ran with no
276 * permission, or the device truly had no audio at scan time),
277 * invalidate so the next load re-queries. Cheap: only triggers
278 * when `cachedTracks.isEmpty()`, never reloads a populated cache. */
279 private fun maybeInvalidateEmptyCache() {
280 if (cachedTracks?.isEmpty() == true) {
281 cachedTracks = null
282 }
283 }
284
285 /** All local tracks matching a genre name (case-insensitive contains). */
286 suspend fun tracksByGenre(genre: String): List<LocalTrack> = withContext(Dispatchers.IO) {
287 val g = genre.trim().lowercase()
288 if (g.isBlank()) emptyList()
289 else loadTracksCached().filter {
290 it.genre?.lowercase()?.contains(g) == true
291 }
292 }
293
294 /** Albums that have at least one track matching the genre. */
295 suspend fun albumsByGenre(genre: String): List<LocalAlbum> = withContext(Dispatchers.IO) {
296 val genreTracks = tracksByGenre(genre)
297 // Maintainer, 2026-10-29: match by group membership (track ids) — a group's id is
298 // only one of its tracks' ALBUM_IDs, so matching on ALBUM_ID could miss albums.
299 val genreTrackIds = genreTracks.map { it.id }.toSet()
300 groupAlbums(loadTracksCached())
301 .filter { (_, list) -> list.any { it.id in genreTrackIds } }
302 .map { it.first }
303 .sortedBy { it.title.lowercase() }
304 }
305
306 /** Random sample of [count] albums from the local library. */
307 suspend fun randomAlbums(count: Int): List<LocalAlbum> = withContext(Dispatchers.IO) {
308 scanAlbums().shuffled().take(count)
309 }
310
311 /** Random sample of [count] tracks from the local library. */
312 suspend fun randomTracks(count: Int): List<LocalTrack> = withContext(Dispatchers.IO) {
313 loadTracksCached().shuffled().take(count)
314 }
315
316 private fun queryTracks(
317 selection: String?,
318 selectionArgs: Array<String>?,
319 sortOrder: String?,
320 ): List<LocalTrack> {
321 val tracks = mutableListOf<LocalTrack>()
322 val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
323 val projection = buildList {
324 add(MediaStore.Audio.Media._ID)
325 add(MediaStore.Audio.Media.TITLE)
326 add(MediaStore.Audio.Media.ARTIST)
327 add(MediaStore.Audio.Media.ALBUM)
328 add(MediaStore.Audio.Media.ALBUM_ID)
329 add(MediaStore.Audio.Media.TRACK)
330 add(MediaStore.Audio.Media.DURATION)
331 add(MediaStore.Audio.Media.MIME_TYPE)
332 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
333 add(MediaStore.Audio.Media.DISC_NUMBER)
334 add(MediaStore.Audio.Media.ALBUM_ARTIST)
335 add(MediaStore.Audio.Media.GENRE)
336 }
337 }.toTypedArray()
338
339 val fullSelection = if (selection != null) {
340 "${MediaStore.Audio.Media.IS_MUSIC} = 1 AND $selection"
341 } else {
342 "${MediaStore.Audio.Media.IS_MUSIC} = 1"
343 }
344
345 // Maintainer, 2026-10-29: on API 26–29 also ask for the pre-R "album_artist" column
346 // (public constant is API 30+), so album grouping has the tag there too. If an
347 // older provider rejects the column, query again with the base projection.
348 val legacyProjection = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
349 projection + LEGACY_ALBUM_ARTIST else null
350 runCatching {
351 val opened = legacyProjection?.let { lp ->
352 runCatching {
353 context.contentResolver.query(uri, lp, fullSelection, selectionArgs, sortOrder)
354 }.getOrNull()
355 } ?: context.contentResolver.query(uri, projection, fullSelection, selectionArgs, sortOrder)
356 opened?.use { cursor ->
357 val colId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
358 val colTitle = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
359 val colArtist = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
360 val colAlbum = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
361 val colAlbumId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
362 val colTrack = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
363 val colDuration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
364 val colMime = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
365 val colDisc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
366 cursor.getColumnIndex(MediaStore.Audio.Media.DISC_NUMBER) else -1
367 // Maintainer, 2026-10-29: pre-R reads the legacy column (-1 if absent).
368 val colAlbumArtist = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
369 cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ARTIST)
370 else cursor.getColumnIndex(LEGACY_ALBUM_ARTIST)
371 val colGenre = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
372 cursor.getColumnIndex(MediaStore.Audio.Media.GENRE) else -1
373
374 while (cursor.moveToNext()) {
375 val trackId = cursor.getLong(colId)
376 val albumId = cursor.getLong(colAlbumId)
377 val contentUri = ContentUris.withAppendedId(
378 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, trackId
379 )
380 val artUri = ContentUris.withAppendedId(
381 Uri.parse("content://media/external/audio/albumart"), albumId
382 )
383
384 // ── MediaStore is the source of truth here ─────────────
385 // We previously enriched each row via MediaMetadataRetriever
386 // to read ID3 tags directly. Reverted: MMR can crash the
387 // process *natively* (SIGSEGV) on malformed audio files —
388 // unrecoverable, no Java exception, no try/catch protection.
389 // For libraries containing even one bad file it makes the
390 // app dead on launch.
391 //
392 // MediaStore.Audio is itself populated from ID3 tags by
393 // Android's system MediaScanner — for well-tagged files
394 // it reads the same TIT2 / TPE1 / TALB frames MMR does.
395 // The system scanner only falls back to filename / parent-
396 // folder when a tag is missing or the file is malformed.
397 // We accept that fallback: a few albums showing folder
398 // names is strictly better than the app crashing.
399 //
400 // Per-track MMR can be reintroduced later as on-demand
401 // enrichment for a *single* user-selected track, where
402 // a native crash would only kill that one operation
403 // (caller can guard the launch).
404
405 tracks.add(
406 LocalTrack(
407 id = trackId,
408 title = cursor.getString(colTitle) ?: "Unknown",
409 artist = cursor.getString(colArtist)?.takeIf { it != "<unknown>" },
410 albumArtist = if (colAlbumArtist >= 0)
411 cursor.getString(colAlbumArtist)?.takeIf { it != "<unknown>" }
412 else null,
413 album = cursor.getString(colAlbum)?.takeIf { it != "<unknown>" },
414 albumId = albumId,
415 // MediaStore packs disc × 1000 + track into TRACK
416 // (1005 = disc 1, track 5); show the track, and take
417 // the disc from it when the DISC column is absent
418 // (design decision 2026-09-02, I.70).
419 trackNumber = cursor.getInt(colTrack).let { raw ->
420 (if (raw >= 1000) raw % 1000 else raw).takeIf { it > 0 }
421 },
422 discNumber = (if (colDisc >= 0) cursor.getInt(colDisc).takeIf { it > 0 } else null)
423 ?: cursor.getInt(colTrack).let { raw -> if (raw >= 1000) raw / 1000 else null },
424 duration = cursor.getLong(colDuration),
425 mimeType = cursor.getString(colMime),
426 genre = if (colGenre >= 0)
427 cursor.getString(colGenre)?.takeIf { it.isNotBlank() }
428 else null,
429 contentUri = contentUri,
430 artUri = artUri,
431 )
432 )
433 }
434 }
435 }
436 return tracks
437 }
438
439 companion object {
440 /** Prefix for all local media IDs in the browse tree. */
441 const val LOCAL_PREFIX = "earhorn_local:"
442 const val LOCAL_ROOT = "${LOCAL_PREFIX}root"
443 const val LOCAL_ALBUMS = "${LOCAL_PREFIX}albums"
444 const val LOCAL_ARTISTS = "${LOCAL_PREFIX}artists"
445 const val LOCAL_ALBUM = "${LOCAL_PREFIX}album:"
446 const val LOCAL_ARTIST = "${LOCAL_PREFIX}artist:"
447 // Maintainer, 2026-10-29: pre-API-30 name of the audio album-artist column.
448 private const val LEGACY_ALBUM_ARTIST = "album_artist"
449 }
450}
451
452// ── Conversion helpers: LocalTrack → DidlItem.Track ─────────────────
453
454fun LocalMediaScanner.LocalTrack.toDidlTrack(): DidlItem.Track = DidlItem.Track(
455 id = "${LocalMediaScanner.LOCAL_PREFIX}track:$id",
456 parentId = "${LocalMediaScanner.LOCAL_ALBUM}$albumId",
457 title = title,
458 artist = artist,
459 albumArtist = albumArtist,
460 album = album,
461 composer = null,
462 genre = genre,
463 date = null,
464 trackNumber = trackNumber,
465 discNumber = discNumber,
466 duration = duration.milliseconds,
467 albumArtUri = artUri?.toString(),
468 resourceUrl = contentUri.toString(),
469 mimeType = mimeType,
470 sampleFrequency = null,
471 bitsPerSample = null,
472 channels = null,
473 bitrate = null,
474 size = null,
475)