Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
148 changes: 100 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,19 @@ getTracksAsync(options?: TrackOptions): Promise<PaginatedResult<Track>>

**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)`
Expand Down Expand Up @@ -145,7 +149,7 @@ getAlbumsAsync(options?: AlbumOptions): Promise<PaginatedResult<Album>>

| 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]` |

Expand Down Expand Up @@ -175,7 +179,7 @@ getArtistsAsync(options?: ArtistOptions): Promise<PaginatedResult<Artist>>

| 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]` |

Expand All @@ -187,52 +191,68 @@ getArtistsAsync(options?: ArtistOptions): Promise<PaginatedResult<Artist>>

### `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`

Expand All @@ -252,6 +272,12 @@ getArtistsAsync(options?: ArtistOptions): Promise<PaginatedResult<Artist>>
| `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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion android/src/main/java/com/musiclibrary/albums/GetAlbums.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
}
}
}
Loading
Loading