@omelhorsite/sdk 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +4 -4
  3. package/dist/types/auth/device.d.ts +1 -1
  4. package/dist/types/auth/index.d.ts +2 -2
  5. package/dist/types/auth/tokens.d.ts +15 -15
  6. package/dist/types/client.d.ts +10 -10
  7. package/dist/types/errors.d.ts +12 -15
  8. package/dist/types/http.d.ts +74 -118
  9. package/dist/types/index.d.ts +1 -2
  10. package/dist/types/local/qr.d.ts +1 -1
  11. package/dist/types/local/wordlist.d.ts +2 -3
  12. package/dist/types/resources/account.d.ts +14 -17
  13. package/dist/types/resources/auth/index.d.ts +1 -1
  14. package/dist/types/resources/auth/passkeys.d.ts +127 -163
  15. package/dist/types/resources/auth/sessions.d.ts +110 -152
  16. package/dist/types/resources/chests.d.ts +27 -31
  17. package/dist/types/resources/dynamicQrs.d.ts +29 -45
  18. package/dist/types/resources/forms.d.ts +37 -58
  19. package/dist/types/resources/jobs.d.ts +28 -40
  20. package/dist/types/resources/media.d.ts +48 -61
  21. package/dist/types/resources/music/artists.d.ts +179 -245
  22. package/dist/types/resources/music/imports.d.ts +181 -210
  23. package/dist/types/resources/music/index.d.ts +8 -7
  24. package/dist/types/resources/music/playlists.d.ts +77 -110
  25. package/dist/types/resources/music/social.d.ts +153 -228
  26. package/dist/types/resources/music/songs.d.ts +160 -206
  27. package/dist/types/resources/realtime.d.ts +75 -88
  28. package/dist/types/resources/shortLinks.d.ts +33 -45
  29. package/dist/types/resources/storage/upload.d.ts +42 -56
  30. package/dist/types/resources/storage.d.ts +71 -104
  31. package/dist/types/resources/tools/backgroundRemoval.d.ts +11 -13
  32. package/dist/types/resources/tools/captions.d.ts +107 -135
  33. package/dist/types/resources/tools/upscale.d.ts +12 -16
  34. package/dist/types/types.d.ts +29 -38
  35. package/package.json +1 -1
@@ -5,43 +5,33 @@
5
5
  *
6
6
  * ## An artist row belongs to ONE user
7
7
  *
8
- * There is no global artist table. `Artist` is scoped by `user_id` and every
9
- * route here runs inside `Artist.viewable_by(Current.user)`, so two accounts
10
- * that both own a Chico Buarque track own two different artist rows with two
11
- * different integer ids. Never cache an artist id across identities, and never
12
- * hand one to another user's client.
8
+ * There is no global artist table. Every artist is scoped to the account that
9
+ * owns it, so two accounts that both own a Chico Buarque track own two
10
+ * different artist rows with two different integer ids. Never cache an artist
11
+ * id across identities, and never hand one to another user's client.
13
12
  *
14
- * Rows are created as a side effect of importing songs (`Songs::ArtistAttacher`)
15
- * and by `ArtistResolver`; this namespace has no `create`. `POST /artists` is
16
- * not routed - `resources :artists, only: [index, show, update, destroy]`.
13
+ * Rows are created as a side effect of importing songs; this namespace has no
14
+ * `create`, and `POST /artists` is not routed.
17
15
  *
18
16
  * ## Almost everything here shares ONE 60/min bucket
19
17
  *
20
- * `Rack::Attack` throttles `/lyrics`, `/artists/`, `/artist_metadata/` and
21
- * `/music_radios/` together at **60 requests a minute** - one bucket for all
22
- * four families, keyed by the literal `Authorization` header (or by IP when the
23
- * call is anonymous). A screen that opens an artist page while a lyrics panel
24
- * polls is spending the same budget twice.
18
+ * `/lyrics*`, `/artists/*`, `/artist_metadata/*` and `/music_radios/*` share
19
+ * **60 requests a minute** - one bucket for all four families, keyed by the
20
+ * `Authorization` header (or by IP when the call is anonymous). A screen that
21
+ * opens an artist page while a lyrics panel polls is spending the same budget
22
+ * twice.
25
23
  *
26
- * The regular expression is `\A/(lyrics|artists/|artist_metadata/|music_radios/)`
27
- * and the trailing slash after `artists` is load-bearing: the path is
28
- * normalised before it is matched, so `GET /artists` (the roster index) does
29
- * NOT match and falls back to the general authenticated ceiling of 600/min.
30
- * `/artists/overview`, `/artists/:id`, both uploads, `PATCH` and `DELETE` all
31
- * DO match. The `/artist_imports*` and `/artist_syncs*` routes do not (neither
32
- * `artist_imports` nor `artist_syncs` is `artists/`) and are on the general
33
- * ceiling as well.
34
- *
35
- * Every method below states which of the two it lands in.
24
+ * `GET /artists` itself (the roster index) is NOT in that bucket and falls
25
+ * under the general authenticated ceiling of 600/min. `/artists/overview`,
26
+ * `/artists/:id`, both uploads, `PATCH` and `DELETE` all are. `/artist_imports*`
27
+ * and `/artist_syncs*` are on the general ceiling as well. Every method below
28
+ * states which of the two it lands in.
36
29
  *
37
30
  * ## An OAuth access token cannot reach any of this
38
31
  *
39
- * `Authentication#enforce_oauth_scope!` denies by default: a Doorkeeper token
40
- * reaches an action only when its controller declared an `oauth_scope` for it.
41
- * No music controller declares one, so a CLI or MCP host holding an OAuth
42
- * token gets `403 {"error":"insufficient_scope"}` on every route in this file,
43
- * whatever scopes it was granted. Use a session token (`POST /sessions`) or,
44
- * in the browser, the session cookie.
32
+ * Every route in this file answers `403 {"error":"insufficient_scope"}` to an
33
+ * OAuth access token, whatever scopes it was granted. Use a session token
34
+ * (`POST /sessions`) or, in the browser, the session cookie.
45
35
  */
46
36
  import { ApiClient, Resource } from "../../http";
47
37
  import type { ListParams } from "../../listing";
@@ -54,25 +44,23 @@ export type ArtistId = number;
54
44
  /** Primary key of an artist import. Also an integer. */
55
45
  export type ArtistImportId = number;
