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..a190442 100644 --- a/README.md +++ b/README.md @@ -92,15 +92,19 @@ getTracksAsync(options?: TrackOptions): Promise> **TrackOptions** -| Property | Type | Default | Description | -| ----------- | -------------------------------------------------------- | ----------- | --------------------------------------- | -| `first` | `number` | `20` | Max number of items to return | -| `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'` +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,52 +191,68 @@ 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 \| null` | Artist name, or `null` | +| `artwork` | `string \| null` | Artwork reference, or `null` | +| `album` | `string \| null` | Album name, or `null` | +| `duration` | `number` | Duration in seconds | +| `url` | `string` | Playable URI; preserves `file://` when available on Android | +| `contentUri` | `string?` | Canonical Android MediaStore URI | +| `createdAt` | `number \| null` | Date added, Unix timestamp in seconds, or `null` | +| `modifiedAt` | `number \| null` | 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` -| Field | Type | Description | -| ------------- | -------- | ------------------------------------ | -| `id` | `string` | Track ID | -| `duration` | `number` | Duration in seconds | -| `bitrate` | `number` | Bitrate in kbps | -| `sampleRate` | `number` | Sample rate in Hz | -| `channels` | `string` | Number of audio channels | -| `format` | `string` | Audio format (e.g. `"AAC"`, `"MP3"`) | -| `title` | `string` | Title tag | -| `artist` | `string` | Artist tag | -| `album` | `string` | Album tag | -| `year` | `number` | Release year | -| `genre` | `string` | Genre tag | -| `track` | `number` | Track number | -| `disc` | `number` | Disc number | -| `composer` | `string` | Composer tag | -| `lyricist` | `string` | Lyricist tag | -| `lyrics` | `string` | Embedded lyrics | -| `albumArtist` | `string` | Album artist tag | -| `comment` | `string` | Comment tag | +| Field | Type | Description | +| ------------- | ---------------- | ----------------------------------- | +| `id` | `string` | Track ID | +| `duration` | `number \| null` | Duration in seconds, or `null` | +| `bitrate` | `number \| null` | Bitrate in kbps, or `null` | +| `sampleRate` | `number \| null` | Sample rate in Hz, or `null` | +| `channels` | `string \| null` | Number of audio channels, or `null` | +| `format` | `string \| null` | Audio format, or `null` | +| `title` | `string \| null` | Title tag, or `null` | +| `artist` | `string \| null` | Artist tag, or `null` | +| `album` | `string \| null` | Album tag, or `null` | +| `year` | `number \| null` | Release year, or `null` | +| `genre` | `string \| null` | Genre tag, or `null` | +| `track` | `number \| null` | Track number, or `null` | +| `disc` | `number \| null` | Disc number, or `null` | +| `composer` | `string \| null` | Composer tag, or `null` | +| `lyricist` | `string \| null` | Lyricist tag, or `null` | +| `lyrics` | `string \| null` | Embedded lyrics, or `null` | +| `albumArtist` | `string \| null` | Album artist tag, or `null` | +| `comment` | `string \| null` | Comment tag, or `null` | ### `Album` -| Field | Type | Description | -| ------------ | --------- | ------------------------------ | -| `id` | `string` | Unique identifier | -| `title` | `string` | Album name | -| `artist` | `string` | Primary artist | -| `artwork` | `string?` | Artwork URI (may be undefined) | -| `trackCount` | `number` | Number of tracks | -| `year` | `number?` | Release year | +| Field | Type | Description | +| ------------ | ---------------- | ---------------------------- | +| `id` | `string` | Unique identifier | +| `title` | `string` | Album name | +| `artist` | `string` | Primary artist | +| `artwork` | `string \| null` | Artwork reference, or `null` | +| `trackCount` | `number` | Number of tracks | +| `year` | `number \| null` | Release year, or `null` | ### `Artist` @@ -252,6 +272,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 @@ -271,6 +297,32 @@ 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. + +## Result and error contract + +Nullable `Track`, `Album`, and `TrackMetadata` fields are always present and +use `null` when the Local Music Library does not provide a value. Structural +optional fields are omitted: `contentUri` is Android-only, and a terminal page +does not contain `endCursor`. Missing or unreadable Embedded Metadata resolves +with the available Library Metadata and `null` values. + +Native failures reject the Promise with a `MusicLibraryError` and one of these +stable codes: + +| Code | Meaning | +| --------------------------- | --------------------------------------------------------- | +| `PERMISSION_DENIED` | Local Music Library access is not authorized | +| `TRACK_NOT_FOUND` | The requested Track no longer exists | +| `QUERY_ERROR` | The platform library query or resource read failed | +| `INVALID_CURSOR` | `after` is not a valid entity-ID cursor | +| `CURSOR_NOT_FOUND` | The cursor is valid but absent from this query | +| `INVALID_PAGE_SIZE` | `first` is outside `1`–`1000` | +| `UNSUPPORTED_DIRECTORY_URI` | Android cannot safely map the directory URI to MediaStore | + ## 🤝 Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for details. 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/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/GetAlbumsByArtist.kt b/android/src/main/java/com/musiclibrary/albums/GetAlbumsByArtist.kt index a681289..baaebcd 100644 --- a/android/src/main/java/com/musiclibrary/albums/GetAlbumsByArtist.kt +++ b/android/src/main/java/com/musiclibrary/albums/GetAlbumsByArtist.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Arguments import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetAlbumsByArtist( private val context: Context, @@ -24,7 +25,7 @@ internal class GetAlbumsByArtist( promise.resolve(albumsArray) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query albums by artist: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query albums by artist") } } } diff --git a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt index 927a983..9f97fa5 100644 --- a/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt +++ b/android/src/main/java/com/musiclibrary/albums/GetAlbumsQuery.kt @@ -2,16 +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 androidx.core.net.toUri +import com.musiclibrary.utils.moveToPageStart +import com.musiclibrary.utils.readCursorPage +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, @@ -22,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, @@ -32,42 +34,26 @@ 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) 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() - } - - var count = 0 - val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries - - while (foundAfter && count < maxItems) { - try { + val foundAfter = moveToPageStart( + after = options.after, + moveToFirst = c::moveToFirst, + currentId = { c.getLong(idColumn).toString() }, + moveToNext = c::moveToNext, + ) + + val page = readCursorPage( + canReadFirst = foundAfter, + maxItems = options.first, + readItem = { val id = c.getLong(idColumn) val albumTitle = c.getString(albumColumn) ?: "" val artist = c.getString(artistColumn) ?: "" @@ -76,42 +62,31 @@ object GetAlbumsQuery { // Skip invalid albums if (albumTitle.isEmpty() || trackCount == 0) { - continue + null + } else { + // Get artwork URI + // Create an Album + Album( + id = id.toString(), + title = albumTitle, + artist = artist, + artwork = "content://media/external/audio/albumart/$id", + 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 { @@ -127,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 @@ -159,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 ce77efd..1ed295b 100644 --- a/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt +++ b/android/src/main/java/com/musiclibrary/artists/GetArtistsQuery.kt @@ -2,16 +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 androidx.core.net.toUri +import com.musiclibrary.utils.moveToPageStart +import com.musiclibrary.utils.readCursorPage +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, @@ -21,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, @@ -31,41 +33,25 @@ 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) 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() - } - - var count = 0 - val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries - - while (foundAfter && count < maxItems) { - try { + val foundAfter = moveToPageStart( + after = options.after, + moveToFirst = c::moveToFirst, + currentId = { c.getLong(idColumn).toString() }, + moveToNext = c::moveToNext, + ) + + val page = readCursorPage( + canReadFirst = foundAfter, + maxItems = options.first, + readItem = { val id = c.getLong(idColumn) val artistName = c.getString(artistColumn) ?: "" val albumCount = c.getInt(albumCountColumn) @@ -73,37 +59,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 { @@ -119,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 @@ -136,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/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..f1c227f 100644 --- a/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt +++ b/android/src/main/java/com/musiclibrary/tracks/GetTrackMetadataQuery.kt @@ -3,6 +3,7 @@ package com.musiclibrary.tracks import android.content.Context import com.facebook.react.bridge.Promise import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetTrackMetadataQuery( private val context: Context, @@ -13,13 +14,13 @@ 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) { promise.reject("TRACK_NOT_FOUND", "Track with id $trackId not found", e) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to get track metadata: ${e.message}", e) + promise.rejectQueryError(e, "Failed to get track metadata") } } } 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/GetTracksByAlbum.kt b/android/src/main/java/com/musiclibrary/tracks/GetTracksByAlbum.kt index af19174..aeea423 100644 --- a/android/src/main/java/com/musiclibrary/tracks/GetTracksByAlbum.kt +++ b/android/src/main/java/com/musiclibrary/tracks/GetTracksByAlbum.kt @@ -4,6 +4,7 @@ import android.content.Context import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Arguments import com.musiclibrary.utils.DataConverter +import com.musiclibrary.utils.rejectQueryError internal class GetTracksByAlbum( private val context: Context, @@ -24,7 +25,7 @@ internal class GetTracksByAlbum( promise.resolve(tracksArray) } catch (e: Exception) { - promise.reject("QUERY_ERROR", "Failed to query tracks by album: ${e.message}", e) + promise.rejectQueryError(e, "Failed to query tracks by album") } } } 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/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 80bf6d7..6a3ecde 100644 --- a/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt +++ b/android/src/main/java/com/musiclibrary/tracks/TrackQuery.kt @@ -1,15 +1,15 @@ 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 import com.musiclibrary.models.TrackOptions +import com.musiclibrary.utils.moveToPageStart +import com.musiclibrary.utils.validatePageSize internal sealed class TrackQueryFilter { object All : TrackQueryFilter() @@ -28,19 +28,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 +80,7 @@ internal object TrackQuery { contentResolver = contentResolver, filter = filter, directory = null, - sortOrder = buildSortOrder(sortPolicy, emptyList()) + sortOrder = buildTrackSortOrder(sortPolicy, emptyList()) ) cursor.use { c -> @@ -94,46 +101,31 @@ 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") } - 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) - 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) 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( @@ -143,9 +135,10 @@ internal object TrackQuery { artwork = artworkUri(id), album = album, duration = duration, - url = "file://$data", - createdAt = dateAdded * 1000, - modifiedAt = dateAdded * 1000, + url = selectTrackUrl(contentUri, data), + contentUri = contentUri, + createdAt = dateAdded, + modifiedAt = dateModified, fileSize = fileSize ) } catch (_: Exception) { @@ -153,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) { @@ -165,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) { @@ -181,28 +178,18 @@ 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() } - 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,26 +205,18 @@ internal object TrackQuery { val order = if (sortOption.ascending) "ASC" else "DESC" "$column $order" } - } - - 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" - } + return (descriptors + "${MediaStore.Audio.Media._ID} ASC").joinToString(", ") } 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, @@ -246,6 +225,7 @@ internal object TrackQuery { val duration: Int, val data: Int, val dateAdded: Int, + val dateModified: Int, val size: Int, ) { companion object { @@ -258,6 +238,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 +253,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/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/main/java/com/musiclibrary/utils/DataConverter.kt b/android/src/main/java/com/musiclibrary/utils/DataConverter.kt index 75db8fe..f97f072 100644 --- a/android/src/main/java/com/musiclibrary/utils/DataConverter.kt +++ b/android/src/main/java/com/musiclibrary/utils/DataConverter.kt @@ -10,15 +10,16 @@ object DataConverter { val map = Arguments.createMap() map.putString("id", track.id) map.putString("title", track.title) - track.artist?.let { map.putString("artist", it) } - track.artwork?.let { map.putString("artwork", it) } - track.album?.let { map.putString("album", it) } + putNullableString(map, "artist", track.artist) + putNullableString(map, "artwork", track.artwork) + putNullableString(map, "album", track.album) 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()) } - track.modifiedAt?.let { map.putDouble("modifiedAt", it.toDouble()) } + putNullableLong(map, "createdAt", track.createdAt) + putNullableLong(map, "modifiedAt", track.modifiedAt) return map } @@ -28,9 +29,9 @@ object DataConverter { map.putString("id", album.id) map.putString("title", album.title) map.putString("artist", album.artist) - album.artwork?.let { map.putString("artwork", it) } + putNullableString(map, "artwork", album.artwork) map.putInt("trackCount", album.trackCount) - album.year?.let { map.putInt("year", it) } + if (album.year == null) map.putNull("year") else map.putInt("year", album.year) return map } @@ -76,23 +77,39 @@ object DataConverter { fun trackMetadataToWritableMap(metadata: TrackMetadata): WritableMap { val map = Arguments.createMap() map.putString("id", metadata.id) - metadata.duration?.let { map.putDouble("duration", it) } - metadata.bitrate?.let { map.putLong("bitrate", it) } - metadata.sampleRate?.let { map.putInt("sampleRate", it) } - metadata.channels?.let { map.putString("channels", it) } - metadata.format?.let { map.putString("format", it) } - metadata.title?.let { map.putString("title", it) } - metadata.artist?.let { map.putString("artist", it) } - metadata.album?.let { map.putString("album", it) } - metadata.year?.let { map.putInt("year", it) } - metadata.genre?.let { map.putString("genre", it) } - metadata.track?.let { map.putInt("track", it) } - metadata.disc?.let { map.putInt("disc", it) } - metadata.composer?.let { map.putString("composer", it) } - metadata.lyricist?.let { map.putString("lyricist", it) } - metadata.lyrics?.let { map.putString("lyrics", it) } - metadata.albumArtist?.let { map.putString("albumArtist", it) } - metadata.comment?.let { map.putString("comment", it) } + putNullableDouble(map, "duration", metadata.duration) + putNullableLong(map, "bitrate", metadata.bitrate) + putNullableInt(map, "sampleRate", metadata.sampleRate) + putNullableString(map, "channels", metadata.channels) + putNullableString(map, "format", metadata.format) + putNullableString(map, "title", metadata.title) + putNullableString(map, "artist", metadata.artist) + putNullableString(map, "album", metadata.album) + putNullableInt(map, "year", metadata.year) + putNullableString(map, "genre", metadata.genre) + putNullableInt(map, "track", metadata.track) + putNullableInt(map, "disc", metadata.disc) + putNullableString(map, "composer", metadata.composer) + putNullableString(map, "lyricist", metadata.lyricist) + putNullableString(map, "lyrics", metadata.lyrics) + putNullableString(map, "albumArtist", metadata.albumArtist) + putNullableString(map, "comment", metadata.comment) return map } + + private fun putNullableString(map: WritableMap, key: String, value: String?) { + if (value == null) map.putNull(key) else map.putString(key, value) + } + + private fun putNullableInt(map: WritableMap, key: String, value: Int?) { + if (value == null) map.putNull(key) else map.putInt(key, value) + } + + private fun putNullableLong(map: WritableMap, key: String, value: Long?) { + if (value == null) map.putNull(key) else map.putDouble(key, value.toDouble()) + } + + private fun putNullableDouble(map: WritableMap, key: String, value: Double?) { + if (value == null) map.putNull(key) else map.putDouble(key, value) + } } diff --git a/android/src/main/java/com/musiclibrary/utils/ModuleUtils.kt b/android/src/main/java/com/musiclibrary/utils/ModuleUtils.kt index 49d5b95..c64cc96 100644 --- a/android/src/main/java/com/musiclibrary/utils/ModuleUtils.kt +++ b/android/src/main/java/com/musiclibrary/utils/ModuleUtils.kt @@ -29,7 +29,7 @@ object ModuleUtils { } catch (e: SecurityException) { promise.reject("PERMISSION_DENIED", e.message, e) } catch (e: Exception) { - promise.reject("MODULE_ERROR", "Operation failed: ${e.message}", e) + promise.reject("QUERY_ERROR", "Operation failed: ${e.message}", e) } } @@ -44,4 +44,4 @@ object ModuleUtils { ) == PackageManager.PERMISSION_GRANTED } } -} \ No newline at end of file +} 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..cf736ad --- /dev/null +++ b/android/src/main/java/com/musiclibrary/utils/Pagination.kt @@ -0,0 +1,59 @@ +package com.musiclibrary.utils + +internal sealed class PaginationException( + override val code: String, + message: String, +) : IllegalArgumentException(message), CodedQueryException + +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)) +} 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..5b488f7 --- /dev/null +++ b/android/src/main/java/com/musiclibrary/utils/QueryErrors.kt @@ -0,0 +1,21 @@ +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 SecurityException) { + reject("PERMISSION_DENIED", error.message, error) + return + } + + 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/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/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 new file mode 100644 index 0000000..31d1fa6 --- /dev/null +++ b/android/src/test/java/com/musiclibrary/tracks/TrackSortOrderTest.kt @@ -0,0 +1,45 @@ +package com.musiclibrary.tracks + +import com.musiclibrary.models.SortOption +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( + "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/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) + } +} 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..d42b6c6 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -199,10 +199,10 @@ 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 + directory?: string; // Legacy path or supported Android SAF tree URI } ``` @@ -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,29 +220,49 @@ 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 interface Track { id: string; title: string; // Track title - artist: string; // Artist name - artwork?: string; // Artwork file URI (may be undefined) - album: string; // Album name + artist: string | null; // Artist name, or null + artwork: string | null; // Artwork reference, or null + album: string | null; // Album name, or null duration: number; // Duration in seconds - url: string; // File URL or path - createdAt: number; // Date added (Unix timestamp) - modifiedAt: number; // Date modified (Unix timestamp) + url: string; // Playable resource URI + contentUri?: string | null; // Canonical Android MediaStore URI + createdAt: number | null; // Date added (Unix seconds), or null + 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 @@ -250,9 +270,9 @@ interface Album { id: string; title: string; // Album name artist: string; // Primary artist - artwork?: string; // Album artwork URI + artwork: string | null; // Album artwork reference, or null trackCount: number; // Number of tracks - year?: number; // Release year + year: number | null; // Release year, or null } ``` @@ -274,30 +294,56 @@ interface TrackMetadata { id: string; // Track ID // Audio header - duration: number; // Duration in seconds - bitrate: number; // Bitrate in kbps - sampleRate: number; // Sample rate in Hz - channels: string; // Number of channels (e.g. "2") - format: string; // Audio format + duration: number | null; // Duration in seconds, or null + bitrate: number | null; // Bitrate in kbps, or null + sampleRate: number | null; // Sample rate in Hz, or null + channels: string | null; // Number of channels, or null + format: string | null; // Audio format, or null // Tag info - title: string; // Track title - artist: string; // Artist name - album: string; // Album name - year: number; // Release year - genre: string; // Music genre - track: number; // Track number - disc: number; // Disc number - composer: string; // Composer - lyricist: string; // Lyricist - lyrics: string; // Lyrics content - albumArtist: string; // Album artist - comment: string; // Comment + title: string | null; // Track title, or null + artist: string | null; // Artist name, or null + album: string | null; // Album name, or null + year: number | null; // Release year, or null + genre: string | null; // Music genre, or null + track: number | null; // Track number, or null + disc: number | null; // Disc number, or null + composer: string | null; // Composer, or null + lyricist: string | null; // Lyricist, or null + lyrics: string | null; // Lyrics content, or null + albumArtist: string | null; // Album artist, or null + comment: string | null; // Comment, or null } ``` +## Result and Error Contract + +Nullable entity and Track Metadata fields are always present and contain +`null` when unavailable. Structural optional fields are omitted: +`contentUri` is Android-only, while terminal pagination results omit +`endCursor`. If Embedded Metadata is absent or unreadable, +`getTrackMetadataAsync` still resolves with available Library Metadata and +`null` for missing values. + +Native failures reject with a `MusicLibraryError` whose `code` is one of: + +| Code | Meaning | +| --------------------------- | --------------------------------------------------------- | +| `PERMISSION_DENIED` | Local Music Library access is not authorized | +| `TRACK_NOT_FOUND` | The requested Track no longer exists | +| `QUERY_ERROR` | The native query or resource read failed | +| `INVALID_CURSOR` | `after` is not a valid entity-ID cursor | +| `CURSOR_NOT_FOUND` | The cursor is valid but absent from the current query | +| `INVALID_PAGE_SIZE` | `first` is outside `1`–`1000` | +| `UNSUPPORTED_DIRECTORY_URI` | Android cannot safely map the directory URI to MediaStore | + ## 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 +352,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/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/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 95375c1..f5abbe7 100644 --- a/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md +++ b/docs/i18n/zh/docusaurus-plugin-content-docs/current/api.md @@ -199,10 +199,10 @@ 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; // 搜索目录路径 + directory?: string; // 旧路径或受支持的 Android SAF 树 URI } ``` @@ -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,29 +220,47 @@ 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 interface Track { id: string; title: string; // 曲目标题 - artist: string; // 艺术家名称 - artwork?: string; // 封面文件 URI(可能为空) - album: string; // 专辑名称 + artist: string | null; // 艺术家名称,缺失时为 null + artwork: string | null; // 封面引用,缺失时为 null + album: string | null; // 专辑名称,缺失时为 null duration: number; // 时长(秒) - url: string; // 文件 URL 或路径 - createdAt: number; // 添加日期(Unix 时间戳) - modifiedAt: number; // 修改日期(Unix 时间戳) + url: string; // 可播放的资源 URI + contentUri?: string | null; // Android MediaStore 规范 URI + createdAt: number | null; // 添加日期(Unix 秒),缺失时为 null + 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 @@ -250,9 +268,9 @@ interface Album { id: string; title: string; // 专辑名称 artist: string; // 主要艺术家 - artwork?: string; // 专辑封面 URI + artwork: string | null; // 专辑封面引用,缺失时为 null trackCount: number; // 曲目数量 - year?: number; // 发行年份 + year: number | null; // 发行年份,缺失时为 null } ``` @@ -274,30 +292,53 @@ interface TrackMetadata { id: string; // 曲目 ID // 音频头信息 - duration: number; // 时长(秒) - bitrate: number; // 比特率(kbps) - sampleRate: number; // 采样率(Hz) - channels: string; // 声道数(如 "2") - format: string; // 音频格式 + duration: number | null; // 时长(秒),缺失时为 null + bitrate: number | null; // 比特率(kbps),缺失时为 null + sampleRate: number | null; // 采样率(Hz),缺失时为 null + channels: string | null; // 声道数,缺失时为 null + format: string | null; // 音频格式,缺失时为 null // 标签信息 - title: string; // 曲目标题 - artist: string; // 艺术家名称 - album: string; // 专辑名称 - year: number; // 发行年份 - genre: string; // 音乐流派 - track: number; // 曲目编号 - disc: number; // 碟片编号 - composer: string; // 作曲家 - lyricist: string; // 作词家 - lyrics: string; // 歌词内容 - albumArtist: string; // 专辑艺术家 - comment: string; // 注释 + title: string | null; // 曲目标题,缺失时为 null + artist: string | null; // 艺术家名称,缺失时为 null + album: string | null; // 专辑名称,缺失时为 null + year: number | null; // 发行年份,缺失时为 null + genre: string | null; // 音乐流派,缺失时为 null + track: number | null; // 曲目编号,缺失时为 null + disc: number | null; // 碟片编号,缺失时为 null + composer: string | null; // 作曲家,缺失时为 null + lyricist: string | null; // 作词家,缺失时为 null + lyrics: string | null; // 歌词内容,缺失时为 null + albumArtist: string | null; // 专辑艺术家,缺失时为 null + comment: string | null; // 注释,缺失时为 null } ``` +## 返回值与错误契约 + +实体和曲目元数据中的可空字段始终存在;无可用值时返回 `null`。结构性的可选 +字段则会省略:`contentUri` 仅 Android 返回,分页终止页不含 `endCursor`。 +内嵌元数据不存在或无法读取时,`getTrackMetadataAsync` 仍会返回已有的音乐库 +元数据,其余字段为 `null`。 + +原生失败会拒绝 Promise,并返回带稳定 `code` 的 `MusicLibraryError`: + +| Code | 含义 | +| --------------------------- | -------------------------------------------- | +| `PERMISSION_DENIED` | 未获得本地音乐库访问权限 | +| `TRACK_NOT_FOUND` | 请求的曲目已不存在 | +| `QUERY_ERROR` | 原生查询或资源读取失败 | +| `INVALID_CURSOR` | `after` 不是合法的实体 ID 游标 | +| `CURSOR_NOT_FOUND` | 游标格式合法,但不在当前查询结果中 | +| `INVALID_PAGE_SIZE` | `first` 不在 `1`–`1000` 范围内 | +| `UNSUPPORTED_DIRECTORY_URI` | Android 无法将目录 URI 安全映射到 MediaStore | + ## 排序选项 +默认按标题升序。单独传入排序键时表示降序。多个排序条件按声明顺序进行 +词典序比较,最后以实体 ID 升序打破平局。两个平台都提供对应字段时,缺失的 +字符串或数值在升序中排在已有值之前。 + ### 曲目排序键 - `'default'` - 默认排序(标题) @@ -306,7 +347,7 @@ interface TrackMetadata { - `'album'` - 按专辑名称排序 - `'duration'` - 按时长排序 - `'createdAt'` - 按创建日期排序 -- `'modifiedAt'` - 按修改日期排序 +- `'modifiedAt'` - 按资源修改日期排序(Android;iOS 缺失值相等并回退到 ID) - `'fileSize'` - 按文件大小排序 ### 专辑排序键 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/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/ios/Podfile.lock b/example/ios/Podfile.lock index ef0cd6b..8530cab 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -32,7 +32,7 @@ PODS: - hermes-engine (0.79.2): - hermes-engine/Pre-built (= 0.79.2) - hermes-engine/Pre-built (0.79.2) - - MusicLibrary (0.10.1): + - MusicLibrary (0.10.2): - DoubleConversion - glog - hermes-engine @@ -2179,7 +2179,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe - MusicLibrary: 7cde7f3ff04c6fc2134ceed501918516aa323481 + MusicLibrary: d63479edcd5069533b4a0a49cd779521c58579e7 RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 RCTDeprecation: 83ffb90c23ee5cea353bd32008a7bca100908f8c RCTRequired: eb7c0aba998009f47a540bec9e9d69a54f68136e 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/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) {