LocalMediaScanner.kt
Each line, with the release that last changed it.
| 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 | ) |