56
46
  /**
57
- * Page size the web roster and the mobile roster both use (FR-37: infinite
58
- * scroll, 60 a page). Nothing on the server requires it; it is here so the
59
- * three clients page identically and their caches line up.
47
+ * Default roster page size, 60. Nothing on the server requires it; it is here
48
+ * so every caller pages identically and caches line up.
60
49
  */
61
50
  export declare const ARTIST_ROSTER_PAGE_SIZE = 60;
62
51
  /** Largest artist image or banner the server will accept, in bytes. */
63
52
  export declare const ARTIST_IMAGE_MAX_BYTES: number;
64
53
  /**
65
- * Content types `Artists::ImageAttacher` maps directly to a stored extension.
54
+ * Content types the server maps directly to a stored extension.
66
55
  *
67
- * A file whose type is not in this list is NOT necessarily refused: the
68
- * attacher falls back to the extension of the filename and only gives up when
69
- * that is empty too. So `application/octet-stream` + `cover.png` is accepted
56
+ * A file whose type is not in this list is NOT necessarily refused: the server
57
+ * falls back to the extension of the filename and only gives up when that is
58
+ * empty too. So `application/octet-stream` + `cover.png` is accepted
70
59
  * and `application/octet-stream` + `cover` is a 400.
71
60
  */
72
61
  export declare const ARTIST_IMAGE_CONTENT_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/gif"];
73
62
  /**
74
- * An artist, as `GET /artists` renders it (the blueprint's default view plus
75
- * two computed fields).
63
+ * An artist, as `GET /artists` renders it.
76
64
  *
77
65
  * ## Every image field, and which of them can be trusted
78
66
  *
@@ -87,13 +75,12 @@ export declare const ARTIST_IMAGE_CONTENT_TYPES: readonly ["image/jpeg", "image/
87
75
  * it - it is written only by `GET /songs/artist_pictures`, which lives in the
88
76
  * songs namespace. See {@link Artist.pictures_fetched_at} for why a fresh
89
77
  * timestamp there is not a promise.
90
- * - `external_image_url` comes from Last.fm through `ArtistResolver`, and is
91
- * usually null on purpose: Last.fm retired artist images and now answers with
92
- * one grey-star placeholder for everybody, which the resolver filters out
93
- * rather than store.
78
+ * - `external_image_url` comes from Last.fm, and is usually null on purpose:
79
+ * Last.fm retired artist images and now answers with one grey-star
80
+ * placeholder for everybody, which is filtered out rather than stored.
94
81
  * - `gallery_image_urls` (extended view only) is Wikipedia/Wikimedia.
95
82
  *
96
- * The resolution chain all three clients implement, in order:
83
+ * The resolution chain, in order:
97
84
  * `compressed_image_media_id` -> `image_media_id` -> the size-appropriate
98
85
  * `picture_*` (medium for an avatar, `picture_xl` for a hero) -> `picture` ->
99
86
  * `gallery_image_urls[0]` -> `fallback_artwork_media_id` -> `external_image_url`
@@ -101,10 +88,8 @@ export declare const ARTIST_IMAGE_CONTENT_TYPES: readonly ["image/jpeg", "image/
101
88
  *
102
89
  * ## `*_media_id` and `*_fs_node_id` are the same value, twice
103
90
  *
104
- * `ApplicationBlueprint.media_id_fields` emits both keys with an identical
105
- * value for every attachment, because the old web frontend still reads the
106
- * legacy `_fs_node_id` name. Read `_media_id`; the twin is scheduled to go.
107
- * Both docs in `oms-music/docs` document only the legacy name.
91
+ * Both keys carry an identical value for every attachment. Read `_media_id`;
92
+ * the `_fs_node_id` twin is a legacy alias scheduled to go.
108
93
  */
