From fd32c2f02d801af0d9795d865013b8744c720818 Mon Sep 17 00:00:00 2001 From: ziven Date: Thu, 20 Aug 2026 23:23:53 +0800 Subject: [PATCH 1/7] fix(android): advance skipped library query rows --- android/build.gradle | 1 + .../com/musiclibrary/albums/GetAlbumsQuery.kt | 73 ++++++++--------- .../musiclibrary/artists/GetArtistsQuery.kt | 63 ++++++--------- .../musiclibrary/utils/CursorPageReader.kt | 32 ++++++++ .../utils/CursorPageReaderTest.kt | 79 +++++++++++++++++++ 5 files changed, 169 insertions(+), 79 deletions(-) create mode 100644 android/src/main/java/com/musiclibrary/utils/CursorPageReader.kt create mode 100644 android/src/test/java/com/musiclibrary/utils/CursorPageReaderTest.kt diff --git a/android/build.gradle b/android/build.gradle index eda4e63..1689f9d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -75,6 +75,7 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation("net.jthink:jaudiotagger:3.0.1") + testImplementation "junit:junit:4.13.2" } react { diff --git a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt index 927a983..c8feaf0 100644 --- a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt +++ b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt @@ -5,6 +5,7 @@ import android.provider.MediaStore import android.net.Uri import android.provider.DocumentsContract import com.musiclibrary.models.* +import com.musiclibrary.utils.readCursorPage import androidx.core.net.toUri object GetAlbumsQuery { @@ -32,12 +33,9 @@ object GetAlbumsQuery { sortOrder ) ?: throw RuntimeException("Failed to query MediaStore: cursor is null") - val albums = mutableListOf() - var hasNextPage: Boolean - var endCursor: String? = null val totalCount = cursor.count - cursor.use { c -> + return cursor.use { c -> val idColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums._ID) val albumColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.ALBUM) val artistColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.ARTIST) @@ -63,11 +61,11 @@ object GetAlbumsQuery { found && cursor.moveToNext() } - var count = 0 val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries - - while (foundAfter && count < maxItems) { - try { + val page = readCursorPage( + canReadFirst = foundAfter, + maxItems = maxItems, + readItem = { val id = c.getLong(idColumn) val albumTitle = c.getString(albumColumn) ?: "" val artist = c.getString(artistColumn) ?: "" @@ -76,42 +74,33 @@ object GetAlbumsQuery { // Skip invalid albums if (albumTitle.isEmpty() || trackCount == 0) { - continue + null + } else { + // Get artwork URI + val artworkUri: Uri = "content://media/external/audio/albumart/${id}".toUri() + + // Create an Album + Album( + id = id.toString(), + title = albumTitle, + artist = artist, + artwork = artworkUri.toString(), + trackCount = trackCount, + year = if (firstYear > 0) firstYear else null + ) } - - // Get artwork URI - val artworkUri: Uri = "content://media/external/audio/albumart/${id}".toUri() - - // Create an Album - val album = Album( - id = id.toString(), - title = albumTitle, - artist = artist, - artwork = artworkUri.toString(), - trackCount = trackCount, - year = if (firstYear > 0) firstYear else null - ) - - albums.add(album) - endCursor = id.toString() - count++ - } catch (e: Exception) { - continue - } - - if (!cursor.moveToNext()) break - } - - // Check if there are more data - hasNextPage = !c.isAfterLast + }, + moveToNext = c::moveToNext, + isAfterLast = { c.isAfterLast }, + ) + + PaginatedResult( + items = page.items, + hasNextPage = page.hasNextPage, + endCursor = page.items.lastOrNull()?.id, + totalCount = totalCount + ) } - - return PaginatedResult( - items = albums, - hasNextPage = hasNextPage, - endCursor = endCursor, - totalCount = totalCount - ) } private fun buildSelection(options: AlbumOptions): String { diff --git a/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt b/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt index ce77efd..174546f 100644 --- a/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt +++ b/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt @@ -5,6 +5,7 @@ import android.provider.MediaStore import android.net.Uri import android.provider.DocumentsContract import com.musiclibrary.models.* +import com.musiclibrary.utils.readCursorPage import androidx.core.net.toUri object GetArtistsQuery { @@ -31,12 +32,9 @@ object GetArtistsQuery { sortOrder ) ?: throw RuntimeException("Failed to query MediaStore: cursor is null") - val artists = mutableListOf() - var hasNextPage: Boolean - var endCursor: String? = null val totalCount = cursor.count - cursor.use { c -> + return cursor.use { c -> val idColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Artists._ID) val artistColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Artists.ARTIST) val albumCountColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Artists.NUMBER_OF_ALBUMS) @@ -61,11 +59,11 @@ object GetArtistsQuery { found && cursor.moveToNext() } - var count = 0 val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries - - while (foundAfter && count < maxItems) { - try { + val page = readCursorPage( + canReadFirst = foundAfter, + maxItems = maxItems, + readItem = { val id = c.getLong(idColumn) val artistName = c.getString(artistColumn) ?: "" val albumCount = c.getInt(albumCountColumn) @@ -73,37 +71,28 @@ object GetArtistsQuery { // Skip invalid artists if (artistName.isEmpty() || trackCount == 0) { - continue + null + } else { + // Create an Artist + Artist( + id = id.toString(), + title = artistName, + albumCount = albumCount, + trackCount = trackCount + ) } - - // Create an Artist - val artist = Artist( - id = id.toString(), - title = artistName, - albumCount = albumCount, - trackCount = trackCount - ) - - artists.add(artist) - endCursor = id.toString() - count++ - } catch (e: Exception) { - continue - } - - if (!cursor.moveToNext()) break - } - - // Check if there are more data - hasNextPage = !c.isAfterLast + }, + moveToNext = c::moveToNext, + isAfterLast = { c.isAfterLast }, + ) + + PaginatedResult( + items = page.items, + hasNextPage = page.hasNextPage, + endCursor = page.items.lastOrNull()?.id, + totalCount = totalCount + ) } - - return PaginatedResult( - items = artists, - hasNextPage = hasNextPage, - endCursor = endCursor, - totalCount = totalCount - ) } private fun buildSelection(options: ArtistOptions): String { diff --git a/android/src/main/java/com/musiclibrary/utils/CursorPageReader.kt b/android/src/main/java/com/musiclibrary/utils/CursorPageReader.kt new file mode 100644 index 0000000..1f3c34e --- /dev/null +++ b/android/src/main/java/com/musiclibrary/utils/CursorPageReader.kt @@ -0,0 +1,32 @@ +package com.musiclibrary.utils + +internal data class CursorPage( + val items: List, + val hasNextPage: Boolean, +) + +internal fun readCursorPage( + canReadFirst: Boolean, + maxItems: Int, + readItem: () -> T?, + moveToNext: () -> Boolean, + isAfterLast: () -> Boolean, +): CursorPage { + val items = mutableListOf() + var canReadCurrent = canReadFirst + + while (canReadCurrent && items.size < maxItems) { + try { + readItem()?.let(items::add) + } catch (_: Exception) { + // Skip rows that cannot be converted, but always advance below. + } + + canReadCurrent = moveToNext() + } + + return CursorPage( + items = items, + hasNextPage = !isAfterLast(), + ) +} diff --git a/android/src/test/java/com/musiclibrary/utils/CursorPageReaderTest.kt b/android/src/test/java/com/musiclibrary/utils/CursorPageReaderTest.kt new file mode 100644 index 0000000..1943e8e --- /dev/null +++ b/android/src/test/java/com/musiclibrary/utils/CursorPageReaderTest.kt @@ -0,0 +1,79 @@ +package com.musiclibrary.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CursorPageReaderTest { + @Test + fun skipsInvalidRowsAndContinuesReading() { + val rows = listOf(null, "album-1", null, "album-2") + var position = 0 + + val page = readCursorPage( + canReadFirst = true, + maxItems = 2, + readItem = { rows[position] }, + moveToNext = { + position++ + position < rows.size + }, + isAfterLast = { position >= rows.size }, + ) + + assertEquals(listOf("album-1", "album-2"), page.items) + assertFalse(page.hasNextPage) + assertEquals(rows.size, position) + } + + @Test + fun advancesAfterConversionFailure() { + val rows = listOf("broken", "artist-1") + var position = 0 + var reads = 0 + + val page = readCursorPage( + canReadFirst = true, + maxItems = 1, + readItem = { + reads++ + if (rows[position] == "broken") { + throw IllegalStateException("Cannot convert row") + } + rows[position] + }, + moveToNext = { + position++ + position < rows.size + }, + isAfterLast = { position >= rows.size }, + ) + + assertEquals(listOf("artist-1"), page.items) + assertEquals(2, reads) + assertEquals(rows.size, position) + assertFalse(page.hasNextPage) + } + + @Test + fun reportsAnotherPageAfterFillingTheCurrentPage() { + val rows = listOf("artist-1", "artist-2") + var position = 0 + + val page = readCursorPage( + canReadFirst = true, + maxItems = 1, + readItem = { rows[position] }, + moveToNext = { + position++ + position < rows.size + }, + isAfterLast = { position >= rows.size }, + ) + + assertEquals(listOf("artist-1"), page.items) + assertEquals(1, position) + assertTrue(page.hasNextPage) + } +} From f5986e599e8c18aca70c8882abde869cdeb8e49a Mon Sep 17 00:00:00 2001 From: ziven Date: Thu, 20 Aug 2026 23:37:16 +0800 Subject: [PATCH 2/7] fix: enforce stable pagination contracts --- CONTEXT.md | 2 +- README.md | 40 +++++---- .../java/com/musiclibrary/albums/GetAlbums.kt | 3 +- .../com/musiclibrary/albums/GetAlbumsQuery.kt | 65 ++++---------- .../com/musiclibrary/artists/GetArtists.kt | 3 +- .../musiclibrary/artists/GetArtistsQuery.kt | 47 ++++------- .../java/com/musiclibrary/tracks/GetTracks.kt | 3 +- .../musiclibrary/tracks/GetTracksByArtist.kt | 3 +- .../com/musiclibrary/tracks/TrackQuery.kt | 50 ++++++----- .../java/com/musiclibrary/utils/Pagination.kt | 70 ++++++++++++++++ .../musiclibrary/albums/AlbumSortOrderTest.kt | 17 ++++ .../artists/ArtistSortOrderTest.kt | 17 ++++ .../musiclibrary/tracks/TrackSortOrderTest.kt | 26 ++++++ .../com/musiclibrary/utils/PaginationTest.kt | 84 +++++++++++++++++++ docs/docs/api.md | 29 +++++-- .../current/api.md | 27 ++++-- ios/MusicLibraryImpl.swift | 23 ++++- ios/albums/GetAlbumsQuery.swift | 80 +++++++++--------- ios/artists/GetArtistsQuery.swift | 74 ++++++++-------- ios/models/PaginatedResult.swift | 42 +++++++++- ios/tracks/GetTracks.swift | 4 +- ios/tracks/GetTracksByArtistQuery.swift | 4 +- ios/tracks/TrackQuery.swift | 46 +++++----- src/NativeMusicLibrary.ts | 12 +-- src/__tests__/index.test.tsx | 59 ++++++++++++- src/index.tsx | 6 +- src/utils.ts | 35 ++++++-- 27 files changed, 605 insertions(+), 266 deletions(-) create mode 100644 android/src/main/java/com/musiclibrary/utils/Pagination.kt create mode 100644 android/src/test/java/com/musiclibrary/albums/AlbumSortOrderTest.kt create mode 100644 android/src/test/java/com/musiclibrary/artists/ArtistSortOrderTest.kt create mode 100644 android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt create mode 100644 android/src/test/java/com/musiclibrary/utils/PaginationTest.kt diff --git a/CONTEXT.md b/CONTEXT.md index 3e57154..ecd2789 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -53,7 +53,7 @@ _Avoid_: offset, page number - A **Track** has **Library Metadata** and may have **Embedded Metadata** - **Track Metadata** may combine **Library Metadata** and **Embedded Metadata** - A **Track** or **Album** may have zero or one **Artwork Reference** -- A **Cursor** points to one **Track**, **Album**, or **Artist** in a paginated list +- A **Cursor** points to one **Track**, **Album**, or **Artist** in a paginated list and is valid only with the entity type, filters, and sort that produced it ## Example dialogue diff --git a/README.md b/README.md index 8894fc5..0cc95ba 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,17 @@ getTracksAsync(options?: TrackOptions): Promise> | Property | Type | Default | Description | | ----------- | -------------------------------------------------------- | ----------- | --------------------------------------- | -| `first` | `number` | `20` | Max number of items to return | +| `first` | `number` | `20` | Items to return (`1`–`1000`) | | `after` | `string` | — | Cursor from previous page's `endCursor` | | `sortBy` | `TrackSortByKey \| [TrackSortByKey, boolean] \| (...)[]` | `'default'` | Sort key, or tuple `[key, ascending]` | | `directory` | `string` | — | Filter by directory path (Android only) | **TrackSortByKey**: `'default' \| 'title' \| 'artist' \| 'album' \| 'duration' \| 'createdAt' \| 'modifiedAt' \| 'fileSize'` +The default sort is title ascending. A bare sort key such as `sortBy: 'artist'` +means descending; use `[key, true]` for ascending. Multiple descriptors are +applied in order, with the entity ID used as a final ascending tie-breaker. + --- ### `getTrackMetadataAsync(trackId)` @@ -145,7 +149,7 @@ getAlbumsAsync(options?: AlbumOptions): Promise> | Property | Type | Default | Description | | -------- | -------------------------------------------------------- | ----------- | --------------------------------------- | -| `first` | `number` | `20` | Max number of items to return | +| `first` | `number` | `20` | Items to return (`1`–`1000`) | | `after` | `string` | — | Cursor from previous page's `endCursor` | | `sortBy` | `AlbumSortByKey \| [AlbumSortByKey, boolean] \| (...)[]` | `'default'` | Sort key, or tuple `[key, ascending]` | @@ -175,7 +179,7 @@ getArtistsAsync(options?: ArtistOptions): Promise> | Property | Type | Default | Description | | -------- | ---------------------------------------------------------- | ----------- | --------------------------------------- | -| `first` | `number` | `20` | Max number of items to return | +| `first` | `number` | `20` | Items to return (`1`–`1000`) | | `after` | `string` | — | Cursor from previous page's `endCursor` | | `sortBy` | `ArtistSortByKey \| [ArtistSortByKey, boolean] \| (...)[]` | `'default'` | Sort key, or tuple `[key, ascending]` | @@ -187,18 +191,18 @@ getArtistsAsync(options?: ArtistOptions): Promise> ### `Track` -| Field | Type | Description | -| ------------ | --------- | ----------------------------------- | -| `id` | `string` | Unique identifier | -| `title` | `string` | Track title | -| `artist` | `string` | Artist name | -| `artwork` | `string?` | Artwork file URI (may be undefined) | -| `album` | `string` | Album name | -| `duration` | `number` | Duration in seconds | -| `url` | `string` | File URI | -| `createdAt` | `number` | Unix timestamp (seconds) | -| `modifiedAt` | `number` | Unix timestamp (seconds) | -| `fileSize` | `number` | File size in bytes | +| Field | Type | Description | +| ------------ | --------- | --------------------------------------------------------- | +| `id` | `string` | Unique identifier | +| `title` | `string` | Track title | +| `artist` | `string` | Artist name | +| `artwork` | `string?` | Artwork file URI (may be undefined) | +| `album` | `string` | Album name | +| `duration` | `number` | Duration in seconds | +| `url` | `string` | File URI | +| `createdAt` | `number?` | Date added, Unix timestamp in seconds | +| `modifiedAt` | `number?` | Resource modification time in Unix seconds; `null` on iOS | +| `fileSize` | `number` | File size in bytes | ### `TrackMetadata` @@ -252,6 +256,12 @@ getArtistsAsync(options?: ArtistOptions): Promise> | `endCursor` | `string?` | Pass to `after` to fetch next page | | `totalCount` | `number?` | Total count (may be expensive to compute) | +`endCursor` is the ID of the last returned entity. Reuse it only with the same +entity, filters, and sort options. A malformed cursor rejects with +`INVALID_CURSOR`; a valid ID absent from the current result rejects with +`CURSOR_NOT_FOUND`. A cursor at the final entity returns an empty terminal page +without a new `endCursor`. + --- ## 🔄 Pagination Example diff --git a/android/src/main/java/com/musiclibrary/albums/GetAlbums.kt b/android/src/main/java/com/musiclibrary/albums/GetAlbums.kt index 75cd582..564b793 100644 --- a/android/src/main/java/com/musiclibrary/albums/GetAlbums.kt +++ b/android/src/main/java/com/musiclibrary/albums/GetAlbums.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.musiclibrary.models.AlbumOptions import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetAlbums( private val context: Context, @@ -22,7 +23,7 @@ internal class GetAlbums( promise.resolve(writableMap) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query albums: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query albums") } } } diff --git a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt index c8feaf0..9f97fa5 100644 --- a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt +++ b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt @@ -2,17 +2,18 @@ package com.musiclibrary.albums import android.content.ContentResolver import android.provider.MediaStore -import android.net.Uri -import android.provider.DocumentsContract import com.musiclibrary.models.* +import com.musiclibrary.utils.moveToPageStart import com.musiclibrary.utils.readCursorPage -import androidx.core.net.toUri +import com.musiclibrary.utils.validatePageSize object GetAlbumsQuery { fun getAlbums( contentResolver: ContentResolver, options: AlbumOptions, ): PaginatedResult { + validatePageSize(options.first) + val projection = arrayOf( MediaStore.Audio.Albums._ID, MediaStore.Audio.Albums.ALBUM, @@ -23,7 +24,7 @@ object GetAlbumsQuery { val selection = buildSelection(options) val selectionArgs = buildSelectionArgs(options) - val sortOrder = buildSortOrder(options.sortBy) + val sortOrder = buildAlbumSortOrder(options.sortBy) val cursor = contentResolver.query( MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, @@ -42,29 +43,16 @@ object GetAlbumsQuery { val trackCountColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.NUMBER_OF_SONGS) val firstYearColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.FIRST_YEAR) - // Jump to the specified start position - val foundAfter = if (options.after == null) { - cursor.moveToFirst() // Move to the first record - true - } else { - var found = false - if (cursor.moveToFirst()) { - do { - val id = cursor.getLong(idColumn).toString() - if (id == options.after) { - found = true - break - } - } while (cursor.moveToNext()) - } - // Move to the next record after the specified after if found - found && cursor.moveToNext() - } + val foundAfter = moveToPageStart( + after = options.after, + moveToFirst = c::moveToFirst, + currentId = { c.getLong(idColumn).toString() }, + moveToNext = c::moveToNext, + ) - val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries val page = readCursorPage( canReadFirst = foundAfter, - maxItems = maxItems, + maxItems = options.first, readItem = { val id = c.getLong(idColumn) val albumTitle = c.getString(albumColumn) ?: "" @@ -77,14 +65,12 @@ object GetAlbumsQuery { null } else { // Get artwork URI - val artworkUri: Uri = "content://media/external/audio/albumart/${id}".toUri() - // Create an Album Album( id = id.toString(), title = albumTitle, artist = artist, - artwork = artworkUri.toString(), + artwork = "content://media/external/audio/albumart/$id", trackCount = trackCount, year = if (firstYear > 0) firstYear else null ) @@ -116,26 +102,9 @@ object GetAlbumsQuery { return null } - private fun uriToFullPath(treeUri: Uri): String? { - val docId = DocumentsContract.getTreeDocumentId(treeUri) // "primary:Music/abc" - val parts = docId.split(":") - if (parts.size < 2) return null - - val type = parts[0] - val relativePath = parts[1] - - return when (type) { - "primary" -> "/storage/emulated/0/$relativePath" - else -> "/storage/$type/$relativePath" - } - } - - private fun buildSortOrder(sortBy: List): String { - if (sortBy.isEmpty()) { - return "${MediaStore.Audio.Albums.ALBUM} ASC" - } - - return sortBy.joinToString(", ") { sortOption -> + internal fun buildAlbumSortOrder(sortBy: List): String { + val options = sortBy.ifEmpty { listOf(SortOption("default", true)) } + val descriptors = options.map { sortOption -> val column = when (sortOption.key.lowercase()) { "default" -> MediaStore.Audio.Albums.ALBUM "title" -> MediaStore.Audio.Albums.ALBUM @@ -148,5 +117,7 @@ object GetAlbumsQuery { val order = if (sortOption.ascending) "ASC" else "DESC" "$column $order" } + + return (descriptors + "${MediaStore.Audio.Albums._ID} ASC").joinToString(", ") } } diff --git a/android/src/main/java/com/musiclibrary/artists/GetArtists.kt b/android/src/main/java/com/musiclibrary/artists/GetArtists.kt index 34418c6..6a0f2b8 100644 --- a/android/src/main/java/com/musiclibrary/artists/GetArtists.kt +++ b/android/src/main/java/com/musiclibrary/artists/GetArtists.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.musiclibrary.models.ArtistOptions import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetArtists( private val context: Context, @@ -23,7 +24,7 @@ internal class GetArtists( promise.resolve(resultMap) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query artists: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query artists") } } } diff --git a/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt b/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt index 174546f..1ed295b 100644 --- a/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt +++ b/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt @@ -2,17 +2,18 @@ package com.musiclibrary.artists import android.content.ContentResolver import android.provider.MediaStore -import android.net.Uri -import android.provider.DocumentsContract import com.musiclibrary.models.* +import com.musiclibrary.utils.moveToPageStart import com.musiclibrary.utils.readCursorPage -import androidx.core.net.toUri +import com.musiclibrary.utils.validatePageSize object GetArtistsQuery { fun getArtists( contentResolver: ContentResolver, options: ArtistOptions, ): PaginatedResult { + validatePageSize(options.first) + val projection = arrayOf( MediaStore.Audio.Artists._ID, MediaStore.Audio.Artists.ARTIST, @@ -22,7 +23,7 @@ object GetArtistsQuery { val selection = buildSelection(options) val selectionArgs = buildSelectionArgs(options) - val sortOrder = buildSortOrder(options.sortBy) + val sortOrder = buildArtistSortOrder(options.sortBy) val cursor = contentResolver.query( MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, @@ -40,29 +41,16 @@ object GetArtistsQuery { val albumCountColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Artists.NUMBER_OF_ALBUMS) val trackCountColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Artists.NUMBER_OF_TRACKS) - // Jump to the specified start position - val foundAfter = if (options.after == null) { - cursor.moveToFirst() // Move to the first record - true - } else { - var found = false - if (cursor.moveToFirst()) { - do { - val id = cursor.getLong(idColumn).toString() - if (id == options.after) { - found = true - break - } - } while (cursor.moveToNext()) - } - // Move to the next record after the specified after if found - found && cursor.moveToNext() - } + val foundAfter = moveToPageStart( + after = options.after, + moveToFirst = c::moveToFirst, + currentId = { c.getLong(idColumn).toString() }, + moveToNext = c::moveToNext, + ) - val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries val page = readCursorPage( canReadFirst = foundAfter, - maxItems = maxItems, + maxItems = options.first, readItem = { val id = c.getLong(idColumn) val artistName = c.getString(artistColumn) ?: "" @@ -108,12 +96,9 @@ object GetArtistsQuery { return null } - private fun buildSortOrder(sortBy: List): String { - if (sortBy.isEmpty()) { - return "${MediaStore.Audio.Artists.ARTIST} ASC" - } - - return sortBy.joinToString(", ") { sortOption -> + internal fun buildArtistSortOrder(sortBy: List): String { + val options = sortBy.ifEmpty { listOf(SortOption("default", true)) } + val descriptors = options.map { sortOption -> val column = when (sortOption.key.lowercase()) { "default" -> MediaStore.Audio.Artists.ARTIST "title" -> MediaStore.Audio.Artists.ARTIST @@ -125,5 +110,7 @@ object GetArtistsQuery { val order = if (sortOption.ascending) "ASC" else "DESC" "$column $order" } + + return (descriptors + "${MediaStore.Audio.Artists._ID} ASC").joinToString(", ") } } diff --git a/android/src/main/java/com/musiclibrary/tracks/GetTracks.kt b/android/src/main/java/com/musiclibrary/tracks/GetTracks.kt index 911388d..3f51683 100644 --- a/android/src/main/java/com/musiclibrary/tracks/GetTracks.kt +++ b/android/src/main/java/com/musiclibrary/tracks/GetTracks.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.musiclibrary.models.TrackOptions import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetTracks( private val context: Context, @@ -22,7 +23,7 @@ internal class GetTracks( promise.resolve(writableMap) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query tracks: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query tracks") } } } diff --git a/android/src/main/java/com/musiclibrary/tracks/GetTracksByArtist.kt b/android/src/main/java/com/musiclibrary/tracks/GetTracksByArtist.kt index 6ca3259..2921881 100644 --- a/android/src/main/java/com/musiclibrary/tracks/GetTracksByArtist.kt +++ b/android/src/main/java/com/musiclibrary/tracks/GetTracksByArtist.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.musiclibrary.models.TrackOptions import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetTracksByArtist( private val context: Context, @@ -24,7 +25,7 @@ internal class GetTracksByArtist( promise.resolve(resultMap) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query tracks by artist: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query tracks by artist") } } } diff --git a/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt b/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt index 80bf6d7..32e0f24 100644 --- a/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt +++ b/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt @@ -10,6 +10,8 @@ import com.musiclibrary.models.PaginatedResult import com.musiclibrary.models.SortOption import com.musiclibrary.models.Track import com.musiclibrary.models.TrackOptions +import com.musiclibrary.utils.moveToPageStart +import com.musiclibrary.utils.validatePageSize internal sealed class TrackQueryFilter { object All : TrackQueryFilter() @@ -28,19 +30,26 @@ internal object TrackQuery { filter: TrackQueryFilter = TrackQueryFilter.All, options: TrackOptions, ): PaginatedResult { + validatePageSize(options.first) + val cursor = queryTracks( contentResolver = contentResolver, filter = filter, directory = options.directory, - sortOrder = buildSortOrder(TrackSortPolicy.Options, options.sortBy) + sortOrder = buildTrackSortOrder(TrackSortPolicy.Options, options.sortBy) ) cursor.use { c -> val columns = TrackColumns.from(c) val tracks = mutableListOf() var endCursor: String? = null - val canReadFirst = moveToPageStart(c, columns, options.after) - val maxItems = options.first.coerceAtMost(1000) + val canReadFirst = moveToPageStart( + after = options.after, + moveToFirst = c::moveToFirst, + currentId = { c.getLong(columns.id).toString() }, + moveToNext = c::moveToNext, + ) + val maxItems = options.first var count = 0 while (canReadFirst && count < maxItems) { @@ -73,7 +82,7 @@ internal object TrackQuery { contentResolver = contentResolver, filter = filter, directory = null, - sortOrder = buildSortOrder(sortPolicy, emptyList()) + sortOrder = buildTrackSortOrder(sortPolicy, emptyList()) ) cursor.use { c -> @@ -103,23 +112,6 @@ internal object TrackQuery { ) ?: throw RuntimeException("Failed to query MediaStore: cursor is null") } - private fun moveToPageStart(cursor: Cursor, columns: TrackColumns, after: String?): Boolean { - if (after == null) { - return cursor.moveToFirst() - } - - if (cursor.moveToFirst()) { - do { - val id = cursor.getLong(columns.id).toString() - if (id == after) { - return cursor.moveToNext() - } - } while (cursor.moveToNext()) - } - - return false - } - private fun readTrack(cursor: Cursor, columns: TrackColumns): Track? { return try { val id = cursor.getLong(columns.id) @@ -134,6 +126,7 @@ internal object TrackQuery { val album = cursor.getString(columns.album) val duration = cursor.getLong(columns.duration) / 1000.0 val dateAdded = cursor.getLong(columns.dateAdded) + val dateModified = cursor.getLong(columns.dateModified) val fileSize = cursor.getLong(columns.size) Track( @@ -144,8 +137,8 @@ internal object TrackQuery { album = album, duration = duration, url = "file://$data", - createdAt = dateAdded * 1000, - modifiedAt = dateAdded * 1000, + createdAt = dateAdded, + modifiedAt = dateModified, fileSize = fileSize ) } catch (_: Exception) { @@ -196,13 +189,13 @@ internal object TrackQuery { return if (args.isEmpty()) null else args.toTypedArray() } - private fun buildSortOrder(sortPolicy: TrackSortPolicy, sortBy: List): String { + internal fun buildTrackSortOrder(sortPolicy: TrackSortPolicy, sortBy: List): String { if (sortPolicy == TrackSortPolicy.AlbumTrackNumber) { - return "${MediaStore.Audio.Media.TRACK} ASC, ${MediaStore.Audio.Media.TITLE} ASC" + return "${MediaStore.Audio.Media.TRACK} ASC, ${MediaStore.Audio.Media.TITLE} ASC, ${MediaStore.Audio.Media._ID} ASC" } val options = sortBy.ifEmpty { listOf(SortOption("default", true)) } - return options.joinToString(", ") { sortOption -> + val descriptors = options.map { sortOption -> val column = when (sortOption.key.lowercase()) { "default" -> MediaStore.Audio.Media.TITLE "title" -> MediaStore.Audio.Media.TITLE @@ -218,6 +211,8 @@ internal object TrackQuery { val order = if (sortOption.ascending) "ASC" else "DESC" "$column $order" } + + return (descriptors + "${MediaStore.Audio.Media._ID} ASC").joinToString(", ") } private fun uriToFullPath(treeUri: Uri): String? { @@ -246,6 +241,7 @@ internal object TrackQuery { val duration: Int, val data: Int, val dateAdded: Int, + val dateModified: Int, val size: Int, ) { companion object { @@ -258,6 +254,7 @@ internal object TrackQuery { duration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION), data = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA), dateAdded = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED), + dateModified = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED), size = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) ) } @@ -272,6 +269,7 @@ internal object TrackQuery { MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DATE_MODIFIED, MediaStore.Audio.Media.SIZE, MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.TRACK diff --git a/android/src/main/java/com/musiclibrary/utils/Pagination.kt b/android/src/main/java/com/musiclibrary/utils/Pagination.kt new file mode 100644 index 0000000..880f5f6 --- /dev/null +++ b/android/src/main/java/com/musiclibrary/utils/Pagination.kt @@ -0,0 +1,70 @@ +package com.musiclibrary.utils + +import com.facebook.react.bridge.Promise + +internal sealed class PaginationException( + val code: String, + message: String, +) : IllegalArgumentException(message) + +internal class InvalidCursorException(cursor: String) : PaginationException( + code = "INVALID_CURSOR", + message = "Cursor must be a positive decimal ID: $cursor", +) + +internal class CursorNotFoundException(cursor: String) : PaginationException( + code = "CURSOR_NOT_FOUND", + message = "Cursor is not present in the current query result: $cursor", +) + +internal class InvalidPageSizeException(first: Int) : PaginationException( + code = "INVALID_PAGE_SIZE", + message = "Page size must be between 1 and 1000: $first", +) + +internal fun validatePageSize(first: Int) { + if (first !in 1..1000) { + throw InvalidPageSizeException(first) + } +} + +internal fun moveToPageStart( + after: String?, + moveToFirst: () -> Boolean, + currentId: () -> String, + moveToNext: () -> Boolean, +): Boolean { + if (after == null) { + return moveToFirst() + } + + if (!isValidCursor(after)) { + throw InvalidCursorException(after) + } + + if (moveToFirst()) { + do { + if (currentId() == after) { + return moveToNext() + } + } while (moveToNext()) + } + + throw CursorNotFoundException(after) +} + +private fun isValidCursor(cursor: String): Boolean { + val maxCursorId = "18446744073709551615" + return cursor.matches(Regex("[1-9][0-9]*")) && + (cursor.length < maxCursorId.length || + (cursor.length == maxCursorId.length && cursor <= maxCursorId)) +} + +internal fun Promise.rejectQueryError(error: Exception, fallbackMessage: String) { + if (error is PaginationException) { + reject(error.code, error.message, error) + return + } + + reject("QUERY_ERROR", "$fallbackMessage: ${error.message}", error) +} diff --git a/android/src/test/java/com/musiclibrary/albums/AlbumSortOrderTest.kt b/android/src/test/java/com/musiclibrary/albums/AlbumSortOrderTest.kt new file mode 100644 index 0000000..b7ad514 --- /dev/null +++ b/android/src/test/java/com/musiclibrary/albums/AlbumSortOrderTest.kt @@ -0,0 +1,17 @@ +package com.musiclibrary.albums + +import com.musiclibrary.models.SortOption +import org.junit.Assert.assertEquals +import org.junit.Test + +class AlbumSortOrderTest { + @Test + fun `multiple sorts preserve declared priority before id`() { + assertEquals( + "artist ASC, album DESC, _id ASC", + GetAlbumsQuery.buildAlbumSortOrder( + listOf(SortOption("artist", true), SortOption("title", false)), + ), + ) + } +} diff --git a/android/src/test/java/com/musiclibrary/artists/ArtistSortOrderTest.kt b/android/src/test/java/com/musiclibrary/artists/ArtistSortOrderTest.kt new file mode 100644 index 0000000..63a31c5 --- /dev/null +++ b/android/src/test/java/com/musiclibrary/artists/ArtistSortOrderTest.kt @@ -0,0 +1,17 @@ +package com.musiclibrary.artists + +import com.musiclibrary.models.SortOption +import org.junit.Assert.assertEquals +import org.junit.Test + +class ArtistSortOrderTest { + @Test + fun `multiple sorts preserve declared priority before id`() { + assertEquals( + "number_of_albums DESC, artist ASC, _id ASC", + GetArtistsQuery.buildArtistSortOrder( + listOf(SortOption("albumCount", false), SortOption("title", true)), + ), + ) + } +} diff --git a/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt b/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt new file mode 100644 index 0000000..fafea1a --- /dev/null +++ b/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt @@ -0,0 +1,26 @@ +package com.musiclibrary.tracks + +import com.musiclibrary.models.SortOption +import org.junit.Assert.assertEquals +import org.junit.Test + +class TrackSortOrderTest { + @Test + fun `default sort is title ascending with stable id tie breaker`() { + assertEquals( + "title ASC, _id ASC", + TrackQuery.buildTrackSortOrder(TrackSortPolicy.Options, emptyList()), + ) + } + + @Test + fun `multiple sorts preserve declared priority before id`() { + assertEquals( + "artist ASC, title DESC, _id ASC", + TrackQuery.buildTrackSortOrder( + TrackSortPolicy.Options, + listOf(SortOption("artist", true), SortOption("title", false)), + ), + ) + } +} diff --git a/android/src/test/java/com/musiclibrary/utils/PaginationTest.kt b/android/src/test/java/com/musiclibrary/utils/PaginationTest.kt new file mode 100644 index 0000000..cbdfdfa --- /dev/null +++ b/android/src/test/java/com/musiclibrary/utils/PaginationTest.kt @@ -0,0 +1,84 @@ +package com.musiclibrary.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PaginationTest { + @Test + fun `missing cursor starts at the first item`() { + val ids = listOf("1", "2") + var index = -1 + + val canRead = moveToPageStart( + after = null, + moveToFirst = { index = 0; ids.isNotEmpty() }, + currentId = { ids[index] }, + moveToNext = { index++; index < ids.size }, + ) + + assertTrue(canRead) + assertTrue(index == 0) + } + + @Test(expected = InvalidCursorException::class) + fun `malformed cursor is rejected`() { + moveToPageStart( + after = "not-an-id", + moveToFirst = { false }, + currentId = { error("not called") }, + moveToNext = { false }, + ) + } + + @Test(expected = InvalidCursorException::class) + fun `cursor above unsigned 64 bit range is rejected`() { + moveToPageStart( + after = "18446744073709551616", + moveToFirst = { false }, + currentId = { error("not called") }, + moveToNext = { false }, + ) + } + + @Test(expected = CursorNotFoundException::class) + fun `unknown cursor is rejected`() { + locate(listOf("1", "2"), after = "3") + } + + @Test + fun `final cursor produces a normal empty page`() { + assertFalse(locate(listOf("1", "2"), after = "2")) + } + + @Test + fun `empty result without a cursor is normal`() { + assertFalse(locate(emptyList(), after = null)) + } + + @Test(expected = InvalidPageSizeException::class) + fun `zero page size is rejected`() { + validatePageSize(0) + } + + @Test(expected = InvalidPageSizeException::class) + fun `page size above the cap is rejected`() { + validatePageSize(1001) + } + + @Test + fun `page size boundaries are accepted`() { + validatePageSize(1) + validatePageSize(1000) + } + + private fun locate(ids: List, after: String?): Boolean { + var index = -1 + return moveToPageStart( + after = after, + moveToFirst = { index = 0; ids.isNotEmpty() }, + currentId = { ids[index] }, + moveToNext = { index++; index < ids.size }, + ) + } +} diff --git a/docs/docs/api.md b/docs/docs/api.md index 1e198d3..559d021 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -199,8 +199,8 @@ const albums = await getAlbumsByArtistAsync('artist-id-123'); ```typescript interface TrackOptions { - after?: string; // Cursor for pagination - first?: number; // Max items to return (default: 20) + after?: string; // Previous page's entity-ID cursor + first?: number; // 1–1000 items (default: 20) sortBy?: SortByValue | SortByValue[]; directory?: string; // Directory path to search } @@ -210,8 +210,8 @@ interface TrackOptions { ```typescript interface AlbumOptions { - after?: string; // Cursor for pagination - first?: number; // Max items to return (default: 20) + after?: string; // Previous page's entity-ID cursor + first?: number; // 1–1000 items (default: 20) sortBy?: SortByValue | SortByValue[]; } ``` @@ -220,12 +220,18 @@ interface AlbumOptions { ```typescript interface ArtistOptions { - after?: string; // Cursor for pagination - first?: number; // Max items to return (default: 20) + after?: string; // Previous page's entity-ID cursor + first?: number; // 1–1000 items (default: 20) sortBy?: SortByValue | SortByValue[]; } ``` +The cursor is the ID of the last entity returned by the previous page and must +be reused with the same entity, filters, and normalized sort. A malformed +cursor rejects with `INVALID_CURSOR`. A valid ID absent from the current query +rejects with `CURSOR_NOT_FOUND`. A cursor at the final entity returns an empty +terminal page with no `endCursor`. + ### `Track` ```typescript @@ -237,8 +243,8 @@ interface Track { album: string; // Album name duration: number; // Duration in seconds url: string; // File URL or path - createdAt: number; // Date added (Unix timestamp) - modifiedAt: number; // Date modified (Unix timestamp) + createdAt?: number | null; // Date added (Unix seconds) + modifiedAt?: number | null; // Resource modification time; null on iOS fileSize: number; // File size in bytes } ``` @@ -298,6 +304,11 @@ interface TrackMetadata { ## Sorting Options +The default sort is title ascending. A bare key is descending. Multiple sort +descriptors are evaluated in declaration order, then the entity ID is used as +an ascending tie-breaker. Missing string or numeric values sort before populated +values in ascending order where both platforms expose the field. + ### Track Sorting Keys - `'default'` - Default sorting (title) @@ -306,7 +317,7 @@ interface TrackMetadata { - `'album'` - Sort by album name - `'duration'` - Sort by duration - `'createdAt'` - Sort by creation date -- `'modifiedAt'` - Sort by modification date +- `'modifiedAt'` - Sort by resource modification date (Android; unavailable values on iOS tie and fall back to ID) - `'fileSize'` - Sort by file size ### Album Sorting Keys diff --git a/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md b/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md index 95375c1..fddd244 100644 --- a/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md +++ b/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md @@ -199,8 +199,8 @@ const albums = await getAlbumsByArtistAsync('artist-id-123'); ```typescript interface TrackOptions { - after?: string; // 分页游标 - first?: number; // 最大返回项目数(默认:20) + after?: string; // 上一页返回的实体 ID 游标 + first?: number; // 返回 1–1000 项(默认:20) sortBy?: SortByValue | SortByValue[]; directory?: string; // 搜索目录路径 } @@ -210,8 +210,8 @@ interface TrackOptions { ```typescript interface AlbumOptions { - after?: string; // 分页游标 - first?: number; // 最大返回项目数(默认:20) + after?: string; // 上一页返回的实体 ID 游标 + first?: number; // 返回 1–1000 项(默认:20) sortBy?: SortByValue | SortByValue[]; } ``` @@ -220,12 +220,17 @@ interface AlbumOptions { ```typescript interface ArtistOptions { - after?: string; // 分页游标 - first?: number; // 最大返回项目数(默认:20) + after?: string; // 上一页返回的实体 ID 游标 + first?: number; // 返回 1–1000 项(默认:20) sortBy?: SortByValue | SortByValue[]; } ``` +游标是上一页最后一个实体的 ID,必须与生成它时的实体类型、筛选条件和 +规范化排序一起复用。格式错误的游标会以 `INVALID_CURSOR` 拒绝;格式正确但 +不在当前查询结果中的 ID 会以 `CURSOR_NOT_FOUND` 拒绝。游标指向最后一个实体 +时,返回不含 `endCursor` 的正常空白终止页。 + ### `Track` ```typescript @@ -237,8 +242,8 @@ interface Track { album: string; // 专辑名称 duration: number; // 时长(秒) url: string; // 文件 URL 或路径 - createdAt: number; // 添加日期(Unix 时间戳) - modifiedAt: number; // 修改日期(Unix 时间戳) + createdAt?: number | null; // 添加日期(Unix 秒) + modifiedAt?: number | null; // 资源修改时间;iOS 上为 null fileSize: number; // 文件大小(字节) } ``` @@ -298,6 +303,10 @@ interface TrackMetadata { ## 排序选项 +默认按标题升序。单独传入排序键时表示降序。多个排序条件按声明顺序进行 +词典序比较,最后以实体 ID 升序打破平局。两个平台都提供对应字段时,缺失的 +字符串或数值在升序中排在已有值之前。 + ### 曲目排序键 - `'default'` - 默认排序(标题) @@ -306,7 +315,7 @@ interface TrackMetadata { - `'album'` - 按专辑名称排序 - `'duration'` - 按时长排序 - `'createdAt'` - 按创建日期排序 -- `'modifiedAt'` - 按修改日期排序 +- `'modifiedAt'` - 按资源修改日期排序(Android;iOS 缺失值相等并回退到 ID) - `'fileSize'` - 按文件大小排序 ### 专辑排序键 diff --git a/ios/MusicLibraryImpl.swift b/ios/MusicLibraryImpl.swift index 00970a4..deb37ac 100644 --- a/ios/MusicLibraryImpl.swift +++ b/ios/MusicLibraryImpl.swift @@ -11,6 +11,9 @@ import MediaPlayer enum MusicLibraryError: LocalizedError { case permissionDenied case trackNotFound(String) + case invalidCursor(String) + case cursorNotFound(String) + case invalidPageSize(Int) var code: String { switch self { @@ -18,6 +21,12 @@ enum MusicLibraryError: LocalizedError { return "PERMISSION_DENIED" case .trackNotFound: return "TRACK_NOT_FOUND" + case .invalidCursor: + return "INVALID_CURSOR" + case .cursorNotFound: + return "CURSOR_NOT_FOUND" + case .invalidPageSize: + return "INVALID_PAGE_SIZE" } } @@ -27,6 +36,12 @@ enum MusicLibraryError: LocalizedError { return "Audio permission is required. Please grant media library permission first." case .trackNotFound(let trackId): return "Track with id \(trackId) not found" + case .invalidCursor(let cursor): + return "Cursor must be a positive decimal ID: \(cursor)" + case .cursorNotFound(let cursor): + return "Cursor is not present in the current query result: \(cursor)" + case .invalidPageSize(let first): + return "Page size must be between 1 and 1000: \(first)" } } } @@ -41,7 +56,7 @@ public class MusicLibraryImpl: NSObject { do { try ensureAuthorized() let getTracks = GetTracks(options: options) - let result = getTracks.execute() + let result = try getTracks.execute() let resultDict = result.toDictionary() NSLog("🎵 [MusicLibrary] getTracksAsync returning: %@", resultDict) @@ -91,7 +106,7 @@ public class MusicLibraryImpl: NSObject { do { try ensureAuthorized() let options = TrackOptions(after: after, first: first, sortBy: sortBy, directory: directory) - let result = GetTracksByArtistQuery.getTracksByArtist(artistId: artistId, options: options) + let result = try GetTracksByArtistQuery.getTracksByArtist(artistId: artistId, options: options) resolve(result.toDictionary()) } catch { rejectError(error, fallbackCode: "QUERY_ERROR", fallbackMessage: "Failed to query tracks by artist", reject: reject) @@ -102,7 +117,7 @@ public class MusicLibraryImpl: NSObject { do { try ensureAuthorized() let options = AlbumOptions(after: after, first: first, sortBy: sortBy) - let result = GetAlbumsQuery.getAlbums(options: options) + let result = try GetAlbumsQuery.getAlbums(options: options) resolve(result.toDictionary()) } catch { rejectError(error, fallbackCode: "QUERY_ERROR", fallbackMessage: "Failed to query albums", reject: reject) @@ -123,7 +138,7 @@ public class MusicLibraryImpl: NSObject { do { try ensureAuthorized() let options = ArtistOptions(after: after, first: first, sortBy: sortBy) - let result = GetArtistsQuery.getArtists(options: options) + let result = try GetArtistsQuery.getArtists(options: options) resolve(result.toDictionary()) } catch { rejectError(error, fallbackCode: "QUERY_ERROR", fallbackMessage: "Failed to query artists", reject: reject) diff --git a/ios/albums/GetAlbumsQuery.swift b/ios/albums/GetAlbumsQuery.swift index 5c6b341..1f821b9 100644 --- a/ios/albums/GetAlbumsQuery.swift +++ b/ios/albums/GetAlbumsQuery.swift @@ -3,39 +3,28 @@ import MediaPlayer internal class GetAlbumsQuery { - static func getAlbums(options: AlbumOptions) -> PaginatedResult { + static func getAlbums(options: AlbumOptions) throws -> PaginatedResult { guard MPMediaLibrary.authorizationStatus() == .authorized else { return PaginatedResult(items: [], hasNextPage: false, totalCount: 0) } let query = MPMediaQuery.albums() - guard let collections = query.collections else { - return PaginatedResult(items: [], hasNextPage: false, totalCount: 0) - } - - var albums = collections.compactMap { buildAlbum($0) } + var albums = (query.collections ?? []).compactMap { buildAlbum($0) } applySortBy(options.sortBy, to: &albums) - let totalCount = albums.count - var startIndex = 0 - if let after = options.after { - if let idx = albums.firstIndex(where: { $0.id == after }) { - startIndex = idx + 1 - } - } - - let endIndex = min(startIndex + options.first, albums.count) - let page = startIndex < albums.count ? Array(albums[startIndex..( - items: page, - hasNextPage: hasNextPage, - endCursor: endCursor, - totalCount: totalCount + items: page.items, + hasNextPage: page.hasNextPage, + endCursor: page.endCursor, + totalCount: albums.count ) } @@ -65,23 +54,38 @@ internal class GetAlbumsQuery { } private static func applySortBy(_ sortBy: [SortOption], to albums: inout [Album]) { - for sortOption in sortBy { - let ascending = sortOption.ascending - switch sortOption.key.lowercased() { - case "default", "title": - albums.sort { ascending ? $0.title < $1.title : $0.title > $1.title } - case "artist": - albums.sort { ascending ? $0.artist < $1.artist : $0.artist > $1.artist } - case "trackcount": - albums.sort { ascending ? $0.trackCount < $1.trackCount : $0.trackCount > $1.trackCount } - case "year": - albums.sort { - let y0 = $0.year ?? 0 - let y1 = $1.year ?? 0 - return ascending ? y0 < y1 : y0 > y1 + let options = sortBy.isEmpty ? [SortOption(key: "default", ascending: true)] : sortBy + albums.sort { lhs, rhs in + for option in options { + if let result = compare(lhs, rhs, by: option) { + return result } - default: break } + + return (UInt64(lhs.id) ?? 0) < (UInt64(rhs.id) ?? 0) + } + } + + private static func compare(_ lhs: Album, _ rhs: Album, by option: SortOption) -> Bool? { + switch option.key.lowercased() { + case "default", "title": + return compare(lhs.title, rhs.title, ascending: option.ascending) + case "artist": + return compare(lhs.artist, rhs.artist, ascending: option.ascending) + case "trackcount": + return compare(lhs.trackCount, rhs.trackCount, ascending: option.ascending) + case "year": + return compare(lhs.year ?? 0, rhs.year ?? 0, ascending: option.ascending) + default: + return nil } } + + private static func compare(_ lhs: T, _ rhs: T, ascending: Bool) -> Bool? { + if lhs == rhs { + return nil + } + + return ascending ? lhs < rhs : lhs > rhs + } } diff --git a/ios/artists/GetArtistsQuery.swift b/ios/artists/GetArtistsQuery.swift index dc15487..01396a7 100644 --- a/ios/artists/GetArtistsQuery.swift +++ b/ios/artists/GetArtistsQuery.swift @@ -3,39 +3,28 @@ import MediaPlayer internal class GetArtistsQuery { - static func getArtists(options: ArtistOptions) -> PaginatedResult { + static func getArtists(options: ArtistOptions) throws -> PaginatedResult { guard MPMediaLibrary.authorizationStatus() == .authorized else { return PaginatedResult(items: [], hasNextPage: false, totalCount: 0) } let artistQuery = MPMediaQuery.artists() - guard let artistCollections = artistQuery.collections else { - return PaginatedResult(items: [], hasNextPage: false, totalCount: 0) - } - - var artists = artistCollections.compactMap { buildArtist($0) } + var artists = (artistQuery.collections ?? []).compactMap { buildArtist($0) } applySortBy(options.sortBy, to: &artists) - let totalCount = artists.count - var startIndex = 0 - if let after = options.after { - if let idx = artists.firstIndex(where: { $0.id == after }) { - startIndex = idx + 1 - } - } - - let endIndex = min(startIndex + options.first, artists.count) - let page = startIndex < artists.count ? Array(artists[startIndex..( - items: page, - hasNextPage: hasNextPage, - endCursor: endCursor, - totalCount: totalCount + items: page.items, + hasNextPage: page.hasNextPage, + endCursor: page.endCursor, + totalCount: artists.count ) } @@ -67,17 +56,36 @@ internal class GetArtistsQuery { } private static func applySortBy(_ sortBy: [SortOption], to artists: inout [Artist]) { - for sortOption in sortBy { - let ascending = sortOption.ascending - switch sortOption.key.lowercased() { - case "default", "title": - artists.sort { ascending ? $0.title < $1.title : $0.title > $1.title } - case "trackcount": - artists.sort { ascending ? $0.trackCount < $1.trackCount : $0.trackCount > $1.trackCount } - case "albumcount": - artists.sort { ascending ? $0.albumCount < $1.albumCount : $0.albumCount > $1.albumCount } - default: break + let options = sortBy.isEmpty ? [SortOption(key: "default", ascending: true)] : sortBy + artists.sort { lhs, rhs in + for option in options { + if let result = compare(lhs, rhs, by: option) { + return result + } } + + return (UInt64(lhs.id) ?? 0) < (UInt64(rhs.id) ?? 0) + } + } + + private static func compare(_ lhs: Artist, _ rhs: Artist, by option: SortOption) -> Bool? { + switch option.key.lowercased() { + case "default", "title": + return compare(lhs.title, rhs.title, ascending: option.ascending) + case "trackcount": + return compare(lhs.trackCount, rhs.trackCount, ascending: option.ascending) + case "albumcount": + return compare(lhs.albumCount, rhs.albumCount, ascending: option.ascending) + default: + return nil } } + + private static func compare(_ lhs: T, _ rhs: T, ascending: Bool) -> Bool? { + if lhs == rhs { + return nil + } + + return ascending ? lhs < rhs : lhs > rhs + } } diff --git a/ios/models/PaginatedResult.swift b/ios/models/PaginatedResult.swift index a67e9b4..853d024 100644 --- a/ios/models/PaginatedResult.swift +++ b/ios/models/PaginatedResult.swift @@ -8,6 +8,47 @@ import Foundation // MARK: - Result Models +internal struct CursorPage { + let items: [T] + let hasNextPage: Bool + let endCursor: String? +} + +internal func paginateById( + _ items: [T], + first: Int, + after: String?, + id: (T) -> String +) throws -> CursorPage { + guard (1...1000).contains(first) else { + throw MusicLibraryError.invalidPageSize(first) + } + + var startIndex = 0 + if let after { + let validRange = after.range( + of: "^[1-9][0-9]*$", + options: .regularExpression + ) + guard validRange != nil, UInt64(after) != nil else { + throw MusicLibraryError.invalidCursor(after) + } + guard let foundIndex = items.firstIndex(where: { id($0) == after }) else { + throw MusicLibraryError.cursorNotFound(after) + } + startIndex = foundIndex + 1 + } + + let endIndex = min(startIndex + first, items.count) + let page = startIndex < items.count ? Array(items[startIndex..: NSObject { public let items: [T] public let hasNextPage: Bool @@ -83,4 +124,3 @@ public class PaginatedResultArtist: NSObject { return inner.toDictionary() } } - diff --git a/ios/tracks/GetTracks.swift b/ios/tracks/GetTracks.swift index 2825f87..8e6ea25 100644 --- a/ios/tracks/GetTracks.swift +++ b/ios/tracks/GetTracks.swift @@ -7,11 +7,11 @@ internal class GetTracks { self.options = options } - func execute() -> PaginatedResult { + func execute() throws -> PaginatedResult { if let directory = options.directory, !directory.isEmpty { NSLog("🎵 [MusicLibrary] Directory filtering ('%@') is not supported on iOS, ignoring parameter", directory) } - return TrackQuery.getPaginatedTracks(options: options) + return try TrackQuery.getPaginatedTracks(options: options) } } diff --git a/ios/tracks/GetTracksByArtistQuery.swift b/ios/tracks/GetTracksByArtistQuery.swift index 6eb5dc8..03ddf45 100644 --- a/ios/tracks/GetTracksByArtistQuery.swift +++ b/ios/tracks/GetTracksByArtistQuery.swift @@ -1,7 +1,7 @@ import Foundation internal class GetTracksByArtistQuery { - static func getTracksByArtist(artistId: String, options: TrackOptions) -> PaginatedResult { - return TrackQuery.getPaginatedTracks(filter: .artist(artistId), options: options) + static func getTracksByArtist(artistId: String, options: TrackOptions) throws -> PaginatedResult { + return try TrackQuery.getPaginatedTracks(filter: .artist(artistId), options: options) } } diff --git a/ios/tracks/TrackQuery.swift b/ios/tracks/TrackQuery.swift index fe78443..bf1babe 100644 --- a/ios/tracks/TrackQuery.swift +++ b/ios/tracks/TrackQuery.swift @@ -18,14 +18,17 @@ internal enum TrackSortPolicy { } internal class TrackQuery { - static func getPaginatedTracks(filter: TrackQueryFilter = .all, options: TrackOptions) -> PaginatedResult { - guard var items = queryItems(filter: filter) else { - return PaginatedResult(items: [], hasNextPage: false, totalCount: 0) - } + static func getPaginatedTracks(filter: TrackQueryFilter = .all, options: TrackOptions) throws -> PaginatedResult { + var items = queryItems(filter: filter) ?? [] applySort(.options(options.sortBy), to: &items) - let page = paginate(items, first: options.first, after: options.after) + let page = try paginateById( + items, + first: options.first, + after: options.after, + id: { "\($0.persistentID)" } + ) let tracks = page.items.compactMap { buildTrack(from: $0, resourcePolicy: .allowLibraryFallback) } @@ -94,7 +97,13 @@ internal class TrackQuery { return lhs.albumTrackNumber < rhs.albumTrackNumber } - return (lhs.title ?? "") < (rhs.title ?? "") + let lhsTitle = lhs.title ?? "" + let rhsTitle = rhs.title ?? "" + if lhsTitle != rhsTitle { + return lhsTitle < rhsTitle + } + + return lhs.persistentID < rhs.persistentID } case .options(let sortBy): let options = sortBy.isEmpty ? [SortOption(key: "default", ascending: true)] : sortBy @@ -126,8 +135,10 @@ internal class TrackQuery { return compare(lhs.albumTitle ?? "", rhs.albumTitle ?? "", ascending: ascending) case "duration": return compare(lhs.playbackDuration, rhs.playbackDuration, ascending: ascending) - case "createdat", "creationtime", "modifiedat", "modificationtime": + case "createdat", "creationtime": return compare(lhs.dateAdded, rhs.dateAdded, ascending: ascending) + case "modifiedat", "modificationtime": + return nil case "filesize": return compare(fileSize(for: lhs), fileSize(for: rhs), ascending: ascending) default: @@ -143,25 +154,6 @@ internal class TrackQuery { return ascending ? lhs < rhs : lhs > rhs } - private static func paginate(_ items: [MPMediaItem], first: Int, after: String?) -> (items: [MPMediaItem], hasNextPage: Bool, endCursor: String?) { - var startIndex = 0 - - if let after = after, let afterId = UInt64(after), - let foundIndex = items.firstIndex(where: { $0.persistentID == afterId }) { - startIndex = foundIndex + 1 - } - - let endIndex = min(startIndex + first, items.count) - let page = startIndex < items.count ? Array(items[startIndex.. Track? { guard let urlString = trackURL(for: item, policy: resourcePolicy) else { return nil @@ -176,7 +168,7 @@ internal class TrackQuery { duration: item.playbackDuration, url: urlString, createdAt: item.dateAdded.timeIntervalSince1970, - modifiedAt: item.dateAdded.timeIntervalSince1970, + modifiedAt: nil, fileSize: fileSize(for: item) ) } diff --git a/src/NativeMusicLibrary.ts b/src/NativeMusicLibrary.ts index dd029b9..5729f4c 100644 --- a/src/NativeMusicLibrary.ts +++ b/src/NativeMusicLibrary.ts @@ -69,6 +69,8 @@ export interface BaseOptions { /** * Maximum number of items to return * @default 20 + * @minimum 1 + * @maximum 1000 */ first?: number; } @@ -80,7 +82,7 @@ export interface TrackOptions extends BaseOptions { /** * Sorting configuration for tracks * @example - * 'title' // Sort by title descending (default) + * 'title' // Sort by title descending * ['title', true] // Sort by title ascending */ sortBy?: SortByValue | SortByValue[]; @@ -99,7 +101,7 @@ export interface AlbumOptions extends BaseOptions { /** * Sorting configuration for albums * @example - * 'title' // Sort by title descending (default) + * 'title' // Sort by title descending * ['trackCount', true] // Sort by track count ascending */ sortBy?: SortByValue | SortByValue[]; @@ -112,7 +114,7 @@ export interface ArtistOptions extends BaseOptions { /** * Sorting configuration for artists * @example - * 'title' // Sort by name descending (default) + * 'title' // Sort by name descending * ['trackCount', true] // Sort by track count ascending */ sortBy?: SortByValue | SortByValue[]; @@ -187,10 +189,10 @@ export interface Track { /** File URI or path */ url: string; - /** Date added to library (Unix timestamp, optional) */ + /** Date added to the library as a Unix timestamp in seconds (optional) */ createdAt?: number | null; - /** Date modified (Unix timestamp, optional) */ + /** Resource modification time in Unix seconds; unavailable and null on iOS */ modifiedAt?: number | null; /** File size in bytes */ diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 487a1d6..d9542a2 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,7 +1,12 @@ import type { TrackSortByKey } from '../NativeMusicLibrary'; import MusicLibrary from '../NativeMusicLibrary'; -import { getAllTracksAsync } from '../index'; -import { getTrackOptions, normalizeSortBy } from '../utils'; +import { getAlbumsAsync, getAllTracksAsync } from '../index'; +import { + getAlbumOptions, + getArtistOptions, + getTrackOptions, + normalizeSortBy, +} from '../utils'; // Mock the native module jest.mock('../NativeMusicLibrary', () => ({ @@ -106,6 +111,43 @@ describe('getTrackOptions', () => { { key: 'duration', ascending: false }, ]); }); + + it.each(['', '0', '-1', '1.5', 'abc', '1abc', '18446744073709551616'])( + 'rejects malformed cursor %p', + (after) => { + expect(() => getTrackOptions({ after })).toThrow( + expect.objectContaining({ code: 'INVALID_CURSOR' }) + ); + } + ); + + it('accepts a positive decimal entity ID cursor', () => { + expect(getTrackOptions({ after: '18446744073709551615' }).after).toBe( + '18446744073709551615' + ); + }); + + it.each([0, -1, 1.5, 1001, Number.POSITIVE_INFINITY])( + 'rejects invalid page size %p', + (first) => { + expect(() => getTrackOptions({ first })).toThrow( + expect.objectContaining({ code: 'INVALID_PAGE_SIZE' }) + ); + } + ); + + it.each([1, 1000])('accepts page-size boundary %p', (first) => { + expect(getTrackOptions({ first }).first).toBe(first); + }); + + it('applies the same cursor and page-size validation to every entity', () => { + expect(() => getAlbumOptions({ after: 'bad' })).toThrow( + expect.objectContaining({ code: 'INVALID_CURSOR' }) + ); + expect(() => getArtistOptions({ first: 1001 })).toThrow( + expect.objectContaining({ code: 'INVALID_PAGE_SIZE' }) + ); + }); }); describe('getAllTracksAsync', () => { @@ -162,6 +204,19 @@ describe('getAllTracksAsync', () => { }); }); +describe('public pagination validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects malformed cursors with a stable error code before native query', async () => { + await expect(getAlbumsAsync({ after: 'bad' })).rejects.toMatchObject({ + code: 'INVALID_CURSOR', + }); + expect(mockMusicLibrary.getAlbumsAsync).not.toHaveBeenCalled(); + }); +}); + function createTrack(id: string) { return { id, diff --git a/src/index.tsx b/src/index.tsx index d2d1aa2..ee77ea3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -62,7 +62,7 @@ export function getTracksByAlbumAsync(albumId: string): Promise { * @param trackOptions - The options for the query (pagination, etc.). * @returns A promise that resolves to a paginated result of tracks by the artist. */ -export function getTracksByArtistAsync( +export async function getTracksByArtistAsync( artistId: string, trackOptions: TrackOptions = {} ): Promise { @@ -93,7 +93,7 @@ export async function getAllTracksByArtistAsync( * @param albumOptions - The options for the query. * @returns A promise that resolves to an array of album info. */ -export function getAlbumsAsync( +export async function getAlbumsAsync( albumOptions: AlbumOptions = {} ): Promise { return MusicLibrary.getAlbumsAsync(getAlbumOptions(albumOptions)); @@ -124,7 +124,7 @@ export function getAlbumsByArtistAsync(artistId: string): Promise { * @param artistOptions - The options for the query. * @returns A promise that resolves to an array of artist info. */ -export function getArtistsAsync( +export async function getArtistsAsync( artistOptions: ArtistOptions = {} ): Promise { return MusicLibrary.getArtistsAsync(getArtistOptions(artistOptions)); diff --git a/src/utils.ts b/src/utils.ts index b37784e..27fad91 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import { Platform } from 'react-native'; import { TrackSortByObject, AlbumSortByObject, @@ -14,6 +13,7 @@ import { } from './NativeMusicLibrary'; type SortByType = 'track' | 'album' | 'artist'; +const MAX_CURSOR_ID = '18446744073709551615'; export function arrayize(item: T | T[]): T[] { if (Array.isArray(item)) { @@ -166,14 +166,33 @@ function validateOptions(options: any): void { if (options.directory != null && typeof options.directory !== 'string') { throw new Error('Option "directory" must be a string!'); } + if (options.after != null && !isValidCursor(options.after)) { + throw createOptionError( + 'INVALID_CURSOR', + 'Option "after" must be a positive decimal ID!' + ); + } if ( - options.after != null && - Platform.OS === 'android' && - isNaN(parseInt(getId(options.after) as string, 10)) + options.first != null && + (!Number.isInteger(options.first) || + options.first < 1 || + options.first > 1000) ) { - throw new Error('Option "after" must be a valid ID!'); - } - if (options.first != null && options.first < 0) { - throw new Error('Option "first" must be a positive integer!'); + throw createOptionError( + 'INVALID_PAGE_SIZE', + 'Option "first" must be an integer between 1 and 1000!' + ); } } + +function isValidCursor(cursor: string): boolean { + return ( + /^[1-9]\d*$/.test(cursor) && + (cursor.length < MAX_CURSOR_ID.length || + (cursor.length === MAX_CURSOR_ID.length && cursor <= MAX_CURSOR_ID)) + ); +} + +function createOptionError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }); +} From 8100bc8eb27e4ed443ee69c9308e084a36067269 Mon Sep 17 00:00:00 2001 From: ziven Date: Thu, 20 Aug 2026 23:44:44 +0800 Subject: [PATCH 3/7] feat(android): add content URI-first track access --- README.md | 52 +++++++---- .../java/com/musiclibrary/models/Assets.kt | 1 + .../tracks/AudioMetadataExtractor.kt | 46 ++++++++-- .../tracks/GetTrackMetadataQuery.kt | 2 +- .../tracks/TrackDirectoryFilter.kt | 90 +++++++++++++++++++ .../tracks/TrackMetadataLookup.kt | 31 +++++-- .../com/musiclibrary/tracks/TrackQuery.kt | 66 ++++++-------- .../com/musiclibrary/utils/DataConverter.kt | 1 + .../java/com/musiclibrary/utils/Pagination.kt | 15 +--- .../com/musiclibrary/utils/QueryErrors.kt | 16 ++++ .../tracks/AudioMetadataExtractorTest.kt | 30 +++++++ .../tracks/TrackDirectoryFilterTest.kt | 36 ++++++++ .../musiclibrary/tracks/TrackSortOrderTest.kt | 19 ++++ docs/docs/api.md | 18 +++- docs/docs/getting-started.md | 2 +- .../current/api.md | 17 +++- .../current/getting-started.md | 32 +++---- example/src/contexts/PlayerContext.tsx | 1 + src/NativeMusicLibrary.ts | 5 +- 19 files changed, 370 insertions(+), 110 deletions(-) create mode 100644 android/src/main/java/com/musiclibrary/tracks/TrackDirectoryFilter.kt create mode 100644 android/src/main/java/com/musiclibrary/utils/QueryErrors.kt create mode 100644 android/src/test/java/com/musiclibrary/tracks/AudioMetadataExtractorTest.kt create mode 100644 android/src/test/java/com/musiclibrary/tracks/TrackDirectoryFilterTest.kt diff --git a/README.md b/README.md index 0cc95ba..a621af5 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,12 @@ getTracksAsync(options?: TrackOptions): Promise> **TrackOptions** -| Property | Type | Default | Description | -| ----------- | -------------------------------------------------------- | ----------- | --------------------------------------- | -| `first` | `number` | `20` | Items to return (`1`–`1000`) | -| `after` | `string` | — | Cursor from previous page's `endCursor` | -| `sortBy` | `TrackSortByKey \| [TrackSortByKey, boolean] \| (...)[]` | `'default'` | Sort key, or tuple `[key, ascending]` | -| `directory` | `string` | — | Filter by directory path (Android only) | +| Property | Type | Default | Description | +| ----------- | -------------------------------------------------------- | ----------- | ---------------------------------------------------- | +| `first` | `number` | `20` | Items to return (`1`–`1000`) | +| `after` | `string` | — | Cursor from previous page's `endCursor` | +| `sortBy` | `TrackSortByKey \| [TrackSortByKey, boolean] \| (...)[]` | `'default'` | Sort key, or tuple `[key, ascending]` | +| `directory` | `string` | — | Legacy path or supported SAF tree URI (Android only) | **TrackSortByKey**: `'default' \| 'title' \| 'artist' \| 'album' \| 'duration' \| 'createdAt' \| 'modifiedAt' \| 'fileSize'` @@ -191,18 +191,34 @@ getArtistsAsync(options?: ArtistOptions): Promise> ### `Track` -| Field | Type | Description | -| ------------ | --------- | --------------------------------------------------------- | -| `id` | `string` | Unique identifier | -| `title` | `string` | Track title | -| `artist` | `string` | Artist name | -| `artwork` | `string?` | Artwork file URI (may be undefined) | -| `album` | `string` | Album name | -| `duration` | `number` | Duration in seconds | -| `url` | `string` | File URI | -| `createdAt` | `number?` | Date added, Unix timestamp in seconds | -| `modifiedAt` | `number?` | Resource modification time in Unix seconds; `null` on iOS | -| `fileSize` | `number` | File size in bytes | +| Field | Type | Description | +| ------------ | --------- | ----------------------------------------------------------- | +| `id` | `string` | Unique identifier | +| `title` | `string` | Track title | +| `artist` | `string` | Artist name | +| `artwork` | `string?` | Artwork file URI (may be undefined) | +| `album` | `string` | Album name | +| `duration` | `number` | Duration in seconds | +| `url` | `string` | Playable URI; preserves `file://` when available on Android | +| `contentUri` | `string?` | Canonical Android MediaStore URI | +| `createdAt` | `number?` | Date added, Unix timestamp in seconds | +| `modifiedAt` | `number?` | Resource modification time in Unix seconds; `null` on iOS | +| `fileSize` | `number` | File size in bytes | + +### Android Track resources + +Use `contentUri` as the canonical Android Track resource. `url` remains +backward compatible: it is a `file://` URI when MediaStore exposes a legacy +path, and otherwise falls back to the same playable `content://` URI. Track +Metadata is opened through `ContentResolver`, so a Track no longer requires a +`DATA` path. + +`directory` still accepts an absolute path for existing callers. On Android 10+ +it also accepts a Storage Access Framework tree URI from the system external +storage provider, including mounted removable volumes; filtering uses the +MediaStore volume and relative path. Other document providers and SAF tree URIs +on older Android versions reject with `UNSUPPORTED_DIRECTORY_URI` instead of +guessing a `/storage/...` path. ### `TrackMetadata` diff --git a/android/src/main/java/com/musiclibrary/models/Assets.kt b/android/src/main/java/com/musiclibrary/models/Assets.kt index cdff04a..d879572 100644 --- a/android/src/main/java/com/musiclibrary/models/Assets.kt +++ b/android/src/main/java/com/musiclibrary/models/Assets.kt @@ -8,6 +8,7 @@ data class Track( val album: String? = null, val duration: Double, val url: String, + val contentUri: String? = null, val createdAt: Long? = null, val modifiedAt: Long? = null, val fileSize: Long, diff --git a/android/src/main/java/com/musiclibrary/tracks/AudioMetadataExtractor.kt b/android/src/main/java/com/musiclibrary/tracks/AudioMetadataExtractor.kt index 460e0fb..8452f7a 100644 --- a/android/src/main/java/com/musiclibrary/tracks/AudioMetadataExtractor.kt +++ b/android/src/main/java/com/musiclibrary/tracks/AudioMetadataExtractor.kt @@ -1,19 +1,28 @@ package com.musiclibrary.tracks +import android.content.Context import com.musiclibrary.models.TrackMetadata import java.io.File import org.jaudiotagger.audio.AudioFileIO import org.jaudiotagger.tag.FieldKey internal object AudioMetadataExtractor { - fun extract(source: TrackMetadataSource): TrackMetadata { - val filePath = source.filePath - if (filePath.isNullOrEmpty()) { - return TrackMetadata(id = source.trackId) - } - + fun extract(context: Context, source: TrackMetadataSource): TrackMetadata { + var temporaryFile: File? = null return try { - val audioFile = AudioFileIO.read(File(filePath)) + val file = File.createTempFile( + "music-library-metadata-", + fileSuffix(source.displayName, source.mimeType), + context.cacheDir, + ) + temporaryFile = file + val input = context.contentResolver.openInputStream(source.contentUri) + ?: return TrackMetadata(id = source.trackId) + input.use { stream -> + file.outputStream().use { output -> stream.copyTo(output) } + } + + val audioFile = AudioFileIO.read(file) val header = audioFile.audioHeader val tag = audioFile.tag @@ -39,6 +48,29 @@ internal object AudioMetadataExtractor { ) } catch (_: Exception) { TrackMetadata(id = source.trackId) + } finally { + temporaryFile?.delete() + } + } + + internal fun fileSuffix(displayName: String?, mimeType: String?): String { + val extension = displayName + ?.substringAfterLast('.', missingDelimiterValue = "") + ?.lowercase() + ?.takeIf { it.matches(Regex("[a-z0-9]{1,10}")) } + + if (extension != null) { + return ".$extension" + } + + return when (mimeType?.lowercase()) { + "audio/aac", "audio/aacp" -> ".aac" + "audio/flac" -> ".flac" + "audio/mp4", "audio/m4a", "audio/x-m4a" -> ".m4a" + "audio/mpeg" -> ".mp3" + "audio/ogg" -> ".ogg" + "audio/wav", "audio/x-wav" -> ".wav" + else -> ".audio" } } } diff --git a/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt b/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt index 9169075..72adc9c 100644 --- a/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt +++ b/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt @@ -13,7 +13,7 @@ internal class GetTrackMetadataQuery( try { val source = TrackMetadataLookup.findTrack(context.contentResolver, trackId) ?: throw TrackNotFoundException(trackId) - val result = AudioMetadataExtractor.extract(source) + val result = AudioMetadataExtractor.extract(context, source) val writableMap = DataConverter.trackMetadataToWritableMap(result) promise.resolve(writableMap) } catch (e: TrackNotFoundException) { diff --git a/android/src/main/java/com/musiclibrary/tracks/TrackDirectoryFilter.kt b/android/src/main/java/com/musiclibrary/tracks/TrackDirectoryFilter.kt new file mode 100644 index 0000000..125e2d7 --- /dev/null +++ b/android/src/main/java/com/musiclibrary/tracks/TrackDirectoryFilter.kt @@ -0,0 +1,90 @@ +package com.musiclibrary.tracks + +import android.os.Build +import android.provider.DocumentsContract +import android.provider.MediaStore +import androidx.core.net.toUri +import com.musiclibrary.utils.CodedQueryException + +internal data class TrackDirectorySelection( + val clause: String? = null, + val arguments: List = emptyList(), +) + +internal data class ExternalStorageDirectory( + val volumeName: String, + val relativePathPrefix: String?, +) + +internal class UnsupportedDirectoryUriException(directory: String) : IllegalArgumentException( + "Directory URI is not a supported MediaStore-backed external storage tree: $directory", +), CodedQueryException { + override val code = "UNSUPPORTED_DIRECTORY_URI" +} + +internal object TrackDirectoryFilter { + fun resolve(directory: String?): TrackDirectorySelection { + if (directory.isNullOrEmpty()) { + return TrackDirectorySelection() + } + + if (!directory.startsWith("content://", ignoreCase = true)) { + return TrackDirectorySelection( + clause = "${MediaStore.Audio.Media.DATA} LIKE ?", + arguments = listOf("$directory%"), + ) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw UnsupportedDirectoryUriException(directory) + } + + val uri = directory.toUri() + if ( + uri.authority != EXTERNAL_STORAGE_AUTHORITY || + !DocumentsContract.isTreeUri(uri) + ) { + throw UnsupportedDirectoryUriException(directory) + } + + val documentId = try { + DocumentsContract.getTreeDocumentId(uri) + } catch (_: IllegalArgumentException) { + throw UnsupportedDirectoryUriException(directory) + } + val parsed = parseExternalStorageDocumentId(documentId) + ?: throw UnsupportedDirectoryUriException(directory) + + val relativePathPrefix = parsed.relativePathPrefix + return if (relativePathPrefix == null) { + TrackDirectorySelection( + clause = "${MediaStore.MediaColumns.VOLUME_NAME} = ?", + arguments = listOf(parsed.volumeName), + ) + } else { + TrackDirectorySelection( + clause = "${MediaStore.MediaColumns.VOLUME_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?", + arguments = listOf(parsed.volumeName, relativePathPrefix), + ) + } + } + + internal fun parseExternalStorageDocumentId(documentId: String): ExternalStorageDirectory? { + val parts = documentId.split(":", limit = 2) + if (parts.isEmpty() || parts[0].isBlank()) { + return null + } + + val volumeName = if (parts[0].equals("primary", ignoreCase = true)) { + MediaStore.VOLUME_EXTERNAL_PRIMARY + } else { + parts[0].lowercase() + } + val relativePath = parts.getOrNull(1)?.trim('/')?.takeIf { it.isNotEmpty() } + val prefix = relativePath?.let { "$it/%" } + + return ExternalStorageDirectory(volumeName, prefix) + } + + private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" +} diff --git a/android/src/main/java/com/musiclibrary/tracks/TrackMetadataLookup.kt b/android/src/main/java/com/musiclibrary/tracks/TrackMetadataLookup.kt index 481cbf1..5716dda 100644 --- a/android/src/main/java/com/musiclibrary/tracks/TrackMetadataLookup.kt +++ b/android/src/main/java/com/musiclibrary/tracks/TrackMetadataLookup.kt @@ -1,11 +1,15 @@ package com.musiclibrary.tracks import android.content.ContentResolver +import android.content.ContentUris +import android.net.Uri import android.provider.MediaStore internal data class TrackMetadataSource( val trackId: String, - val filePath: String?, + val contentUri: Uri, + val displayName: String?, + val mimeType: String?, ) internal object TrackMetadataLookup { @@ -13,24 +17,33 @@ internal object TrackMetadataLookup { contentResolver: ContentResolver, trackId: String, ): TrackMetadataSource? { - val projection = arrayOf(MediaStore.Audio.Media.DATA) - val selection = "${MediaStore.Audio.Media._ID} = ?" - val selectionArgs = arrayOf(trackId) + val id = trackId.toLongOrNull() ?: return null + val contentUri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id, + ) + val projection = arrayOf( + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.MIME_TYPE, + ) val cursor = contentResolver.query( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + contentUri, projection, - selection, - selectionArgs, + null, + null, null ) cursor?.use { c -> if (c.moveToFirst()) { - val dataColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + val displayNameColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val mimeTypeColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) return TrackMetadataSource( trackId = trackId, - filePath = c.getString(dataColumn) + contentUri = contentUri, + displayName = c.getString(displayNameColumn), + mimeType = c.getString(mimeTypeColumn), ) } } diff --git a/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt b/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt index 32e0f24..6a3ecde 100644 --- a/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt +++ b/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt @@ -1,11 +1,9 @@ package com.musiclibrary.tracks import android.content.ContentResolver +import android.content.ContentUris import android.database.Cursor -import android.net.Uri -import android.provider.DocumentsContract import android.provider.MediaStore -import androidx.core.net.toUri import com.musiclibrary.models.PaginatedResult import com.musiclibrary.models.SortOption import com.musiclibrary.models.Track @@ -103,11 +101,12 @@ internal object TrackQuery { directory: String?, sortOrder: String, ): Cursor { + val directorySelection = TrackDirectoryFilter.resolve(directory) return contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, TRACK_PROJECTION, - buildSelection(filter, directory), - buildSelectionArgs(filter, directory), + buildSelection(filter, directorySelection), + buildSelectionArgs(filter, directorySelection), sortOrder ) ?: throw RuntimeException("Failed to query MediaStore: cursor is null") } @@ -115,11 +114,11 @@ internal object TrackQuery { private fun readTrack(cursor: Cursor, columns: TrackColumns): Track? { return try { val id = cursor.getLong(columns.id) - val data = cursor.getString(columns.data) ?: return null - - if (data.isEmpty()) { - return null - } + val data = cursor.getString(columns.data)?.takeIf { it.isNotEmpty() } + val contentUri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id, + ).toString() val title = cursor.getString(columns.title) ?: "" val artist = cursor.getString(columns.artist) @@ -136,7 +135,8 @@ internal object TrackQuery { artwork = artworkUri(id), album = album, duration = duration, - url = "file://$data", + url = selectTrackUrl(contentUri, data), + contentUri = contentUri, createdAt = dateAdded, modifiedAt = dateModified, fileSize = fileSize @@ -146,7 +146,10 @@ internal object TrackQuery { } } - private fun buildSelection(filter: TrackQueryFilter, directory: String?): String { + private fun buildSelection( + filter: TrackQueryFilter, + directory: TrackDirectorySelection, + ): String { val conditions = mutableListOf() when (filter) { @@ -158,14 +161,15 @@ internal object TrackQuery { conditions.add("${MediaStore.Audio.Media.IS_MUSIC} = 1") conditions.add("${MediaStore.Audio.Media.DURATION} > 0") - if (!directory.isNullOrEmpty()) { - conditions.add("${MediaStore.Audio.Media.DATA} LIKE ?") - } + directory.clause?.let { conditions.add(it) } return conditions.joinToString(" AND ") } - private fun buildSelectionArgs(filter: TrackQueryFilter, directory: String?): Array? { + private fun buildSelectionArgs( + filter: TrackQueryFilter, + directory: TrackDirectorySelection, + ): Array? { val args = mutableListOf() when (filter) { @@ -174,17 +178,7 @@ internal object TrackQuery { TrackQueryFilter.All -> Unit } - if (!directory.isNullOrEmpty()) { - val dir = if (directory.startsWith("content://")) { - uriToFullPath(directory.toUri()) - } else { - directory - } - - if (!dir.isNullOrEmpty()) { - args.add("$dir%") - } - } + args.addAll(directory.arguments) return if (args.isEmpty()) null else args.toTypedArray() } @@ -215,24 +209,14 @@ internal object TrackQuery { return (descriptors + "${MediaStore.Audio.Media._ID} ASC").joinToString(", ") } - private fun uriToFullPath(treeUri: Uri): String? { - val docId = DocumentsContract.getTreeDocumentId(treeUri) - val parts = docId.split(":") - if (parts.size < 2) return null - - val type = parts[0] - val relativePath = parts[1] - - return when (type) { - "primary" -> "/storage/emulated/0/$relativePath" - else -> "/storage/$type/$relativePath" - } - } - private fun artworkUri(id: Long): String { return "content://media/external/audio/media/$id/albumart" } + internal fun selectTrackUrl(contentUri: String, filePath: String?): String { + return filePath?.takeIf { it.isNotEmpty() }?.let { "file://$it" } ?: contentUri + } + private data class TrackColumns( val id: Int, val title: Int, diff --git a/android/src/main/java/com/musiclibrary/utils/DataConverter.kt b/android/src/main/java/com/musiclibrary/utils/DataConverter.kt index 75db8fe..77ea27b 100644 --- a/android/src/main/java/com/musiclibrary/utils/DataConverter.kt +++ b/android/src/main/java/com/musiclibrary/utils/DataConverter.kt @@ -15,6 +15,7 @@ object DataConverter { track.album?.let { map.putString("album", it) } map.putDouble("duration", track.duration) map.putString("url", track.url) + track.contentUri?.let { map.putString("contentUri", it) } map.putLong("fileSize", track.fileSize) track.createdAt?.let { map.putDouble("createdAt", it.toDouble()) } diff --git a/android/src/main/java/com/musiclibrary/utils/Pagination.kt b/android/src/main/java/com/musiclibrary/utils/Pagination.kt index 880f5f6..cf736ad 100644 --- a/android/src/main/java/com/musiclibrary/utils/Pagination.kt +++ b/android/src/main/java/com/musiclibrary/utils/Pagination.kt @@ -1,11 +1,9 @@ package com.musiclibrary.utils -import com.facebook.react.bridge.Promise - internal sealed class PaginationException( - val code: String, + override val code: String, message: String, -) : IllegalArgumentException(message) +) : IllegalArgumentException(message), CodedQueryException internal class InvalidCursorException(cursor: String) : PaginationException( code = "INVALID_CURSOR", @@ -59,12 +57,3 @@ private fun isValidCursor(cursor: String): Boolean { (cursor.length < maxCursorId.length || (cursor.length == maxCursorId.length && cursor <= maxCursorId)) } - -internal fun Promise.rejectQueryError(error: Exception, fallbackMessage: String) { - if (error is PaginationException) { - reject(error.code, error.message, error) - return - } - - reject("QUERY_ERROR", "$fallbackMessage: ${error.message}", error) -} diff --git a/android/src/main/java/com/musiclibrary/utils/QueryErrors.kt b/android/src/main/java/com/musiclibrary/utils/QueryErrors.kt new file mode 100644 index 0000000..ccbd881 --- /dev/null +++ b/android/src/main/java/com/musiclibrary/utils/QueryErrors.kt @@ -0,0 +1,16 @@ +package com.musiclibrary.utils + +import com.facebook.react.bridge.Promise + +internal interface CodedQueryException { + val code: String +} + +internal fun Promise.rejectQueryError(error: Exception, fallbackMessage: String) { + if (error is CodedQueryException) { + reject(error.code, error.message, error) + return + } + + reject("QUERY_ERROR", "$fallbackMessage: ${error.message}", error) +} diff --git a/android/src/test/java/com/musiclibrary/tracks/AudioMetadataExtractorTest.kt b/android/src/test/java/com/musiclibrary/tracks/AudioMetadataExtractorTest.kt new file mode 100644 index 0000000..e9c3cd0 --- /dev/null +++ b/android/src/test/java/com/musiclibrary/tracks/AudioMetadataExtractorTest.kt @@ -0,0 +1,30 @@ +package com.musiclibrary.tracks + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioMetadataExtractorTest { + @Test + fun `temporary metadata resource preserves safe display-name extension`() { + assertEquals( + ".flac", + AudioMetadataExtractor.fileSuffix("Track.FLAC", "audio/mpeg"), + ) + } + + @Test + fun `mime type supplies extension when display name has none`() { + assertEquals( + ".mp3", + AudioMetadataExtractor.fileSuffix("Track", "audio/mpeg"), + ) + } + + @Test + fun `unknown resource type receives neutral suffix`() { + assertEquals( + ".audio", + AudioMetadataExtractor.fileSuffix(null, null), + ) + } +} diff --git a/android/src/test/java/com/musiclibrary/tracks/TrackDirectoryFilterTest.kt b/android/src/test/java/com/musiclibrary/tracks/TrackDirectoryFilterTest.kt new file mode 100644 index 0000000..aec0d54 --- /dev/null +++ b/android/src/test/java/com/musiclibrary/tracks/TrackDirectoryFilterTest.kt @@ -0,0 +1,36 @@ +package com.musiclibrary.tracks + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TrackDirectoryFilterTest { + @Test + fun `primary tree maps to primary MediaStore volume and relative path`() { + assertEquals( + ExternalStorageDirectory("external_primary", "Music/Live/%"), + TrackDirectoryFilter.parseExternalStorageDocumentId("primary:Music/Live"), + ) + } + + @Test + fun `removable tree preserves normalized volume name`() { + assertEquals( + ExternalStorageDirectory("1234-abcd", "Audio/%"), + TrackDirectoryFilter.parseExternalStorageDocumentId("1234-ABCD:Audio"), + ) + } + + @Test + fun `volume root covers every relative path`() { + assertEquals( + ExternalStorageDirectory("external_primary", null), + TrackDirectoryFilter.parseExternalStorageDocumentId("primary:"), + ) + } + + @Test + fun `missing volume is rejected`() { + assertNull(TrackDirectoryFilter.parseExternalStorageDocumentId(":")) + } +} diff --git a/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt b/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt index fafea1a..31d1fa6 100644 --- a/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt +++ b/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt @@ -5,6 +5,25 @@ import org.junit.Assert.assertEquals import org.junit.Test class TrackSortOrderTest { + @Test + fun `legacy file URL is preserved when MediaStore exposes a path`() { + assertEquals( + "file:///storage/emulated/0/Music/track.mp3", + TrackQuery.selectTrackUrl( + "content://media/external/audio/media/1", + "/storage/emulated/0/Music/track.mp3", + ), + ) + } + + @Test + fun `content URI is playable fallback when legacy path is absent`() { + assertEquals( + "content://media/external/audio/media/1", + TrackQuery.selectTrackUrl("content://media/external/audio/media/1", null), + ) + } + @Test fun `default sort is title ascending with stable id tie breaker`() { assertEquals( diff --git a/docs/docs/api.md b/docs/docs/api.md index 559d021..7a3db21 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -202,7 +202,7 @@ interface TrackOptions { after?: string; // Previous page's entity-ID cursor first?: number; // 1–1000 items (default: 20) sortBy?: SortByValue | SortByValue[]; - directory?: string; // Directory path to search + directory?: string; // Legacy path or supported Android SAF tree URI } ``` @@ -242,13 +242,27 @@ interface Track { artwork?: string; // Artwork file URI (may be undefined) album: string; // Album name duration: number; // Duration in seconds - url: string; // File URL or path + url: string; // Playable resource URI + contentUri?: string | null; // Canonical Android MediaStore URI createdAt?: number | null; // Date added (Unix seconds) modifiedAt?: number | null; // Resource modification time; null on iOS fileSize: number; // File size in bytes } ``` +#### Android Track resource compatibility + +Prefer `contentUri` for Android playback or resource access. For compatibility, +`url` remains a `file://` URI when MediaStore exposes a legacy path and falls +back to `contentUri` when it does not. Embedded Track Metadata is read through +`ContentResolver` and a temporary file adapter for the file-based tag parser. + +Absolute `directory` paths retain the legacy `DATA` filter. On Android 10 and +newer, Storage Access Framework tree URIs from +`com.android.externalstorage.documents` are mapped to MediaStore volume and +relative-path filters, including mounted removable volumes. Other providers and +SAF tree filtering before Android 10 reject with `UNSUPPORTED_DIRECTORY_URI`. + ### `Album` ```typescript diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 1e62e8b..e0fd318 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -95,7 +95,7 @@ const loadTracks = async () => { console.log( `Duration: ${Math.floor(track.duration / 60)}:${track.duration % 60}` ); - console.log(`File: ${track.url}`); + console.log(`Resource: ${track.contentUri ?? track.url}`); }); } catch (error) { console.error('Failed to load tracks:', error); diff --git a/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md b/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md index fddd244..41d3623 100644 --- a/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md +++ b/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md @@ -202,7 +202,7 @@ interface TrackOptions { after?: string; // 上一页返回的实体 ID 游标 first?: number; // 返回 1–1000 项(默认:20) sortBy?: SortByValue | SortByValue[]; - directory?: string; // 搜索目录路径 + directory?: string; // 旧路径或受支持的 Android SAF 树 URI } ``` @@ -241,13 +241,26 @@ interface Track { artwork?: string; // 封面文件 URI(可能为空) album: string; // 专辑名称 duration: number; // 时长(秒) - url: string; // 文件 URL 或路径 + url: string; // 可播放的资源 URI + contentUri?: string | null; // Android MediaStore 规范 URI createdAt?: number | null; // 添加日期(Unix 秒) modifiedAt?: number | null; // 资源修改时间;iOS 上为 null fileSize: number; // 文件大小(字节) } ``` +#### Android 曲目资源兼容策略 + +Android 播放或资源访问应优先使用 `contentUri`。为保持兼容,MediaStore 能提供 +旧文件路径时,`url` 仍是 `file://` URI;不能提供时,`url` 回退为同一个 +`contentUri`。内嵌曲目元数据通过 `ContentResolver` 读取,并用临时文件适配 +只接受文件的标签解析器。 + +绝对 `directory` 路径保留原有的 `DATA` 筛选。Android 10 及以上支持来自 +`com.android.externalstorage.documents` 的 SAF 树 URI,并按 MediaStore 卷和 +相对路径筛选,包括已挂载的可移动存储。其他 provider,或 Android 10 以前的 +SAF 树筛选,会以 `UNSUPPORTED_DIRECTORY_URI` 拒绝。 + ### `Album` ```typescript diff --git a/docs/i18n/zh/docusaurus-plugin-content-docs/current/getting-started.md b/docs/i18n/zh/docusaurus-plugin-content-docs/current/getting-started.md index 5a4153a..14041c8 100644 --- a/docs/i18n/zh/docusaurus-plugin-content-docs/current/getting-started.md +++ b/docs/i18n/zh/docusaurus-plugin-content-docs/current/getting-started.md @@ -36,7 +36,7 @@ import { request, PERMISSIONS, RESULTS } from 'react-native-permissions'; const requestMusicPermission = async () => { const result = await request(PERMISSIONS.ANDROID.READ_MEDIA_AUDIO); - + if (result === RESULTS.GRANTED) { console.log('音乐权限已授予'); } else { @@ -75,11 +75,11 @@ const requestMusicPermission = async () => { ### 导入库 ```js -import { - getTracksAsync, - getAlbumsAsync, +import { + getTracksAsync, + getAlbumsAsync, getArtistsAsync, - getTrackMetadataAsync + getTrackMetadataAsync, } from '@nodefinity/react-native-music-library'; ``` @@ -89,11 +89,13 @@ import { const loadTracks = async () => { try { const result = await getTracksAsync(); - - result.items.forEach(track => { + + result.items.forEach((track) => { console.log(`${track.title} - ${track.artist}`); - console.log(`时长: ${Math.floor(track.duration / 60)}:${track.duration % 60}`); - console.log(`文件: ${track.url}`); + console.log( + `时长: ${Math.floor(track.duration / 60)}:${track.duration % 60}` + ); + console.log(`资源: ${track.contentUri ?? track.url}`); }); } catch (error) { console.error('加载曲目失败:', error); @@ -108,10 +110,10 @@ const loadAlbums = async () => { try { const result = await getAlbumsAsync({ sortBy: ['title', true], // 按标题升序排序 - first: 50 + first: 50, }); - - result.items.forEach(album => { + + result.items.forEach((album) => { console.log(`${album.title} - ${album.artist}`); console.log(`曲目数: ${album.trackCount}`); }); @@ -127,10 +129,10 @@ const loadAlbums = async () => { const loadArtists = async () => { try { const result = await getArtistsAsync({ - sortBy: 'title' // 按名称排序 + sortBy: 'title', // 按名称排序 }); - - result.items.forEach(artist => { + + result.items.forEach((artist) => { console.log(`${artist.title}`); console.log(`专辑数: ${artist.albumCount}, 曲目数: ${artist.trackCount}`); }); diff --git a/example/src/contexts/PlayerContext.tsx b/example/src/contexts/PlayerContext.tsx index f0d13c9..4d605f6 100644 --- a/example/src/contexts/PlayerContext.tsx +++ b/example/src/contexts/PlayerContext.tsx @@ -6,6 +6,7 @@ import { AudioPro } from 'react-native-audio-pro'; export function toAudioProTrack(track: Track): AudioProTrack { return { ...track, + url: track.contentUri ?? track.url, artwork: track.artwork ?? require('../assets/default_artwork.png'), artist: track.artist ?? '', album: track.album ?? '', diff --git a/src/NativeMusicLibrary.ts b/src/NativeMusicLibrary.ts index 5729f4c..51227b3 100644 --- a/src/NativeMusicLibrary.ts +++ b/src/NativeMusicLibrary.ts @@ -186,9 +186,12 @@ export interface Track { /** Duration in seconds */ duration: number; - /** File URI or path */ + /** Playable resource URI. May use file, content, or platform-library schemes. */ url: string; + /** Canonical Android MediaStore content URI (Android only) */ + contentUri?: string | null; + /** Date added to the library as a Unix timestamp in seconds (optional) */ createdAt?: number | null; From 1a958849144f77175dc29bb0498243ba473521d7 Mon Sep 17 00:00:00 2001 From: ziven Date: Thu, 20 Aug 2026 23:46:50 +0800 Subject: [PATCH 4/7] refactor(example): use complete library helpers --- README.md | 5 + docs/docs/examples.md | 17 ++++ .../current/examples.md | 16 ++++ example/src/pages/AlbumListScreen.tsx | 37 ++------ example/src/pages/ArtistListScreen.tsx | 37 ++------ example/src/pages/TrackListScreen.tsx | 44 +++------ src/__tests__/index.test.tsx | 94 ++++++++++++++++++- 7 files changed, 160 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index a621af5..5003845 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,11 @@ async function fetchAllTracks() { } ``` +For workflows that need the complete current result, use +`getAllTracksAsync`, `getAllTracksByArtistAsync`, `getAllAlbumsAsync`, or +`getAllArtistsAsync`. These helpers follow `endCursor`, preserve the low-level +query options, and reject malformed or non-advancing native pagination. + ## 🤝 Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for details. diff --git a/docs/docs/examples.md b/docs/docs/examples.md index f7e347e..33ba810 100644 --- a/docs/docs/examples.md +++ b/docs/docs/examples.md @@ -32,6 +32,23 @@ const nextPage = firstPage.hasNextPage : undefined; ``` +## Load a complete result + +Use the high-level helpers when the workflow needs the complete current list. +The paginated methods remain available for incremental screens. + +```ts +import { + getAllTracksAsync, + getAllAlbumsAsync, + getAllArtistsAsync, +} from '@nodefinity/react-native-music-library'; + +const tracks = await getAllTracksAsync({ first: 100 }); +const albums = await getAllAlbumsAsync({ first: 100 }); +const artists = await getAllArtistsAsync({ first: 100 }); +``` + ## Read audio metadata and lyrics Load detailed Track Metadata when a user opens a track. The returned values diff --git a/docs/i18n/zh/docusaurus-plugin-content-docs/current/examples.md b/docs/i18n/zh/docusaurus-plugin-content-docs/current/examples.md index e87a6f1..1efe836 100644 --- a/docs/i18n/zh/docusaurus-plugin-content-docs/current/examples.md +++ b/docs/i18n/zh/docusaurus-plugin-content-docs/current/examples.md @@ -30,6 +30,22 @@ const nextPage = firstPage.hasNextPage : undefined; ``` +## 加载完整结果 + +当业务需要当前完整列表时,使用高层 helper;增量加载页面仍可继续使用分页方法。 + +```ts +import { + getAllTracksAsync, + getAllAlbumsAsync, + getAllArtistsAsync, +} from '@nodefinity/react-native-music-library'; + +const tracks = await getAllTracksAsync({ first: 100 }); +const albums = await getAllAlbumsAsync({ first: 100 }); +const artists = await getAllArtistsAsync({ first: 100 }); +``` + ## 读取音频元数据和歌词 当用户打开某个曲目时再加载详细的曲目元数据。返回值取决于平台和曲目本身能够提供的元数据。 diff --git a/example/src/pages/AlbumListScreen.tsx b/example/src/pages/AlbumListScreen.tsx index 8595b9f..363c58c 100644 --- a/example/src/pages/AlbumListScreen.tsx +++ b/example/src/pages/AlbumListScreen.tsx @@ -9,11 +9,8 @@ import { Alert, Button, } from 'react-native'; -import { getAlbumsAsync } from '@nodefinity/react-native-music-library'; -import type { - Album, - AlbumOptions, -} from '@nodefinity/react-native-music-library'; +import { getAllAlbumsAsync } from '@nodefinity/react-native-music-library'; +import type { Album } from '@nodefinity/react-native-music-library'; import { usePermission } from '../hooks/usePermission'; import { SafeAreaView } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -26,7 +23,7 @@ export default function AlbumListScreen({ navigation }: Props) { const [loading, setLoading] = useState(false); const { permissionStatus, requestPermissions } = usePermission(); - const getAllAlbums = async () => { + const handleGetAllAlbums = async () => { try { if (!permissionStatus?.granted) { const result = await requestPermissions(); @@ -41,7 +38,10 @@ export default function AlbumListScreen({ navigation }: Props) { setLoading(true); const startTime = Date.now(); - const results = await loadAllAlbums(); + const results = await getAllAlbumsAsync({ + first: 100, + sortBy: ['title', true], + }); const endTime = Date.now(); setAlbums(results); @@ -57,27 +57,6 @@ export default function AlbumListScreen({ navigation }: Props) { } }; - const loadAllAlbums = async (options: AlbumOptions = {}) => { - let allAlbums: Album[] = []; - let hasMore = true; - let cursor; - - while (hasMore) { - const result = await getAlbumsAsync({ - first: 100, - ...options, - after: cursor, - sortBy: ['title', true], - }); - - allAlbums = [...allAlbums, ...result.items]; - hasMore = result.hasNextPage; - cursor = result.endCursor; - } - - return allAlbums; - }; - const handleAlbumPress = async (album: Album) => { try { console.log('Loading album tracks for:', album.title); @@ -127,7 +106,7 @@ export default function AlbumListScreen({ navigation }: Props) {