LocalMediaScanner.kt
475 lines · 22.8 KB
Changed in: 0.5.0
| 1 | package dev.stave.local |
| 2 | |
| 3 | import android.content.ContentUris |
| 4 | import android.content.Context |
| 5 | import android.net.Uri |
| 6 | import android.os.Build |
| 7 | import android.provider.MediaStore |
| 8 | import dagger.hilt.android.qualifiers.ApplicationContext |
| 9 | import dev.stave.domain.model.DidlItem |
| 10 | import kotlinx.coroutines.Dispatchers |
| 11 | import kotlinx.coroutines.withContext |
| 12 | import javax.inject.Inject |
| 13 | import javax.inject.Singleton |
| 14 | import 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 |
| 29 | class 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 | |
| 454 | fun 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 | ) |