109
94
  export interface Artist {
110
95
  /** Integer, and unique only within one user's library. */
@@ -131,9 +116,8 @@ export interface Artist {
131
116
  /** Legacy twin of {@link Artist.image_media_id}. Same value. */
132
117
  readonly image_fs_node_id: string | null;
133
118
  /**
134
- * Legacy compressed variant. Only the `artists:merge` rake helpers ever
135
- * adopt one; nothing generates new ones, and an upload purges the stale
136
- * companion, so on a modern row this is null.
119
+ * Legacy compressed variant. Nothing generates new ones, and an upload
120
+ * purges the stale companion, so on a modern row this is null.
137
121
  */
138
122
  readonly compressed_image_media_id: string | null;
139
123
  /** Legacy twin of {@link Artist.compressed_image_media_id}. */
@@ -165,21 +149,18 @@ export interface Artist {
165
149
  * When the Deezer picture set was last WRITTEN - which is not the same as
166
150
  * when it was last checked.
167
151
  *
168
- * `ArtistPicturesFetcher` stamps this only on a successful answer (a hit, or
169
- * a genuine "not on Deezer" miss). A failure - and a quota refusal is a
170
- * failure that arrives as `HTTP 200` with an `{"error": ...}` body - stamps
171
- * NOTHING and only sets a 30-minute in-cache guard, precisely so an
172
- * over-quota sweep cannot mark a whole library as "fetched, no picture" for
173
- * three days. That is the shape of the bug this behaviour exists to prevent,
174
- * so do not read a stale timestamp as "Deezer has nothing for this artist".
152
+ * It is stamped only on a successful answer (a hit, or a genuine "not on
153
+ * Deezer" miss). A failure - a Deezer quota refusal included - stamps NOTHING
154
+ * and only pauses retries for 30 minutes, precisely so an over-quota sweep
155
+ * cannot mark a whole library as "fetched, no picture" for three days. So do
156
+ * not read a stale timestamp as "Deezer has nothing for this artist".
175
157
  */
176
158
  readonly pictures_fetched_at: Timestamp | null;
177
159
  /**
178
160
  * When Last.fm's biography was last fetched, successfully OR NOT.
179
161
  *
180
- * `ArtistResolver#populate!` rescues every transport failure and then stamps
181
- * all three `*_fetched_at` columns anyway, so the freshness gate stops
182
- * hammering an upstream that is down. The row therefore looks populated
162
+ * A failed fetch still stamps all three `*_fetched_at` fields, so an
163
+ * upstream that is down is not hammered. The row therefore looks populated
183
164
  * while `bio_html` is still null. A null `bio_html` next to a recent
184
165
  * `bio_fetched_at` means "we tried and got nothing", not "never tried".
185
166
  */
@@ -187,25 +168,21 @@ export interface Artist {
187
168
  /** Same semantics as {@link Artist.bio_fetched_at}, for the similar list. */
188
169
  readonly similar_fetched_at: Timestamp | null;
189
170
  /**
190
- * Number of `song_artists` join rows, counting every credit (primary,
191
- * featured and with), not distinct songs.
171
+ * Number of credits (primary, featured and with), not distinct songs.
192
172
  *
193
- * Computed per response, never stored. On `GET /artists` it comes from one
194
- * grouped `COUNT` for the whole page; everywhere else the blueprint runs its
195
- * own `COUNT(*)`. Always present.
173
+ * Computed per response, never stored. Always present.
196
174
  */
197
175
  readonly songs_count: number;
198
176
  /**
199
177
  * Artwork of one of this artist's own lead songs, for a card that has no
200
- * picture at all. A media id, resolvable through `oms.media` (the canonical
201
- * `/media/:id/data`); `/fs_nodes/:id/data` is the alias kept for the web
202
- * frontend and reaches the same bytes.
203
- *
204
- * NON-NULL ONLY ON `GET /artists` AND `GET /artists/:id`, where the
205
- * controller precomputes the whole page with a single `DISTINCT ON` query.
206
- * `PATCH` and both uploads render the blueprint standalone and answer null
207
- * here even for an artist that has one - so a card redrawn from an upload
208
- * response loses its fallback. Keep the value you already had.
178
+ * picture at all. A media id, resolvable through `oms.media`
179
+ * (`/media/:id/data`); `/fs_nodes/:id/data` is a legacy alias that reaches
180
+ * the same bytes.
181
+ *
182
+ * NON-NULL ONLY ON `GET /artists` AND `GET /artists/:id`. `PATCH` and both
183
+ * uploads answer null here even for an artist that has one - so a card
184
+ * redrawn from an upload response loses its fallback. Keep the value you
185
+ * already had.
209
186
  */
210
187
  readonly fallback_artwork_media_id: string | null;
211
188
  /** Legacy twin of {@link Artist.fallback_artwork_media_id}. */
@@ -214,28 +191,19 @@ export interface Artist {
214
191
  /** One entry of Last.fm's similar-artists list. */
215
192
  export interface ArtistSimilarEntry {
216
193
  readonly name: string;
217
- /**
218
- * Last.fm's similarity score, `0..1`. A NUMBER: the resolver runs `to_f`
219
- * before storing it. The web frontend types the whole `similar` field as an
220
- * object with an `artists` array, which is the DATABASE column's shape and
221
- * not the payload's - the blueprint flattens it to this array.
222
- */
194
+ /** Last.fm's similarity score, `0..1`. A number. */
223
195
  readonly match: number | null;
224
196
  readonly mbid: string | null;
225
197
  }
226
198
  /**
227
- * The `:extended` view: everything in {@link Artist} plus three fields.
228
- *
229
- * Blueprinter views INHERIT the base fields, so this is a superset and never a
230
- * subset. Returned by `GET /artists/:id`, `PATCH /artists/:id` and both upload
231
- * endpoints. `oms-music/docs/api-music.md` describes `:compact` and `:card` as
232
- * narrow subsets; they are not, for the same inheritance reason, and
233
- * `docs/API.md` says so correctly.
199
+ * The extended artist: everything in {@link Artist} plus three fields, a
200
+ * superset and never a subset. Returned by `GET /artists/:id`,
201
+ * `PATCH /artists/:id` and both upload endpoints.
234
202
  */
235
203
  export interface ArtistExtended extends Artist {
236
204
  /**
237
205
  * Last.fm biography summary. Contains HTML (and a trailing "Read more"
238
- * anchor); sanitised on the `/artist_metadata` shim but NOT here, so treat it
206
+ * anchor); sanitised on `/artist_metadata/:name` but NOT here, so treat it
239
207
  * as untrusted markup and sanitise before injecting it.
240
208
  */
241
209
  readonly bio_html: string | null;
@@ -243,15 +211,15 @@ export interface ArtistExtended extends Artist {
243
211
  * Wikipedia/Wikimedia photos of the artist.
244
212
  *
245
213
  * Writable through {@link MusicArtistsNamespace.update}, and OVERWRITTEN
246
- * without warning by the next metadata refresh: `ArtistResolver#populate!`
247
- * always assigns `gallery_image_urls` from `Artists::GalleryFetcher`, so a
248
- * hand-curated list survives only until the artist's gallery TTL (30 days,
249
- * jittered by up to 7) expires and someone opens the artist page. There is
214
+ * without warning by the next metadata refresh, which always replaces it
215
+ * with what Wikimedia returns. A hand-curated list survives only until the
216
+ * artist's gallery TTL (30 days, jittered by up to 7) expires and someone
217
+ * opens the artist page. There is
250
218
  * no "pinned" flag. If a client offers gallery editing, it has to be
251
219
  * prepared to re-apply it.
252
220
  */
253
221
  readonly gallery_image_urls: string[];
254
- /** Flattened from the stored `similar_json`. Empty array, never null. */
222
+ /** Empty array, never null. */
255
223
  readonly similar: ArtistSimilarEntry[];
256
224
  }
257
225
  /**
@@ -259,9 +227,9 @@ export interface ArtistExtended extends Artist {
259
227
  *
260
228
  * The server's allowlist for this index is exactly `id`, `name`, `slug`,
261
229
  * `canonical_name`, `created_at` and `updated_at`. Any other key inside
262
- * `search` / `exact_search` is a **400**, not a silently ignored filter -
263
- * `CrudActions#reject_unknown_filter_keys!` fails closed on purpose, because
264
- * the alternative (dropping the key) answers with the UNFILTERED table.
230
+ * `search` / `exact_search` is a **400**, not a silently ignored filter: the
231
+ * allowlist fails closed on purpose, because the alternative (dropping the
232
+ * key) would answer with the UNFILTERED list.
265
233
  */
266
234
  export declare const ARTIST_FILTER_COLUMNS: readonly ["id", "name", "slug", "canonical_name", "created_at"];
267
235
  export interface ListArtistsParams extends ListParams<(typeof ARTIST_FILTER_COLUMNS)[number]> {
@@ -269,14 +237,13 @@ export interface ListArtistsParams extends ListParams<(typeof ARTIST_FILTER_COLU
269
237
  * Substring match on the display name - `search[name]`. This is the roster
270
238
  * search box.
271
239
  *
272
- * `QuerySearcher#string_search` slugifies BOTH sides before comparing:
273
- * lowercase, accents transliterated, every run of anything outside
274
- * `[a-z0-9-]` replaced by a hyphen, then wrapped in `%`. So the query is
275
- * really `slug(name) LIKE %slug(input)%`, and three consequences follow:
276
- * `"Beyoncé"` finds `"Beyonce"` and the reverse; `"chico buarque"` and
277
- * `"chico-buarque"` are the SAME query; and punctuation is erased on both
278
- * sides, so `"P!nk"` matches `"P nk"` too. An accent this table's
279
- * `TRANSLATE` map does not list survives as a hyphen, which still matches.
240
+ * The server slugifies BOTH sides before comparing: lowercase, accents
241
+ * transliterated, every run of anything outside `[a-z0-9-]` replaced by a
242
+ * hyphen, then a substring match. Three consequences follow: `"Beyoncé"`
243
+ * finds `"Beyonce"` and the reverse; `"chico buarque"` and `"chico-buarque"`
244
+ * are the SAME query; and punctuation is erased on both sides, so `"P!nk"`
245
+ * matches `"P nk"` too. An accent the transliteration does not know survives
246
+ * as a hyphen, which still matches.
280
247
  */
281
248
  readonly name?: string;
282
249
  /** Exact slug. Cheaper and less surprising than a `name` search. */
@@ -294,12 +261,11 @@ export interface ListArtistsParams extends ListParams<(typeof ARTIST_FILTER_COLU
294
261
  * `"column:asc"` / `"column:desc"`.
295
262
  *
296
263
  * PASS ONE. The index has no default order, so paging without it is paging
297
- * an unordered relation: Postgres may hand back the same row on two pages
298
- * and never hand back another. `name:asc` and `created_at:desc` are what the
299
- * clients use.
264
+ * an unordered set: the server may hand back the same row on two pages
265
+ * and never hand back another. `name:asc` and `created_at:desc` are the
266
+ * usual choices.
300
267
  *
301
- * `QueryModifier#apply_ordering` checks the column against
302
- * `model.column_names` and SILENTLY IGNORES anything else, so
268
+ * A column the record does not have is SILENTLY IGNORED, so
303
269
  * `songs_count:desc` is not an error and is not an ordering either - that
304
270
  * field is computed per response and cannot be sorted on. Sort client-side,
305
271
  * or read {@link MusicArtistsNamespace.overview}, which ranks for you.
@@ -368,11 +334,8 @@ export interface ArtistNeglectedEntry {
368
334
  }
369
335
  /**
370
336
  * The editorial header of the Artists page: five aggregate queries answered as
371
- * one document.
372
- *
373
- * The artists inside are rendered with the blueprint's `:card` view, which
374
- * inherits the base fields, so each one is a whole {@link Artist} - including
375
- * `fallback_artwork_media_id`, which the controller precomputes here.
337
+ * one document. Each artist inside is a whole {@link Artist},
338
+ * `fallback_artwork_media_id` included.
376
339
  */
377
340
  export interface ArtistOverview {
378
341
  readonly stats: ArtistOverviewStats;
@@ -402,13 +365,13 @@ export interface ArtistOverview {
402
365
  /** In the library, nothing played in 90 days. Up to 12. */
403
366
  readonly neglected: ArtistNeglectedEntry[];
404
367
  }
405
- /** `ArtistImport::STATES`. */
368
+ /** Every state an artist import can be in. */
406
369
  export declare const ARTIST_IMPORT_STATES: readonly ["queued", "running", "complete", "failed"];
407
370
  /** State of a bulk artist import. */
408
371
  export type ArtistImportState = (typeof ARTIST_IMPORT_STATES)[number];
409
372
  /**
410
- * Cadence the three clients poll an in-flight import at, from
411
- * `oms-music/docs/API.md` section 14. There is no cable channel for imports.
373
+ * Sensible cadence to poll an in-flight import at. Nothing pushes import
374
+ * progress over the realtime stream.
412
375
  */
413
376
  export declare const ARTIST_IMPORT_POLL_INTERVAL_MS = 1500;
414
377
  /**
@@ -422,10 +385,8 @@ export declare function isArtistImportTerminal(state: ArtistImportState | string
422
385
  /**
423
386
  * One bulk import of an artist's Spotify catalogue into the library.
424
387
  *
425
- * The counters exist because the pre-refactor flow swallowed Spotify errors
426
- * inside the job and left the user watching a spinner. Read them together:
427
- * `processed_albums` of `total_albums` is progress, and
428
- * `queued + skipped + failed` is what became of the tracks.
388
+ * Read the counters together: `processed_albums` of `total_albums` is
389
+ * progress, and `queued + skipped + failed` is what became of the tracks.
429
390
  */
430
391
  export interface ArtistImport {
431
392
  readonly id: ArtistImportId;
@@ -440,7 +401,7 @@ export interface ArtistImport {
440
401
  readonly album_ids: string[];
441
402
  readonly state: ArtistImportState;
442
403
  readonly total_albums: number | null;
443
- /** Null until the job has expanded the albums into tracks. */
404
+ /** Null until the albums have been expanded into tracks. */
444
405
  readonly total_tracks: number | null;
445
406
  readonly processed_albums: number;
446
407
  /** Tracks handed to the song-import pipeline. */
@@ -462,11 +423,7 @@ export interface ArtistImportRosterMatch {
462
423
  readonly id: ArtistId;
463
424
  readonly name: string;
464
425
  readonly slug: string;
465
- /**
466
- * A real URL (`picture_medium`, then `external_image_url`), or null. An
467
- * earlier version put a raw storage node id in this field, which no client
468
- * could load as an image.
469
- */
426
+ /** A real URL (`picture_medium`, then `external_image_url`), or null. */
470
427
  readonly image_url: string | null;
471
428
  }
472
429
  /** A Spotify search hit offered by the import picker. */
@@ -535,9 +492,9 @@ export interface ListArtistImportsParams {
535
492
  *
536
493
  * ## Every route here needs a LINKED SPOTIFY IDENTITY, not a flag
537
494
  *
538
- * The gate is `Current.user.identities.find_by(provider: "spotify")`. Without
539
- * one the answer is `400 "Connect Spotify first."`, and when the stored refresh
540
- * token no longer works it is `400 "Spotify connection needs to be relinked."`
495
+ * Without one the answer is `400 "Connect Spotify first."`, and when the
496
+ * stored refresh token no longer works it is
497
+ * `400 "Spotify connection needs to be relinked."`
541
498
  * Both are plain JSON strings, both arrive as an {@link OmsApiError} with
542
499
  * status 400, and the only way to tell them apart is the message - so match on
543
500
  * it if the UI needs to distinguish "connect" from "reconnect".
@@ -546,11 +503,10 @@ export interface ListArtistImportsParams {
546
503
  *
547
504
  * ## These calls are SLOW and they are on the general ceiling
548
505
  *
549
- * `/artist_imports*` does not match the 60/min proxy bucket, so it sits under
550
- * the general authenticated ceiling (600/min). What bounds it in practice is
551
- * Spotify: {@link search} and {@link albums} both call out synchronously on the
552
- * request thread, and {@link albums} pages through an entire discography. The
553
- * app allows 60 seconds for each, and so does this namespace.
506
+ * `/artist_imports*` sits under the general authenticated ceiling (600/min).
507
+ * What bounds it in practice is Spotify: {@link search} and {@link albums}
508
+ * both call out synchronously, and {@link albums} pages through an entire
509
+ * discography. This namespace allows 60 seconds for each.
554
510
  */
555
511
  export declare class MusicArtistImportsNamespace extends Resource {
556
512
  /**
@@ -579,12 +535,9 @@ export declare class MusicArtistImportsNamespace extends Resource {
579
535
  *
580
536
  * The server wraps the array in `{ items: [...] }`; this returns the array.
581
537
  *
582
- * `spotifyArtistId` is validated here rather than sent empty, because the
583
- * controller uses `params.require` and a missing key raises
584
- * `ActionController::ParameterMissing`. That escapes the API's own error
585
- * convention - it is a framework 400 with a framework body rather than a
586
- * bare JSON string - and it trips `ErrorReporting`, which pages the owner on
587
- * Discord for what is really a client bug.
538
+ * `spotifyArtistId` is validated here rather than sent empty: a missing key
539
+ * answers a generic 400 whose body is not one of the API's bare JSON
540
+ * strings.
588
541
  *
589
542
  * @throws {OmsError} `invalid_request` when `spotifyArtistId` is blank.
590
543
  * @throws {OmsApiError} 400 `"Connect Spotify first."` with no linked
@@ -597,21 +550,21 @@ export declare class MusicArtistImportsNamespace extends Resource {
597
550
  /**
598
551
  * `POST /artist_imports` - queues every track of the chosen albums.
599
552
  *
600
- * Answers `201` immediately with a `queued` record; the work happens in
601
- * `ArtistImportJob`. Watch it with {@link list} at
553
+ * Answers `201` immediately with a `queued` record; the work happens in the
554
+ * background. Watch it with {@link list} at
602
555
  * {@link ARTIST_IMPORT_POLL_INTERVAL_MS} - THERE IS NO `GET
603
556
  * /artist_imports/:id`, so polling means re-reading the recent list and
604
557
  * finding your id in it.
605
558
  *
606
559
  * Not retried, and this one matters more than most: a replay does not
607
- * deduplicate, it creates a second `ArtistImport` row and runs the whole
608
- * catalogue through the pipeline again. Pass `retry: {}` only if you are
560
+ * deduplicate, it creates a second import and runs the whole catalogue
561
+ * through the pipeline again. Pass `retry: {}` only if you are
609
562
  * prepared to explain the duplicate.
610
563
  *
611
564
  * @throws {OmsError} `invalid_request` when `spotifyArtistId` is blank or
612
- * `albumIds` is empty. The first would raise `ParameterMissing` on the
613
- * server (see {@link albums}); the second is a clean
614
- * `400 "album_ids required"`, checked here only so the round trip is saved.
565
+ * `albumIds` is empty. The first would answer a generic 400 (see
566
+ * {@link albums}); the second is a clean `400 "album_ids required"`,
567
+ * checked here only so the round trip is saved.
615
568
  * @throws {OmsApiError} 400 for the two Spotify-identity messages.
616
569
  */
617
570
  create(input: CreateArtistImportInput, options?: RequestOptions): Promise<ArtistImport>;
@@ -624,32 +577,29 @@ export declare class MusicArtistImportsNamespace extends Resource {
624
577
  *
625
578
  * This is also the poll: filter for a `state` that
626
579
  * {@link isArtistImportTerminal} rejects to know whether anything is still
627
- * running. Nothing pushes import progress over the cable.
580
+ * running. Nothing pushes import progress over the realtime stream.
628
581
  */
629
582
  list(params?: ListArtistImportsParams, options?: RequestOptions): Promise<ArtistImport[]>;
630
583
  }
631
584
  /**
632
585
  * Primary key of an artist sync. An integer, like {@link ArtistId} and
633
586
  * {@link ArtistImportId}, and interchangeable with NEITHER: a sync is keyed on
634
- * a SPOTIFY artist id and never references a local `Artist` row at all. Three
587
+ * a SPOTIFY artist id and never references a local artist at all. Three
635
588
  * integer id spaces meet in this file and only the field name tells them apart.
636
589
  */
637
590
  export type ArtistSyncId = number;
638
591
  /**
639
592
  * A followed artist, as `/artist_syncs` renders it.
640
593
  *
641
- * ## This payload is hand-built, so the base fields you expect are MISSING
594
+ * ## The base fields you expect are MISSING
642
595
  *
643
- * There is no `ArtistSyncBlueprint`. `ArtistSyncsController#serialize` writes
644
- * the hash literally, which is why this is the one music record with no
645
- * `created_at` and no `updated_at` - the convention that every payload carries
646
- * them holds everywhere a Blueprinter view is involved and stops here. Do not
647
- * sort a list of these by `created_at` client-side; the server already returns
648
- * them newest-first and that ordering is the only one available.
596
+ * This is the one music record with no `created_at` and no `updated_at`. Do
597
+ * not sort a list of these by `created_at` client-side; the server already
598
+ * returns them newest-first and that ordering is the only one available.
649
599
  *
650
- * `known_album_ids` is likewise not exposed. The row stores the full array of
651
- * Spotify album ids (a `jsonb` column) and only its SIZE crosses the wire, so
652
- * the SDK cannot tell you WHICH albums are already known - only how many.
600
+ * The known album ids themselves are not exposed either: only their COUNT
601
+ * crosses the wire, so the SDK cannot tell you WHICH albums are already known
602
+ * - only how many.
653
603
  */
654
604
  export interface ArtistSync {
655
605
  readonly id: ArtistSyncId;
@@ -665,12 +615,11 @@ export interface ArtistSync {
665
615
  */
666
616
  readonly artist_name: string | null;
667
617
  /**
668
- * Whether `ArtistDailySyncDispatcherJob` will pick this row up (its scope is
669
- * `where(enabled: true)`).
618
+ * Whether the daily check will pick this row up.
670
619
  *
671
620
  * Always `true` on anything this SDK can produce: `create` sets it, and there
672
621
  * is no update route to turn it off. A `false` here can only have been
673
- * written by the console, and the only way a client can stop a sync is
622
+ * written by an administrator, and the only way a client can stop a sync is
674
623
  * {@link MusicArtistSyncsNamespace.delete}. Render it, do not offer a toggle.
675
624
  */
676
625
  readonly enabled: boolean;
@@ -678,21 +627,20 @@ export interface ArtistSync {
678
627
  * When the daily check last ran, ISO-8601, or `null` in the vanishingly
679
628
  * short window before `create` saves the row.
680
629
  *
681
- * A TOUCH, not a success marker. `ArtistSyncCheckJob` writes it on the happy
682
- * path AND in both of its rescue arms (dead refresh token, Spotify upstream
683
- * error), so a fresh timestamp proves the job ran, never that Spotify
684
- * answered. There is no field that records the last failure - it is a
685
- * `Rails.logger.warn` and nothing else - so a UI cannot honestly say "last
630
+ * A TOUCH, not a success marker. It is written whether the check succeeded
631
+ * or failed (dead refresh token, Spotify upstream error), so a fresh
632
+ * timestamp proves the check ran, never that Spotify answered. There is no
633
+ * field that records the last failure, so a UI cannot honestly say "last
686
634
  * checked, all good".
687
635
  */
688
636
  readonly last_checked_at: Timestamp | null;
689
637
  /**
690
- * How many Spotify album ids the snapshot holds. `Array(known_album_ids).size`.
638
+ * How many Spotify album ids the snapshot holds.
691
639
  *
692
640
  * This is the baseline the daily diff runs against, not a count of songs
693
- * imported. It GROWS and never shrinks, because the job stores the union
694
- * (`known | current_ids`) precisely so an album Spotify hides and later shows
695
- * again cannot re-import as new.
641
+ * imported. It GROWS and never shrinks, because each check stores the union
642
+ * of what it knew and what it saw, precisely so an album Spotify hides and
643
+ * later shows again cannot re-import as new.
696
644
  */
697
645
  readonly known_album_count: number;
698
646
  }
@@ -725,7 +673,7 @@ export interface CreateArtistSyncInput {
725
673
  * | --- | --- | --- |
726
674
  * | what it does | imports the albums you CHOSE, now | watches for albums released LATER |
727
675
  * | back catalogue | yes, that is the point | never |
728
- * | when work happens | immediately, `ArtistImportJob` | daily, 05:00 server time |
676
+ * | when work happens | immediately | daily, 05:00 server time |
729
677
  * | you pick albums | yes, `albumIds` is required | no, there is no album argument |
730
678
  * | repeating the call | duplicates the whole import | idempotent, one row per artist |
731
679
  *
@@ -737,40 +685,35 @@ export interface CreateArtistSyncInput {
737
685
  *
738
686
  * ## What the sync produces is an ArtistImport, so watch it there
739
687
  *
740
- * `ArtistDailySyncDispatcherJob` runs at 05:00, walks every enabled row and
741
- * schedules each check at a random offset inside a 30-minute window (the same
742
- * anti-stampede discipline as the Spotify sync). Each `ArtistSyncCheckJob`
743
- * re-walks the artist's catalogue, diffs it against the snapshot and, when
744
- * something is new, creates an ordinary `ArtistImport` holding ONLY the new
745
- * album ids - `last_message` starts as `"Novo lançamento detectado pelo sync
746
- * diário…"`, which is the marker that tells an automatic import from one a
747
- * person asked for.
688
+ * The daily check runs at 05:00 server time, at a random offset inside a
689
+ * 30-minute window. It re-walks the artist's catalogue, diffs it against the
690
+ * snapshot and, when something is new, creates an ordinary
691
+ * {@link ArtistImport} holding ONLY the new album ids - `last_message` starts
692
+ * as `"Novo lançamento detectado pelo sync diário…"`, which is the marker that
693
+ * tells an automatic import from one a person asked for.
748
694
  *
749
695
  * So there is no progress on this namespace and nothing to poll here. Progress
750
696
  * lives in {@link MusicArtistImportsNamespace.list}, mixed in with manual
751
- * imports. Nothing pushes either over the cable.
697
+ * imports. Nothing pushes either over the realtime stream.
752
698
  *
753
699
  * ## A LINKED SPOTIFY IDENTITY is required to write, not to read
754
700
  *
755
- * {@link create} is gated on `Current.user.identities.find_by(provider:
756
- * "spotify")` and answers `400 "Connect Spotify first."` without one. {@link
701
+ * {@link create} answers `400 "Connect Spotify first."` without one. {@link
757
702
  * list} and {@link delete} are not gated, which matters after an unlink: the
758
- * rows survive, they still list, they can still be deleted, and the daily job
759
- * quietly skips them (`return unless identity`) without recording that it did.
703
+ * rows survive, they still list, they can still be deleted, and the daily
704
+ * check quietly skips them without recording that it did.
760
705
  *
761
706
  * ## Ceilings and cost
762
707
  *
763
708
  * Every route here is on the GENERAL authenticated ceiling of 600/min.
764
- * `/artist_syncs` does not match the 60/min `\A/(lyrics|artists/|...)` bucket -
765
- * the underscore breaks the `artists/` prefix - and it is not in
766
- * `EXPENSIVE_TOOL_PATHS` either, even though {@link create} is by far the
767
- * slowest call in this file.
709
+ * `/artist_syncs` is not in the 60/min `/artists/*` bucket and has no budget
710
+ * of its own, even though {@link create} is by far the slowest call in this
711
+ * file.
768
712
  *
769
713
  * ## An OAuth access token cannot reach any of this
770
714
  *
771
- * `ArtistSyncsController` declares no `oauth_scope`, and the gate denies by
772
- * default, so a Doorkeeper token gets `403 {"error":"insufficient_scope"}`
773
- * here as it does everywhere else in `music`. Session token or cookie only.
715
+ * An OAuth access token gets `403 {"error":"insufficient_scope"}` here as it
716
+ * does everywhere else in `music`. Session token or cookie only.
774
717
  */
775
718
  export declare class MusicArtistSyncsNamespace extends Resource {
776
719
  /**
@@ -786,8 +729,8 @@ export declare class MusicArtistSyncsNamespace extends Resource {
786
729
  * `/artist_imports` uses and the opposite of the bare arrays the rest of the
787
730
  * API returns - and is unwrapped here.
788
731
  *
789
- * Safe to retry, and scoped to the caller: `ArtistSync.viewable_by` is
790
- * `where(user:)`, so there is no way to read anyone else's follows.
732
+ * Safe to retry, and scoped to the caller: there is no way to read anyone
733
+ * else's follows.
791
734
  */
792
735
  list(options?: RequestOptions): Promise<ArtistSync[]>;
793
736
  /**
@@ -800,35 +743,31 @@ export declare class MusicArtistSyncsNamespace extends Resource {
800
743
  *
801
744
  * ## Idempotent, unlike its neighbour
802
745
  *
803
- * `find_or_initialize_by(user:, spotify_artist_id:)` behind a unique index,
804
- * so calling this twice for one artist updates a single row instead of
805
- * creating a second - it re-enables the sync, overwrites the name if one was
806
- * sent, and touches `last_checked_at`. It does NOT re-snapshot: the Spotify
807
- * walk is guarded by `if sync.known_album_ids.blank?`, so a second create is
808
- * cheap and, more importantly, cannot silently widen the baseline and swallow
809
- * releases that arrived in between.
746
+ * One row per artist per account, so calling this twice for one artist
747
+ * updates a single row instead of creating a second - it re-enables the
748
+ * sync, overwrites the name if one was sent, and touches `last_checked_at`.
749
+ * It does NOT re-snapshot when a snapshot already exists, so a second create
750
+ * is cheap and, more importantly, cannot silently widen the baseline and
751
+ * swallow releases that arrived in between.
810
752
  *
811
753
  * That is why this one opts INTO retries (`retry: {}`) while
812
754
  * {@link MusicArtistImportsNamespace.create} refuses them: a replayed follow
813
755
  * converges on the same row, a replayed import runs a whole discography
814
756
  * twice. Pass `retry: false` to opt back out.
815
757
  *
816
- * ## It is slow, because it walks the discography on the request thread
758
+ * ## It is slow, because it walks the discography before answering
817
759
  *
818
- * `SpotifyClient#each_artist_album` pages through every album before the
819
- * response is written, exactly like
760
+ * Every album is paged through before the response is written, exactly like
820
761
  * {@link MusicArtistImportsNamespace.albums}. Sixty seconds by default. The
821
762
  * one case that is instant is a re-create over a row that already has a
822
763
  * snapshot.
823
764
  *
824
765
  * An artist with a genuinely EMPTY catalogue never stops paying that cost:
825
- * `[]` is `blank?`, so every create for it walks Spotify again.
766
+ * an empty snapshot counts as no snapshot, so every create for it walks
767
+ * Spotify again.
826
768
  *
827
769
  * @throws {OmsError} `invalid_request` when `spotifyArtistId` is blank. The
828
- * server would answer `400` for it too - `params.require` raises
829
- * `ParameterMissing` and this controller, unlike the import one, RESCUES it
830
- * into a normal bad request, so it does not page the owner - but the round
831
- * trip buys nothing.
770
+ * server would answer `400` for it too, but the round trip buys nothing.
832
771
  * @throws {OmsApiError} 400 `"Connect Spotify first."` with no linked
833
772
  * identity, `"Spotify connection needs to be relinked."` on a dead refresh
834
773
  * token, or `"Spotify upstream error: ..."` (truncated to 200 characters).
@@ -856,8 +795,8 @@ export declare class MusicArtistSyncsNamespace extends Resource {
856
795
  * would find nothing and report `404` for a row it had just removed.
857
796
  *
858
797
  * @throws {OmsApiError} 404 `"Artist sync not found"` for an id that is not
859
- * yours or no longer exists. A non-numeric id lands here too, cast to `0`
860
- * by the column type rather than rejected.
798
+ * yours or no longer exists. A non-numeric id lands here too rather than
799
+ * being rejected.
861
800
  */
862
801
  delete(id: ArtistSyncId, options?: RequestOptions): Promise<void>;
863
802
  }
@@ -871,14 +810,12 @@ export declare class MusicArtistsNamespace extends Resource {
871
810
  /**
872
811
  * `GET /artists` - the roster.
873
812
  *
874
- * On the GENERAL ceiling (600/min), not the 60/min artist bucket: the
875
- * throttle's pattern is `/artists/` with a trailing slash and the index path
876
- * is normalised to `/artists`. It is the only route in this namespace with
877
- * that luxury.
813
+ * On the GENERAL ceiling (600/min), not the 60/min artist bucket. It is the
814
+ * only route in this namespace with that luxury.
878
815
  *
879
- * Rows are the blueprint's default view: no `bio_html`, no
880
- * `gallery_image_urls`, no `similar`. `songs_count` and
881
- * `fallback_artwork_media_id` ARE filled in here.
816
+ * Rows are the base {@link Artist}: no `bio_html`, no `gallery_image_urls`,
817
+ * no `similar`. `songs_count` and `fallback_artwork_media_id` ARE filled in
818
+ * here.
882
819
  *
883
820
  * ALWAYS PASS `order`. See {@link ListArtistsParams.order} - the relation
884
821
  * has no order of its own, and paging an unordered relation loses rows.
@@ -902,17 +839,16 @@ export declare class MusicArtistsNamespace extends Resource {
902
839
  * Three lookups in order: a purely numeric segment is an id, otherwise a
903
840
  * slug, otherwise a canonical name. So `get(42)`, `get("chico-buarque")` and
904
841
  * `get("Chico Buarque")` all work, and an artist whose slug is all digits
905
- * would be unreachable by slug - a case the backend does not handle and
842
+ * would be unreachable by slug - a case the server does not handle and
906
843
  * nothing in practice produces.
907
844
  *
908
845
  * ## This call can be slow the FIRST time, and it is not the network
909
846
  *
910
- * The controller runs `ArtistResolver.refresh_if_stale`, which is
911
- * stale-while-revalidate with one exception. A row that has never been
912
- * populated is filled in INLINE, on the request thread: a MusicBrainz search,
913
- * two Last.fm calls and a Wikimedia gallery fetch before the response is
914
- * written. A row that merely went stale is served immediately and refreshed
915
- * by a background job, deduplicated to one job per artist per ten minutes.
847
+ * Metadata is refreshed stale-while-revalidate, with one exception. A row
848
+ * that has never been populated is filled in INLINE, before the response: a
849
+ * MusicBrainz search, two Last.fm calls and a Wikimedia gallery fetch. A row
850
+ * that merely went stale is served immediately and refreshed in the
851
+ * background, at most once per artist per ten minutes.
916
852
  *
917
853
  * So budget a generous `timeoutMs` for a cold artist, and do not read a
918
854
  * slow first load as a broken server. What you must NOT do is retry it fast:
@@ -920,7 +856,7 @@ export declare class MusicArtistsNamespace extends Resource {
920
856
  *
921
857
  * ## What null metadata means
922
858
  *
923
- * If every external call fails, the resolver still stamps `bio_fetched_at`,
859
+ * If every external call fails, the server still stamps `bio_fetched_at`,
924
860
  * `similar_fetched_at` and the gallery timestamp, so the upstream is not
925
861
  * hammered on every page view. The response is a normal `200` with
926
862
  * `bio_html: null` and `similar: []`, and it will stay that way until the TTL
@@ -940,8 +876,8 @@ export declare class MusicArtistsNamespace extends Resource {
940
876
  /**
941
877
  * `GET /artists/overview` - the whole Artists page header in one request.
942
878
  *
943
- * **Cached server-side for one hour, per user**, under
944
- * `artists_overview:v2:<user id>`. Two consequences worth designing around:
879
+ * **Cached server-side for one hour, per user.** Two consequences worth
880
+ * designing around:
945
881
  * polling it is pointless, and an artist you just renamed or gave a new
946
882
  * picture keeps its old card here for up to an hour while
947
883
  * {@link MusicArtistsNamespace.get} already shows the new one. A client that
@@ -956,12 +892,10 @@ export declare class MusicArtistsNamespace extends Resource {
956
892
  *
957
893
  * ## The body is FLAT
958
894
  *
959
- * `{ name, gallery_image_urls }` at the top level. The web frontend sends
960
- * `{ artist: { ... } }`, which `params.permit(:name, gallery_image_urls: [])`
961
- * permits nothing out of: the update then assigns an empty hash, saves
962
- * successfully and answers `200` with the record UNCHANGED. A nested body is
963
- * not an error, it is a silent no-op, and it is the reason this method takes
964
- * the fields rather than a body.
895
+ * `{ name, gallery_image_urls }` at the top level. A body nested under an
896
+ * `artist` key is not an error, it is a silent no-op: the server answers
897
+ * `200` with the record UNCHANGED. That is the reason this method takes the
898
+ * fields rather than a body.
965
899
  *
966
900
  * Fields outside those two are dropped in silence as well (the fail-closed
967
901
  * 400 applies to filter buckets, not to update params), so read the returned
@@ -969,8 +903,8 @@ export declare class MusicArtistsNamespace extends Resource {
969
903
  *
970
904
  * ## Addressed by NUMERIC ID ONLY
971
905
  *
972
- * Unlike {@link get}, this goes through the generic CRUD lookup
973
- * (`find_by(id:)`), so a slug 404s - and with a different message,
906
+ * Unlike {@link get}, this is addressed by id only, so a slug 404s - and
907
+ * with a different message,
974
908
  * `"Resource not found"` rather than `"Artist not found"`. Resolve the slug
975
909
  * with {@link get} first.
976
910
  *
@@ -988,8 +922,8 @@ export declare class MusicArtistsNamespace extends Resource {
988
922
  *
989
923
  * ## A refusal arrives as 401, not 400
990
924
  *
991
- * `Artist#destroyable_by?` is `owner && song_artists.empty?`, and the CRUD
992
- * action turns a false there into
925
+ * An artist can be destroyed only by its owner and only when it has no
926
+ * credits left; anything else is
993
927
  * `401 "You are not authorized to destroy this resource"`. So "this artist
994
928
  * still has songs" and "this artist is not yours" are the SAME response, and
995
929
  * the SDK surfaces both as an {@link OmsAuthError}.
@@ -1025,8 +959,8 @@ export declare class MusicArtistsNamespace extends Resource {
1025
959
  /**
1026
960
  * `POST /artists/:id/upload_banner` - multipart, field name **`banner`**.
1027
961
  *
1028
- * The field is `banner`, NOT `image`. The web frontend sends `image` to this
1029
- * route and gets `400 "banner required"` for it; do not copy that.
962
+ * The field is `banner`, NOT `image`; sending `image` here answers
963
+ * `400 "banner required"`.
1030
964
  *
1031
965
  * ## Shared rules for both uploads
1032
966
  *
@@ -1046,13 +980,13 @@ export declare class MusicArtistsNamespace extends Resource {
1046
980
  * - Uploading purges the legacy `compressed_*` companion, because nothing
1047
981
  * regenerates it for artists and a leftover copy would keep rendering the
1048
982
  * OLD picture. Expect `compressed_banner_media_id` to be null afterwards.
1049
- * - The response is the `:extended` view rendered STANDALONE, so
1050
- * `fallback_artwork_media_id` comes back null even for an artist that has
1051
- * one. Merge the response into what you already had; do not replace it.
983
+ * - The response is an {@link ArtistExtended} whose
984
+ * `fallback_artwork_media_id` is null even for an artist that has one.
985
+ * Merge the response into what you already had; do not replace it.
1052
986
  * - Addressed like {@link get}, not like {@link update}: id, slug or
1053
987
  * canonical name all resolve, and the 404 message is `"Artist not found"`.
1054
988
  *
1055
- * ## The three clients
989
+ * ## The three runtimes
1056
990
  *
1057
991
  * React Native passes the picked `{ uri, name, type }` straight through; the
1058
992
  * transport appends it verbatim, which is the only thing that works there.