@omelhorsite/sdk 0.2.0 → 0.3.0

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 (44) hide show
  1. package/dist/index.js +4939 -552
  2. package/dist/types/client.d.ts +60 -3
  3. package/dist/types/http.d.ts +444 -19
  4. package/dist/types/index.d.ts +4 -1
  5. package/dist/types/resources/account.d.ts +66 -3
  6. package/dist/types/resources/admin.d.ts +1837 -0
  7. package/dist/types/resources/auth/index.d.ts +39 -0
  8. package/dist/types/resources/auth/passkeys.d.ts +652 -0
  9. package/dist/types/resources/auth/sessions.d.ts +847 -0
  10. package/dist/types/resources/chests.d.ts +54 -3
  11. package/dist/types/resources/content.d.ts +2970 -0
  12. package/dist/types/resources/dynamicQrs.d.ts +39 -3
  13. package/dist/types/resources/forms.d.ts +176 -35
  14. package/dist/types/resources/index.d.ts +19 -8
  15. package/dist/types/resources/ipLookup.d.ts +20 -4
  16. package/dist/types/resources/jobs.d.ts +62 -21
  17. package/dist/types/resources/library.d.ts +1435 -0
  18. package/dist/types/resources/linkTrees.d.ts +142 -30
  19. package/dist/types/resources/media.d.ts +351 -0
  20. package/dist/types/resources/movies.d.ts +1186 -0
  21. package/dist/types/resources/music/artists.d.ts +1066 -0
  22. package/dist/types/resources/music/imports.d.ts +940 -0
  23. package/dist/types/resources/music/index.d.ts +61 -0
  24. package/dist/types/resources/music/playlists.d.ts +1026 -0
  25. package/dist/types/resources/music/social.d.ts +1132 -0
  26. package/dist/types/resources/music/songs.d.ts +1183 -0
  27. package/dist/types/resources/notepads.d.ts +4 -1
  28. package/dist/types/resources/quotas.d.ts +7 -1
  29. package/dist/types/resources/realtime.d.ts +855 -0
  30. package/dist/types/resources/shortLinks.d.ts +45 -4
  31. package/dist/types/resources/social.d.ts +1330 -0
  32. package/dist/types/resources/storage/upload.d.ts +158 -11
  33. package/dist/types/resources/storage.d.ts +88 -22
  34. package/dist/types/resources/tickets.d.ts +82 -3
  35. package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
  36. package/dist/types/resources/tools/captions.d.ts +448 -21
  37. package/dist/types/resources/tools/downloader.d.ts +21 -0
  38. package/dist/types/resources/tools/index.d.ts +57 -15
  39. package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
  40. package/dist/types/resources/tools/transcription.d.ts +35 -13
  41. package/dist/types/resources/tools/upscale.d.ts +23 -3
  42. package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
  43. package/dist/types/types.d.ts +249 -17
  44. package/package.json +2 -1
@@ -0,0 +1,1183 @@
1
+ /**
2
+ * The `music.songs` namespace: the library, its lyrics and its stems.
3
+ *
4
+ * Everything under here is strictly single-tenant. `Song.viewable_by` is
5
+ * `where(user: current_user)` with no grant, no share and no public branch, so
6
+ * there is no such thing as reading somebody else's track by id: a foreign id
7
+ * is a `404 "Resource not found"`, never a `403`. The same is true of
8
+ * `liked_songs`, of `/artist_metadata/:name` (which resolves against YOUR
9
+ * artist roster, not a global one) and of `/songs/artist_pictures`. Cross-user
10
+ * song payloads exist in this API, but they arrive through jams and social
11
+ * feeds, not through this namespace.
12
+ *
13
+ * ## Three things to know before the first call
14
+ *
15
+ * **Song ids are integers.** So are `liked_songs` ids. The ids sitting NEXT to
16
+ * them are not: `user_id` is a string uuid, every `*_media_id` is a string, and
17
+ * a vocal separation's own `id` is a string. On ActionCable the same song ids
18
+ * come back as strings. Compare with `===` against the right type or nothing
19
+ * will ever match.
20
+ *
21
+ * **Media never travels inline.** A song carries ids of storage nodes, not
22
+ * bytes and not URLs. Resolve one with `oms.media` (`GET /media/:id/data_url`
23
+ * hands back a short-lived signed URL, which is what you give a player). The
24
+ * `oms.storage` route `GET /fs_nodes/:id/data_url` reaches the same bytes and
25
+ * the web frontend still uses it, but the backend calls it a temporary alias
26
+ * for that frontend, so new code should ask `oms.media`. Prefer the compressed
27
+ * twin either way: `compressed_audio_media_id`
28
+ * before `audio_media_id`, `compressed_artwork_media_id` before
29
+ * `artwork_media_id`. The originals are lossless files on a Raspberry Pi and an
30
+ * album grid that reaches for them takes seconds per tile.
31
+ *
32
+ * **Every key that names a media node is sent TWICE.** `ApplicationBlueprint.
33
+ * media_id_fields` emits `<name>_media_id` and `<name>_fs_node_id` with the
34
+ * identical value, the second being a temporary shim for the old web frontend.
35
+ * Read the `_media_id` spelling; the twin is declared here only so that code
36
+ * ported from the web app keeps type-checking, and it will be removed
37
+ * server-side without a major version of this SDK.
38
+ *
39
+ * ## An OAuth token cannot reach any of this
40
+ *
41
+ * None of the five controllers behind this namespace (`songs`, `liked_songs`,
42
+ * `lyrics`, `artist_metadata`, `music/external_search`) declares an
43
+ * `oauth_scope`, and `enforce_oauth_scope!` denies by omission: an OAuth access
44
+ * token gets `403 {"error":"insufficient_scope"}` on EVERY method here, before
45
+ * the action runs. Music needs a session cookie or a personal token. That is a
46
+ * server-side gap rather than a design decision, so it may open later; until it
47
+ * does, an {@link OmsAuthError} with status 403 and that body means "wrong kind
48
+ * of credential", not "wrong user".
49
+ *
50
+ * ## The 60-per-minute bucket nobody expects to share
51
+ *
52
+ * rack-attack throttles `/lyrics*`, `/artists/*`, `/artist_metadata/*` and
53
+ * `/music_radios/*` through ONE rule - `external_proxy/by_session`, 60 requests
54
+ * per minute keyed by the literal `Authorization` header - because all four
55
+ * proxy to somebody else's servers (lrclib, Genius, Last.fm, Wikipedia, Deezer)
56
+ * and getting our IP banned there breaks the feature for everyone. One counter,
57
+ * four route families: fetching lyrics for sixty tracks in a minute leaves zero
58
+ * budget for artist metadata, and the 429 lands on whichever call is unlucky
59
+ * enough to be the sixty-first. Pace a backfill at roughly one request per
60
+ * second and it will never be seen.
61
+ *
62
+ * The rest of the namespace lives under the general ceiling (600/min
63
+ * authenticated), with two exceptions that have their own budgets and their own
64
+ * doc comments: {@link MusicSongsNamespace.startSeparation} (20/min, shared with
65
+ * every other expensive tool) and {@link MusicSongsNamespace.externalSearch}
66
+ * (30/min, and it does NOT answer 429 - read that method).
67
+ */
68
+ import { Resource } from "../../http";
69
+ import { type BaseRecord, type FileInput, type FileOutput, type Id, type NativeFile, type PageParams, type Paginated, type QueryValue, type RequestOptions, type Timestamp } from "../../types";
70
+ import type { VocalSeparation } from "../tools/vocalSeparation";
71
+ /**
72
+ * Primary key of a song. An **integer**, unlike most ids in this API.
73
+ *
74
+ * `songs`, `artists`, `playlists`, `playlist_songs`, `liked_songs`,
75
+ * `play_events`, `jams`, `song_imports` and `artist_imports` kept integer
76
+ * primary keys; `users`, `sessions`, `fs_nodes`, `playback_states` and
77
+ * `vocal_separations` are string uuids. Both spellings are accepted wherever a
78
+ * song id is taken as an argument, because a caller that read the id off a
79
+ * cable message is holding a string, but the JSON these methods RETURN always
80
+ * carries a number.
81
+ */
82
+ export type SongId = number | string;
83
+ /** How an artist is credited on a track. */
84
+ export type SongArtistRole = "primary" | "featured" | "with";
85
+ /**
86
+ * One row of `song_artists`, nested under {@link Song.artists}.
87
+ *
88
+ * `id` is the id of the JOIN row, not of the artist - the artist's own id is
89
+ * `artist_id`. Getting those two the wrong way round is how a link to an artist
90
+ * page ends up pointing at a random other artist, so prefer `slug` for routing
91
+ * and keep `artist_id` for lookups.
92
+ *
93
+ * The picture fields are a deliberate denormalisation: almost no artist has an
94
+ * uploaded image, the cached Deezer picture is what actually renders in a song
95
+ * row, and the alternative was a second request per row. Only `picture` and
96
+ * `picture_medium` travel here; the full set (`picture_small`, `picture_big`,
97
+ * `picture_xl`) lives on the artist record.
98
+ */
99
+ export interface SongArtistCredit extends Omit<BaseRecord, "id"> {
100
+ /** Id of the `song_artists` join row. NOT the artist id. */
101
+ readonly id: number;
102
+ readonly song_id: number;
103
+ readonly artist_id: number;
104
+ /** Ordering within the credits. Sort by it; the server does not. */
105
+ readonly position: number;
106
+ readonly role: SongArtistRole;
107
+ /** `null` only if the join row outlived its artist, which the schema prevents. */
108
+ readonly name: string | null;
109
+ readonly slug: string | null;
110
+ /** Artist avatar the user uploaded, as a storage node id. Usually `null`. */
111
+ readonly image_media_id: Id | null;
112
+ readonly compressed_image_media_id: Id | null;
113
+ /** Cached Deezer picture URLs, absolute and public. Usually the ones that render. */
114
+ readonly picture: string | null;
115
+ readonly picture_medium: string | null;
116
+ /** Stamped by the Last.fm / MusicBrainz backfill. */
117
+ readonly external_image_url: string | null;
118
+ /** @deprecated Legacy twin of `image_media_id`. Same value. Read the `_media_id` spelling. */
119
+ readonly image_fs_node_id?: Id | null;
120
+ /** @deprecated Legacy twin of `compressed_image_media_id`. Same value. */
121
+ readonly compressed_image_fs_node_id?: Id | null;
122
+ }
123
+ /** Where a track came from. */
124
+ export type SongSourceKind = "upload" | "yt_dlp" | "spotify_sync";
125
+ /**
126
+ * A track in the library.
127
+ *
128
+ * `SongBlueprint` declares no extra fields in its `:extended` view, so the
129
+ * shape below is what every route in this namespace answers with - index,
130
+ * show, update and import alike. There is no narrower view to guard against.
131
+ *
132
+ * Two fields the web frontend's own type declares do NOT exist on the wire:
133
+ * `track_number` and `disc_number` (they are optional there and always
134
+ * `undefined`), and neither does the legacy `artist` string column, which was
135
+ * dropped when `song_artists` landed. {@link Song.artists} is the only artist
136
+ * source.
137
+ */
138
+ export interface Song extends Omit<BaseRecord, "id"> {
139
+ /** Integer primary key. See {@link SongId}. */
140
+ readonly id: number;
141
+ readonly title: string;
142
+ /** `null` is a real value: the "no album" bucket, reachable with `album: null`. */
143
+ readonly album: string | null;
144
+ /** Whole seconds. */
145
+ readonly duration: number;
146
+ /** Legacy library ordering. Nullable and largely unused. */
147
+ readonly position: number | null;
148
+ readonly year: number | null;
149
+ /** Owner. A string uuid, next to an integer `id`. */
150
+ readonly user_id: Id;
151
+ readonly source_kind: SongSourceKind | null;
152
+ /** `"youtube"`, `"soundcloud"`, `"spotify"`, `"bandcamp"`, `"vimeo"`, or another. */
153
+ readonly source_provider: string | null;
154
+ readonly source_url: string | null;
155
+ readonly source_id: string | null;
156
+ /** Recording identifier, when the importer resolved one. */
157
+ readonly isrc: string | null;
158
+ readonly original_filename: string | null;
159
+ readonly audio_codec: string | null;
160
+ readonly audio_bitrate_kbps: number | null;
161
+ readonly audio_sample_rate_hz: number | null;
162
+ readonly audio_channels: number | null;
163
+ readonly audio_lossless: boolean | null;
164
+ readonly audio_filesize_bytes: number | null;
165
+ /**
166
+ * The uploaded file, as a storage node id. `null` when the attachment is
167
+ * gone - which is rare but real, and is why
168
+ * {@link MusicSongsNamespace.startSeparation} can answer `400 "Song has no
169
+ * audio"`.
170
+ */
171
+ readonly audio_media_id: Id | null;
172
+ /** Transcoded stream copy. Prefer it over the original for playback. */
173
+ readonly compressed_audio_media_id: Id | null;
174
+ readonly artwork_media_id: Id | null;
175
+ /** Thumbnail. Prefer it in any grid: the original is a full-size cover. */
176
+ readonly compressed_artwork_media_id: Id | null;
177
+ /** Set once a separation finished. See {@link MusicSongsNamespace.separation}. */
178
+ readonly vocals_media_id: Id | null;
179
+ readonly instrumental_media_id: Id | null;
180
+ /**
181
+ * Stamped when a separation starts and cleared when it settles, so a
182
+ * non-null value means "a separation is in flight". It can go stale if a
183
+ * worker dies; `GET /songs/:id/separation` clears it as a side effect, which
184
+ * is one reason to poll that route rather than re-reading the song.
185
+ */
186
+ readonly vocal_separation_started_at: Timestamp | null;
187
+ /** Credits, unsorted. Sort by {@link SongArtistCredit.position} yourself. */
188
+ readonly artists: SongArtistCredit[];
189
+ /** @deprecated Legacy twin of `audio_media_id`. Same value. */
190
+ readonly audio_fs_node_id?: Id | null;
191
+ /** @deprecated Legacy twin of `compressed_audio_media_id`. Same value. */
192
+ readonly compressed_audio_fs_node_id?: Id | null;
193
+ /** @deprecated Legacy twin of `artwork_media_id`. Same value. */
194
+ readonly artwork_fs_node_id?: Id | null;
195
+ /** @deprecated Legacy twin of `compressed_artwork_media_id`. Same value. */
196
+ readonly compressed_artwork_fs_node_id?: Id | null;
197
+ /** @deprecated Legacy twin of `vocals_media_id`. Same value. */
198
+ readonly vocals_fs_node_id?: Id | null;
199
+ /** @deprecated Legacy twin of `instrumental_media_id`. Same value. */
200
+ readonly instrumental_fs_node_id?: Id | null;
201
+ /**
202
+ * Ready-made presigned URL, present ONLY on a song injected into a jam by
203
+ * another member - the host cannot resolve a stranger's storage nodes, so
204
+ * the jam serializer inlines URLs instead of ids. A song that came out of
205
+ * this namespace never has them.
206
+ */
207
+ readonly audio_url?: string;
208
+ readonly artwork_url?: string | null;
209
+ /** Pre-joined display line, again only on jam entries. */
210
+ readonly artist_names?: string | string[];
211
+ /** Marks a jam proposal. Never record a play event for one. */
212
+ readonly jam_song?: true;
213
+ readonly jam_proposer?: {
214
+ readonly id: Id;
215
+ readonly handle: string;
216
+ readonly name: string;
217
+ };
218
+ }
219
+ /**
220
+ * A row of `GET /songs/albums`.
221
+ *
222
+ * Not a database entity: the endpoint groups the caller's songs in Ruby and
223
+ * this is the summary it builds. `name: null` is the bucket of songs with no
224
+ * album, and it is a legitimate row rather than an error.
225
+ */
226
+ export interface SongAlbumSummary {
227
+ /** `null` for the no-album bucket. */
228
+ readonly name: string | null;
229
+ /** Display name of the primary artist. A plain string here, not an object. */
230
+ readonly artist: string | null;
231
+ readonly artist_slug: string | null;
232
+ readonly artwork_media_id: Id | null;
233
+ /** @deprecated Legacy twin of `artwork_media_id`. Same value. */
234
+ readonly artwork_fs_node_id?: Id | null;
235
+ }
236
+ /**
237
+ * One Deezer picture set, from `GET /songs/artist_pictures`.
238
+ *
239
+ * Every field is nullable: Deezer returns partial sets, and the row is only
240
+ * emitted at all when `picture` itself is non-null.
241
+ */
242
+ export interface SongArtistPictures {
243
+ readonly picture: string | null;
244
+ readonly picture_small: string | null;
245
+ readonly picture_medium: string | null;
246
+ readonly picture_big: string | null;
247
+ readonly picture_xl: string | null;
248
+ }
249
+ /** Wrapper `GET /songs/:id/separation` answers with. */
250
+ export interface SongSeparationStatus {
251
+ /** True once both stems are attached to the song. The one flag worth branching on. */
252
+ readonly stems_ready: boolean;
253
+ readonly vocals_media_id: Id | null;
254
+ readonly instrumental_media_id: Id | null;
255
+ /**
256
+ * Lifted out of `job` for convenience, and `null` unless the run is actually
257
+ * processing - queued and finished runs both report `null` here.
258
+ */
259
+ readonly progress_percent: number | null;
260
+ /**
261
+ * The run itself, or `null` when this song has never had one.
262
+ *
263
+ * The same `VocalSeparation` record the `tools.vocalSeparation` namespace
264
+ * returns - one model, one blueprint, two ways in - so it is imported rather
265
+ * than redeclared here. For a song-owned run `vocals_url` and
266
+ * `instrumental_url` are permanently `null`: the stems are written onto the
267
+ * song as storage nodes, not attached to this row.
268
+ */
269
+ readonly job: VocalSeparation | null;
270
+ }
271
+ /** A liked track. The join row, with the whole song inlined. */
272
+ export interface LikedSong extends Omit<BaseRecord, "id"> {
273
+ /** Integer primary key of the like itself. NOT the song id. */
274
+ readonly id: number;
275
+ readonly user_id: Id;
276
+ readonly song_id: number;
277
+ /** The cursor {@link ListLikedSongsParams.before} pages on. */
278
+ readonly liked_at: Timestamp;
279
+ /** Always present: the route renders the `:extended` view, which inlines it. */
280
+ readonly song: Song;
281
+ }
282
+ /** Lyrics for one track. */
283
+ export interface SongLyrics {
284
+ /** LRC text with `[mm:ss.xx]` timestamps, or `null` when only plain text exists. */
285
+ readonly synced: string | null;
286
+ /** Newline-separated plain text, or `null`. */
287
+ readonly plain: string | null;
288
+ /** Where they came from, e.g. `"lrclib.net"`. Never `null`. */
289
+ readonly attribution: string;
290
+ }
291
+ /** Lyrics translated line for line, with the LRC timestamps untouched. */
292
+ export interface SongLyricsTranslation extends SongLyrics {
293
+ /** Echo of the requested target. */
294
+ readonly target: string;
295
+ }
296
+ /**
297
+ * The seven locales the translator accepts. Anything else is a `400
298
+ * "Unsupported target"`, checked before any work is done and before the
299
+ * hourly budget is spent.
300
+ */
301
+ export declare const LYRICS_TRANSLATION_TARGETS: readonly ["pt", "en", "es", "fr", "de", "it", "lv"];
302
+ /** One of {@link LYRICS_TRANSLATION_TARGETS}. */
303
+ export type LyricsTranslationTarget = (typeof LYRICS_TRANSLATION_TARGETS)[number];
304
+ /** Handle returned by {@link MusicSongsNamespace.syncLyrics}. */
305
+ export interface LyricsSyncHandle {
306
+ /** Poll it with `oms.jobs`, or just re-read the lyrics until `synced` appears. */
307
+ readonly job_id: string;
308
+ }
309
+ /** A related artist, as Last.fm scored it. */
310
+ export interface ArtistMetadataSimilar {
311
+ readonly name: string | null;
312
+ /** Similarity in `[0, 1]`, as a number or a numeric string depending on upstream. */
313
+ readonly match: number | string | null;
314
+ readonly mbid: string | null;
315
+ }
316
+ /**
317
+ * The legacy payload `GET /artist_metadata/:name` answers with.
318
+ *
319
+ * `ArtistMetadataBlueprint` inherits `Blueprinter::Base` directly rather than
320
+ * `ApplicationBlueprint`, so this is one of the very few records in the API
321
+ * with NO `created_at` / `updated_at`. It also never 404s - see
322
+ * {@link MusicSongsNamespace.artistMetadata}.
323
+ */
324
+ export interface ArtistMetadata {
325
+ /** `null` on the not-found branch. Integer when the artist exists. */
326
+ readonly id: number | null;
327
+ /** Echoed back verbatim on the not-found branch, so it is the one non-null key there. */
328
+ readonly name: string | null;
329
+ readonly slug: string | null;
330
+ readonly mbid: string | null;
331
+ readonly lastfm_listeners: number | null;
332
+ readonly lastfm_playcount: number | null;
333
+ /** Sanitised HTML. Still HTML: escape it or render it deliberately. */
334
+ readonly bio_html: string | null;
335
+ /** The artist's `external_image_url`, renamed by this legacy shim. */
336
+ readonly image_url: string | null;
337
+ readonly image_media_id: Id | null;
338
+ readonly compressed_image_media_id: Id | null;
339
+ readonly banner_media_id: Id | null;
340
+ readonly compressed_banner_media_id: Id | null;
341
+ readonly picture: string | null;
342
+ readonly picture_small: string | null;
343
+ readonly picture_medium: string | null;
344
+ readonly picture_big: string | null;
345
+ readonly picture_xl: string | null;
346
+ /** `[]` on the not-found branch, never `null`. */
347
+ readonly similar: ArtistMetadataSimilar[];
348
+ /** @deprecated Legacy twin of `image_media_id`. Same value. */
349
+ readonly image_fs_node_id?: Id | null;
350
+ /** @deprecated Legacy twin of `compressed_image_media_id`. Same value. */
351
+ readonly compressed_image_fs_node_id?: Id | null;
352
+ /** @deprecated Legacy twin of `banner_media_id`. Same value. */
353
+ readonly banner_fs_node_id?: Id | null;
354
+ /** @deprecated Legacy twin of `compressed_banner_media_id`. Same value. */
355
+ readonly compressed_banner_fs_node_id?: Id | null;
356
+ }
357
+ /**
358
+ * Where an external-search hit came from.
359
+ *
360
+ * The frontend's own union also lists `"soundcloud"` and `"bandcamp"`. The
361
+ * current `Music::ExternalSearch` queries Spotify, iTunes and YouTube and
362
+ * nothing else, so those two branches are dead - keep handling them if you
363
+ * ported code that does, but do not wait for them.
364
+ */
365
+ export type MusicExternalSource = "spotify" | "itunes" | "youtube" | "soundcloud" | "bandcamp";
366
+ /** What to ask the external providers for. */
367
+ export type MusicExternalSearchKind = "track" | "album" | "artist" | "any";
368
+ /** An external track hit. */
369
+ export interface MusicExternalTrack {
370
+ readonly source: MusicExternalSource;
371
+ readonly kind: "track";
372
+ /** Provider-native id. `null` from iTunes rows missing a `trackId`. */
373
+ readonly source_id: string | null;
374
+ /** Downloadable or linkable page. Only YouTube rows are actually downloadable. */
375
+ readonly source_url: string | null;
376
+ readonly title: string | null;
377
+ /** Already joined with `", "` for Spotify rows; the uploader name for YouTube. */
378
+ readonly artist: string | null;
379
+ readonly album: string | null;
380
+ readonly duration_ms: number | null;
381
+ /** Spotify only; `null` from iTunes and YouTube. The importer's fast path. */
382
+ readonly isrc: string | null;
383
+ readonly artwork_url: string | null;
384
+ }
385
+ /** An external album hit. Spotify only. */
386
+ export interface MusicExternalAlbum {
387
+ readonly source: "spotify";
388
+ readonly kind: "album";
389
+ readonly source_id: string | null;
390
+ readonly source_url: string | null;
391
+ readonly title: string | null;
392
+ readonly artist: string | null;
393
+ readonly total_tracks: number | null;
394
+ readonly artwork_url: string | null;
395
+ }
396
+ /** An external artist hit. Spotify only. */
397
+ export interface MusicExternalArtist {
398
+ readonly source: "spotify";
399
+ readonly kind: "artist";
400
+ readonly source_id: string | null;
401
+ readonly source_url: string | null;
402
+ readonly name: string | null;
403
+ readonly followers: number | null;
404
+ readonly artwork_url: string | null;
405
+ }
406
+ /** All three lists, always all three keys. */
407
+ export interface MusicExternalSearchResult {
408
+ readonly tracks: MusicExternalTrack[];
409
+ readonly albums: MusicExternalAlbum[];
410
+ readonly artists: MusicExternalArtist[];
411
+ }
412
+ /**
413
+ * Columns `search` and `exact_search` accept on `/songs` and `/songs/albums`.
414
+ *
415
+ * Exported because the filter allowlist FAILS CLOSED: an unrecognised key is a
416
+ * `400 "Unknown search filters: ..."`, not a wider result. Check against this
417
+ * before building a filter bag from user input.
418
+ *
419
+ * `artist` is on the list but is not a column - see
420
+ * {@link ListSongsParams.artist}.
421
+ */
422
+ export declare const SONG_FILTER_COLUMNS: readonly string[];
423
+ /** Columns the backend will accept in `modifiers[order]`. */
424
+ export declare const SONG_ORDER_COLUMNS: readonly string[];
425
+ /** Filters shared by `GET /songs` and `GET /songs/albums`. */
426
+ export interface SongFilters {
427
+ /**
428
+ * Partial title match, sent as `search[title]`.
429
+ *
430
+ * The comparison is slug-shaped: the server lowercases, strips accents
431
+ * through a `TRANSLATE`, replaces every run of non-alphanumerics with a
432
+ * hyphen and then does a `LIKE %...%` on both sides. So `"cafe"` finds
433
+ * "Café", `"nao quero"` finds "Não Quero", and punctuation is irrelevant. It
434
+ * is not a full-text index and there is no ranking.
435
+ *
436
+ * An empty or whitespace-only string is DROPPED rather than matching
437
+ * everything, which is right but means a cleared search box quietly becomes
438
+ * an unfiltered listing.
439
+ */
440
+ readonly title?: string;
441
+ /**
442
+ * Exact album, sent as `exact_search[album]`. Pass `null` for the no-album
443
+ * bucket: the transport encodes it as the backend's `\b` sentinel and
444
+ * `Searchable.exact_search` turns that into `WHERE album IS NULL`, which is
445
+ * the only way to ask for it.
446
+ *
447
+ * Not a substring match. `search[album]` exists and IS partial, but it can
448
+ * never express the null bucket, so this field takes the exact route and the
449
+ * escape hatch below covers the other one.
450
+ */
451
+ readonly album?: string | null;
452
+ /** Exact year, or a list of years (encoded as `IN`). `null` matches rows with no year. */
453
+ readonly year?: number | number[] | null;
454
+ /** Fetch specific songs in one request, sent as `exact_search[id][]`. */
455
+ readonly ids?: number[];
456
+ /**
457
+ * Narrow to one artist, by canonical name OR by slug.
458
+ *
459
+ * This one is not a column and does not behave like the others. The
460
+ * controller reads it straight out of `params`, resolves it against YOUR
461
+ * artist roster (canonical name first, then slug) and joins through
462
+ * `song_artists`. Consequences:
463
+ *
464
+ * - it is EXACT even though it is spelled like a search. `search[artist]`
465
+ * and `exact_search[artist]` are the same code path; there is no partial
466
+ * artist match anywhere in this API, so a type-ahead over artists has to
467
+ * filter client-side or go through `oms.music.artists`;
468
+ * - an artist you do not have resolves to nothing and yields an EMPTY list,
469
+ * not a 404 and not an error. An empty page is genuinely ambiguous here;
470
+ * - it changes what {@link MusicSongsNamespace.albums} deduplicates on, which
471
+ * is the point of that endpoint's `filter_artist_id`.
472
+ */
473
+ readonly artist?: string;
474
+ /**
475
+ * Restrict the artist filter to one kind of credit. Meaningless without
476
+ * {@link SongFilters.artist} and silently ignored then.
477
+ *
478
+ * `"featured"` is subtractive: it means "credited as featured or with, AND
479
+ * not also primary on that same song", so a song where the artist leads is
480
+ * excluded even if they also appear as a feature.
481
+ */
482
+ readonly artistRole?: SongArtistRole;
483
+ /**
484
+ * Escape hatch for a partial match on a column {@link SongFilters} does not
485
+ * name, sent verbatim as `search[...]`.
486
+ *
487
+ * Only the keys in {@link SONG_FILTER_COLUMNS} are accepted and an unknown
488
+ * one is a 400, so do not forward user-controlled keys. Two behaviours
489
+ * surprise people: on a NUMBER column (`year`, `position`, `id`) `search` is
490
+ * an exact `IN`, not a range or a prefix; and any blank value is dropped
491
+ * instead of matching nothing.
492
+ */
493
+ readonly search?: Readonly<Record<string, QueryValue>>;
494
+ /**
495
+ * Escape hatch for an equality filter, sent verbatim as `exact_search[...]`.
496
+ * Same allowlist, same fail-closed 400. An array becomes `IN`, `null`
497
+ * becomes `IS NULL`.
498
+ */
499
+ readonly exactSearch?: Readonly<Record<string, QueryValue>>;
500
+ }
501
+ /** Arguments for {@link MusicSongsNamespace.list}. */
502
+ export interface ListSongsParams extends SongFilters, PageParams {
503
+ /**
504
+ * `modifiers[order]`, as `"column:asc"` or `"column:desc"`.
505
+ *
506
+ * Defaults to `"created_at:asc"`, which is also the endpoint's own base
507
+ * order and the one that makes paging stable. Two traps:
508
+ *
509
+ * - a column the model does not have is IGNORED, silently. `modifiers[order]`
510
+ * is an allowed key so the request is not rejected, and `QueryModifier`
511
+ * simply returns before ordering, handing back the base order. A typo
512
+ * costs you nothing but the sort you asked for;
513
+ * - a real column REPLACES the base order (`reorder`, not `order`), tie
514
+ * breaker included. Ordering 4000 tracks by `title:asc` when several share
515
+ * a title gives Postgres licence to return them in a different sequence per
516
+ * page, which duplicates and drops rows across a paged walk. Prefer
517
+ * `created_at` or `id` for anything you intend to page through.
518
+ *
519
+ * A third `:`-separated segment pins specific values first
520
+ * (`"album:asc:Clube da Esquina,Acabou Chorare"`), at the cost of an extra
521
+ * `SELECT DISTINCT` over the column.
522
+ */
523
+ readonly order?: string;
524
+ /**
525
+ * `modifiers[random]=true` - shuffle server-side with `RANDOM()`.
526
+ *
527
+ * Mutually destructive with paging: the ordering is re-evaluated per request,
528
+ * so page 2 of a random listing is a fresh shuffle and shares rows with page
529
+ * 1. Use it for "give me N tracks", never to walk a library. It also disables
530
+ * the endpoint's `ETag`, deliberately, because every answer differs.
531
+ */
532
+ readonly random?: boolean;
533
+ }
534
+ /** Arguments for {@link MusicSongsNamespace.albums}. */
535
+ export interface ListSongAlbumsParams extends SongFilters {
536
+ /**
537
+ * Page of SONGS to scan, not of albums. Read
538
+ * {@link MusicSongsNamespace.albums} before setting it; omitting it is
539
+ * almost always right.
540
+ */
541
+ readonly page?: number;
542
+ /** Size of that song scan, capped at 500 by the server. */
543
+ readonly pageSize?: number;
544
+ }
545
+ /**
546
+ * Fields {@link MusicSongsNamespace.update} can change.
547
+ *
548
+ * Four real columns and three virtual inputs. The virtual ones are the
549
+ * complicated half; each carries its own note.
550
+ */
551
+ export interface UpdateSongInput {
552
+ readonly title?: string;
553
+ /** `null` clears it and moves the track into the no-album bucket. */
554
+ readonly album?: string | null;
555
+ readonly year?: number | null;
556
+ readonly position?: number | null;
557
+ /**
558
+ * The full list of artists, in credit order, replacing whatever is there.
559
+ *
560
+ * Wins over {@link UpdateSongInput.artist} when both are sent, and unlike it
561
+ * there is no comma-splitting heuristic, so a name that genuinely contains a
562
+ * comma survives.
563
+ *
564
+ * An EMPTY array is not "remove every artist": it is `blank?` server-side,
565
+ * which drops it back to `nil` and re-runs the legacy parser. There is no way
566
+ * through this endpoint to leave a song with no artists at all.
567
+ */
568
+ readonly artistNames?: string[];
569
+ /**
570
+ * The featured credits, replacing whatever is there. Sending this key at all
571
+ * is what switches the backend out of its legacy mode.
572
+ *
573
+ * That legacy mode is a heuristic over the TITLE: with no `featured_artist_
574
+ * names` key present, the server re-reads `"Song (feat. X)"` and rebuilds the
575
+ * credits from it. So a caller that edits artists without sending this key
576
+ * can watch its explicit list be overwritten by a parse of the title.
577
+ *
578
+ * An empty array means "explicitly no featured artists" and is transmitted
579
+ * correctly in both encodings by this method - which takes some doing in
580
+ * multipart, where an empty array appends no parts and is indistinguishable
581
+ * from an absent key. See the note on {@link MusicSongsNamespace.update}.
582
+ */
583
+ readonly featuredArtistNames?: string[];
584
+ /**
585
+ * Legacy single-line artist input, re-parsed server-side (it splits on
586
+ * commas and on "feat."). Prefer {@link UpdateSongInput.artistNames}; this
587
+ * exists for parity with the old web form.
588
+ */
589
+ readonly artist?: string;
590
+ /**
591
+ * New cover art. Its presence is what makes the request multipart.
592
+ *
593
+ * Stored as a new node in the caller's music storage and charged against the
594
+ * music quota, so it can answer `400 "Music storage quota exceeded"`. On
595
+ * React Native pass the picker's `{ uri, name, type }` object directly.
596
+ */
597
+ readonly artwork?: FileInput | NativeFile;
598
+ }
599
+ /** Arguments for {@link MusicSongsNamespace.listLiked}. */
600
+ export interface ListLikedSongsParams {
601
+ /**
602
+ * How many rows to return. Server default 200, ceiling 500, and a value at
603
+ * or below zero falls back to the default rather than erroring.
604
+ */
605
+ readonly limit?: number;
606
+ /**
607
+ * Cursor: return only likes STRICTLY OLDER than this instant. Pass the
608
+ * `liked_at` of the last row you already hold.
609
+ *
610
+ * Deliberately not an offset. The list is ordered by `liked_at` descending
611
+ * and liking one track mid-scroll shifts every later offset page by one,
612
+ * which shows a duplicate and hides a row. A `Date` is encoded as ISO-8601,
613
+ * which is what `Time.zone.parse` wants; an unparseable string is a
614
+ * `400 "Invalid before timestamp"`.
615
+ */
616
+ readonly before?: string | Date;
617
+ }
618
+ /** Arguments for {@link MusicSongsNamespace.startSeparation}. */
619
+ export interface StartSongSeparationInput {
620
+ /**
621
+ * Which sidecar model to run. Omit for the default. The selectable list is
622
+ * `oms.tools.vocalSeparation.models()`; an id that is not on it is a
623
+ * `400 "Unknown model"`.
624
+ */
625
+ readonly modelId?: string;
626
+ }
627
+ /** Tags {@link MusicSongsNamespace.modifyMetadata} can write. */
628
+ export interface SongFileMetadata {
629
+ readonly title?: string;
630
+ readonly artist?: string;
631
+ readonly album?: string;
632
+ /** A string, not a number: it is written into the container's tag verbatim. */
633
+ readonly year?: string;
634
+ readonly genre?: string;
635
+ /** Cover art to embed. Re-encoded to MJPEG, or to a Vorbis picture block for ogg/opus. */
636
+ readonly artwork?: FileInput | NativeFile;
637
+ }
638
+ /** Arguments for {@link MusicSongsNamespace.modifyMetadata}. */
639
+ export interface ModifySongMetadataInput {
640
+ /** The file to retag. Hard cap 50 MiB, enforced before anything else happens. */
641
+ readonly audio: FileInput | NativeFile;
642
+ /**
643
+ * At least one tag is REQUIRED. An empty bag raises server-side and comes
644
+ * back as a 500 with a Discord page attached, so this method rejects it
645
+ * locally instead.
646
+ */
647
+ readonly metadata: SongFileMetadata;
648
+ }
649
+ /** Arguments for {@link MusicSongsNamespace.externalSearch}. */
650
+ export interface MusicExternalSearchParams {
651
+ /** The query. Blank short-circuits to three empty lists without spending budget. */
652
+ readonly q: string;
653
+ /** Defaults to `"track"` server-side. See the method for what it does and does not change. */
654
+ readonly kind?: MusicExternalSearchKind;
655
+ }
656
+ /** Default `limit` the liked-songs endpoint applies when none is sent. */
657
+ export declare const LIKED_SONGS_DEFAULT_LIMIT = 200;
658
+ /** Hard ceiling the liked-songs endpoint clamps `limit` to. */
659
+ export declare const LIKED_SONGS_MAX_LIMIT = 500;
660
+ /** Audio extensions `POST /songs/import` accepts. Anything else is a 415. */
661
+ export declare const SONG_IMPORT_EXTENSIONS: readonly string[];
662
+ /** Ceiling `POST /songs/import` enforces on the uploaded file: 1 GiB. */
663
+ export declare const SONG_IMPORT_MAX_BYTES = 1073741824;
664
+ /** Ceiling `POST /songs/metadata_modifier` enforces on its input: 50 MiB. */
665
+ export declare const SONG_METADATA_MAX_BYTES = 52428800;
666
+ /**
667
+ * True when this error is `music/external_search` refusing on its rate limit.
668
+ *
669
+ * That endpoint answers **`400 "Rate limit exceeded"`**, not `429`, so it
670
+ * arrives as an {@link OmsApiError} with `code === "invalid_request"` and slips
671
+ * straight past `instanceof OmsQuotaError` and past `status === 429`. Every
672
+ * error handler that routes by status treats it as "your query was malformed"
673
+ * and retries with a different query, which spends more budget. This is the
674
+ * check to use instead.
675
+ *
676
+ * Matched on the body rather than only on the status, because a genuine 400
677
+ * from this route is possible in principle and must not be swallowed as a
678
+ * quota.
679
+ */
680
+ export declare function isMusicExternalSearchRateLimited(error: unknown): boolean;
681
+ /**
682
+ * Builds the one-line artist credit for a song, Spotify style.
683
+ *
684
+ * `"Chico Buarque, Milton Nascimento (feat. Elis Regina)"`: primaries joined
685
+ * with `", "`, then a `feat.` clause. Pure string building, no request.
686
+ *
687
+ * `with` credits are excluded unless `includeWith` is set, which mirrors what
688
+ * the three clients do - they render only in a credits dialog and in media
689
+ * session metadata, where completeness beats line length.
690
+ *
691
+ * Written here because {@link Song.artists} arrives UNSORTED and every client
692
+ * that forgot to sort by `position` printed the credits in insertion order,
693
+ * which is roughly random. It also copes with a jam entry, whose `artist_names`
694
+ * is a pre-joined string and whose `artists` array is empty.
695
+ */
696
+ export declare function songArtistsLine(song: Pick<Song, "artists"> & Partial<Pick<Song, "artist_names">>, includeWith?: boolean): string;
697
+ /** The `music.songs` namespace, reachable as `oms.music.songs`. */
698
+ export declare class MusicSongsNamespace extends Resource {
699
+ /**
700
+ * `GET /songs` - the caller's library, oldest first.
701
+ *
702
+ * Pagination is FORCED here and nowhere else in this namespace: the
703
+ * controller overrides `modifiers_params` for `index` only, so a request with
704
+ * no page modifier is given `1:500` and one asking for more than 500 is
705
+ * clamped to it. That is a DoS guard, not tidiness - a five-thousand-track
706
+ * library serialises megabytes of JSON with every credit inlined, and a
707
+ * handful of concurrent unbounded listings used to exhaust the Puma threads.
708
+ * The SDK sends a page modifier every time, so the clamp never surprises you
709
+ * and {@link Paginated.pageSize} always reports the size the rows were
710
+ * counted against.
711
+ *
712
+ * Order defaults to `created_at:asc`, the endpoint's own base order, which is
713
+ * what makes a paged walk stable. See {@link ListSongsParams.order} before
714
+ * changing it.
715
+ *
716
+ * The response supports `ETag` / `If-None-Match` (except with
717
+ * {@link ListSongsParams.random}), so a repeated identical listing is cheap
718
+ * for the server even though the SDK does not cache it for you.
719
+ *
720
+ * @throws {OmsAuthError} 401 when anonymous, 403 for an OAuth token.
721
+ * @throws {OmsApiError} 400 naming the offending key when a filter is not in
722
+ * {@link SONG_FILTER_COLUMNS}. Filters fail closed on purpose: silently
723
+ * dropping an unknown one used to answer with the UNFILTERED set.
724
+ */
725
+ list(params?: ListSongsParams, options?: RequestOptions): Promise<Paginated<Song>>;
726
+ /**
727
+ * `GET /songs/:id` - one track.
728
+ *
729
+ * Renders the `:extended` view, which `SongBlueprint` leaves identical to the
730
+ * default one, so this returns exactly what a row of {@link list} carries.
731
+ *
732
+ * @throws {OmsApiError} 404 `"Resource not found"` - for an id that does not
733
+ * exist AND for one that belongs to somebody else, indistinguishably. The
734
+ * lookup is scoped to the caller before the id is even compared.
735
+ */
736
+ get(id: SongId, options?: RequestOptions): Promise<Song>;
737
+ /**
738
+ * `PATCH /songs/:id` - edits metadata, and optionally replaces the artwork.
739
+ *
740
+ * JSON normally; multipart as soon as {@link UpdateSongInput.artwork} is
741
+ * present, because that is the only way to carry a file. Both encodings reach
742
+ * the same code path server-side, and this method papers over the two places
743
+ * where they would otherwise behave differently:
744
+ *
745
+ * - **clearing a column in multipart.** Every form field is a string, so
746
+ * there is no `null` to send. The backend's `\b` sentinel is decoded for
747
+ * update params exactly as it is for filters, so `album: null` is written
748
+ * as that one character and clears the column. (The Expo app's own comment
749
+ * claims multipart cannot express this and splits the request in two; it
750
+ * can, and it does not need to.)
751
+ * - **an empty `featuredArtistNames`.** Appending an empty array appends
752
+ * nothing, and an absent key is what puts the backend back into its
753
+ * title-parsing legacy mode - the opposite of what "no featured artists"
754
+ * means. This sends the single empty string the server reads as an explicit
755
+ * empty list.
756
+ *
757
+ * Editing the TITLE alone also re-runs artist parsing, which can rewrite the
758
+ * credits you did not touch. Send `featuredArtistNames` whenever you care
759
+ * about them.
760
+ *
761
+ * @throws {OmsAuthError} 401 when the song is not yours - `update` checks
762
+ * `updatable_by?` and answers 401, not 403 or 404.
763
+ * @throws {OmsApiError} 400 `"Music storage quota exceeded"` when the artwork
764
+ * would not fit in the music quota.
765
+ */
766
+ update(id: SongId, input: UpdateSongInput, options?: RequestOptions): Promise<Song>;
767
+ /**
768
+ * `DELETE /songs/:id` - removes the track and its media.
769
+ *
770
+ * @throws {OmsAuthError} 401 when the song is not yours.
771
+ * @throws {OmsApiError} 404 the second time, because the row is already gone.
772
+ * That is why this is not retried on a torn connection: a replay would
773
+ * report "not found" for a delete that worked perfectly well.
774
+ */
775
+ delete(id: SongId, options?: RequestOptions): Promise<void>;
776
+ /**
777
+ * `POST /songs/import` - uploads an audio file and adds it to the library.
778
+ *
779
+ * Synchronous, and slow: the server reads the tags, extracts the embedded
780
+ * artwork, detects the real codec from the file header rather than the
781
+ * extension, stores the original and enqueues a transcode. A lossless upload
782
+ * takes tens of seconds, so the per-attempt deadline defaults to five minutes
783
+ * here instead of the client's usual one.
784
+ *
785
+ * Answers **200**, not 201, with the created song. Do not branch on the code.
786
+ *
787
+ * Two size limits and they are not the same one. Rails rejects anything over
788
+ * {@link SONG_IMPORT_MAX_BYTES} (1 GiB) with a 400 - but production sits
789
+ * behind Cloudflare, which refuses a request body over roughly 100 MB with
790
+ * its own `413` before Rails ever sees it. A big FLAC therefore fails with an
791
+ * HTML-ish 413 that says nothing about songs. There is no chunked import
792
+ * route; that ceiling is real.
793
+ *
794
+ * On React Native pass the picker's `{ uri, name, type }` object directly -
795
+ * it is appended verbatim and streamed off disk by the native layer.
796
+ *
797
+ * @throws {OmsApiError} 400 for a missing file, a file over 1 GiB, an
798
+ * extension outside {@link SONG_IMPORT_EXTENSIONS}, or
799
+ * `"Music storage quota exceeded"`; 415 with the model's validation
800
+ * messages when the audio itself will not import.
801
+ */
802
+ import(file: FileInput | NativeFile, options?: RequestOptions): Promise<Song>;
803
+ /**
804
+ * `GET /songs/albums` - one card per album in the library.
805
+ *
806
+ * Takes the same filters as {@link list}, and is the endpoint every album
807
+ * grid is built on. It is also the most expensive read in this namespace, for
808
+ * a reason worth understanding: the forced pagination that protects
809
+ * `GET /songs` is applied to the `index` action ONLY, so this action loads
810
+ * the whole filtered library, eager-loads the credits and deduplicates in
811
+ * Ruby by `[album, primary artist]`. On a five-thousand-track library that is
812
+ * a full table scan per call.
813
+ *
814
+ * Paging it does not fix that and is usually a mistake:
815
+ * {@link ListSongAlbumsParams.page} pages the SONGS that get scanned, and the
816
+ * grouping happens after the page is cut. Page 2 is "the albums of the next
817
+ * 500 songs", which overlaps page 1 wherever an album straddles the boundary,
818
+ * and concatenating the pages gives you duplicates rather than the full list.
819
+ * Ask for everything, once, and cache it.
820
+ *
821
+ * With {@link SongFilters.artist} set, the dedup key switches to the FILTERED
822
+ * artist, which is what stops a compilation appearing twice because two of
823
+ * its tracks have different leads.
824
+ *
825
+ * @throws {OmsApiError} 400 for an unknown filter key, exactly as {@link list}.
826
+ */
827
+ albums(params?: ListSongAlbumsParams, options?: RequestOptions): Promise<SongAlbumSummary[]>;
828
+ /**
829
+ * `GET /songs/artists` - the names of every artist in the caller's roster.
830
+ *
831
+ * A flat array of strings, ordered by name, and that is the whole payload.
832
+ *
833
+ * It IGNORES every filter you could send it: the action never touches the
834
+ * listing scope, it plucks names straight off the artists table. This method
835
+ * therefore takes no parameters at all rather than accepting some that would
836
+ * do nothing. It also does not go through `/artists`, so despite the shared
837
+ * subject it does NOT spend the 60/min external-proxy budget.
838
+ *
839
+ * Kept for back-compatibility. `oms.music.artists` returns real records with
840
+ * ids, slugs, pictures and counts; reach for that unless a list of bare names
841
+ * is genuinely all you want.
842
+ */
843
+ artistNames(options?: RequestOptions): Promise<string[]>;
844
+ /**
845
+ * `GET /songs/artist_pictures?name=` - the cached Deezer picture set for an
846
+ * artist you already have.
847
+ *
848
+ * Lookup only: an artist absent from your roster returns `[]` and no stub row
849
+ * is created, which is deliberate - the old frontend used to send slugs and
850
+ * joined display strings ("100 gecs, Lil West, Tony Velour") here and
851
+ * polluted the artist table with them.
852
+ *
853
+ * The array holds zero or one entry. Zero means either "not your artist" or
854
+ * "Deezer has never given us a picture for them", and the two are not
855
+ * distinguishable from the response.
856
+ *
857
+ * Prefer the `picture_*` fields already inlined on {@link SongArtistCredit}
858
+ * and on artist records: they are the same values, and a page that calls this
859
+ * per row does a request per row for data it was already sent. A cold lookup
860
+ * also blocks on Deezer, and results are cached on the artist for about three
861
+ * days with a jitter.
862
+ *
863
+ * @throws {OmsQuotaError} 429 once the shared 60/min external-proxy bucket is
864
+ * spent - this route sits under `/songs`, but a cold call still talks to
865
+ * Deezer through the same guard.
866
+ */
867
+ artistPictures(name: string, options?: RequestOptions): Promise<SongArtistPictures[]>;
868
+ /**
869
+ * `POST /songs/metadata_modifier` - retags a local file and hands it back.
870
+ *
871
+ * The odd one out in this namespace: it writes nothing to the library, reads
872
+ * nothing from it, and answers with BINARY rather than JSON. The file is
873
+ * remuxed with ffmpeg (`-c:a copy`, so the audio is never re-encoded) with the
874
+ * new tags and, when given one, an embedded cover.
875
+ *
876
+ * The result is buffered fully into memory in every runtime. That is
877
+ * unavoidable - there is no URL to hand a downloader - but it means a 50 MiB
878
+ * input is a 50 MiB Blob on a phone. `FileOutput.filename` carries the name
879
+ * the server suggested in `Content-Disposition`.
880
+ *
881
+ * At least one tag must be present, and this method enforces that locally
882
+ * because the server does not fail gracefully: an empty `metadata` hash
883
+ * raises inside the service, escapes as a 500, and pages a human on Discord.
884
+ * A missing audio file does the same. Both are avoided here.
885
+ *
886
+ * The web frontend also sends `metadata[track_number]`; the backend's permit
887
+ * list drops it in silence, so it is not offered here.
888
+ *
889
+ * @throws {TypeError} when `metadata` carries no usable tag.
890
+ * @throws {OmsApiError} 413 `"File too big"` above
891
+ * {@link SONG_METADATA_MAX_BYTES}, checked before the body is read. Note
892
+ * that Cloudflare's own ~100 MB body limit sits above it and never fires
893
+ * first.
894
+ */
895
+ modifyMetadata(input: ModifySongMetadataInput, options?: RequestOptions): Promise<FileOutput>;
896
+ /**
897
+ * `POST /songs/:id/separate` - splits a track into vocals and instrumental.
898
+ *
899
+ * Answers `201` with the separation row. **Idempotent while one is running**:
900
+ * if this song already has a separation that has not reached `complete` or
901
+ * `failed`, the existing row is returned untouched and nothing new is
902
+ * enqueued. So there is no need to guard the call site - but also no way to
903
+ * force a re-run without deleting the stems first.
904
+ *
905
+ * **Rate limit:** 20 requests per minute, from the `expensive_tools` bucket
906
+ * SHARED with `POST /vocal_separations`, the upscaler, transcriptions,
907
+ * caption jobs, jumpstyle and the yt-dlp previews. It is there because each
908
+ * call schedules minutes of CPU and several gigabytes of RAM on the native
909
+ * sidecar; a load generator that walked a library firing one separation per
910
+ * song pushed the machine into swap, which is exactly what this budget now
911
+ * prevents. Do not batch a library through it.
912
+ *
913
+ * The run itself is asynchronous. Poll {@link separation} roughly every three
914
+ * seconds; the returned row's `status` moves `pending` to `processing` to
915
+ * `complete` or `failed`, and there is no `canceled` no matter what the web
916
+ * frontend's own type says.
917
+ *
918
+ * @throws {OmsQuotaError} 429 when the shared expensive-tools budget is spent.
919
+ * @throws {OmsAuthError} 401 when the song is not yours.
920
+ * @throws {OmsApiError} 400 `"Song has no audio"` when the attachment is
921
+ * missing, `"Unknown model"` for a model id not on
922
+ * `oms.tools.vocalSeparation.models()`.
923
+ */
924
+ startSeparation(id: SongId, input?: StartSongSeparationInput, options?: RequestOptions): Promise<VocalSeparation>;
925
+ /**
926
+ * `GET /songs/:id/separation` - the poll.
927
+ *
928
+ * Answers `200` for every song, with `job: null` when none has ever run - not
929
+ * a 404. Branch on {@link SongSeparationStatus.stems_ready}, which is the
930
+ * only field that reflects the song rather than the run: a run can be
931
+ * `complete` a moment before the stems are attached, and a song can have
932
+ * stems from a run that was swept long ago.
933
+ *
934
+ * Reading this has a SIDE EFFECT, and it is a useful one:
935
+ * `ClearStaleSeparationFlag` runs first and clears
936
+ * {@link Song.vocal_separation_started_at} when the flag outlived its job (a
937
+ * worker that died mid-run leaves it set forever). A UI that decides "a
938
+ * separation is in flight" from the song record alone can therefore be stuck
939
+ * on a spinner that only this call will clear.
940
+ *
941
+ * Poll at about three seconds. Nothing on this route is throttled beyond the
942
+ * general ceiling, but `progress_percent` is fetched live from the sidecar on
943
+ * every call while the run is processing, so a tighter loop costs real work.
944
+ *
945
+ * @throws {OmsApiError} 404 for a song that is not yours.
946
+ */
947
+ separation(id: SongId, options?: RequestOptions): Promise<SongSeparationStatus>;
948
+ /**
949
+ * `DELETE /songs/:id/separation` - throws the stems away.
950
+ *
951
+ * Deletes both stems and clears the ids off the song. The ORIGINAL audio is
952
+ * untouched, so this is safe: it costs a re-run, not the track. Answers 204.
953
+ *
954
+ * The way to force a fresh separation with a different model: delete, then
955
+ * {@link startSeparation} again.
956
+ *
957
+ * @throws {OmsAuthError} 401 when the song is not yours.
958
+ */
959
+ deleteSeparation(id: SongId, options?: RequestOptions): Promise<void>;
960
+ /**
961
+ * `GET /liked_songs` - the caller's likes, newest first, with each song
962
+ * inlined in full.
963
+ *
964
+ * Cursor-paged, not offset-paged, and the cursor is a timestamp rather than
965
+ * an id: pass the `liked_at` of the last row you hold as
966
+ * {@link ListLikedSongsParams.before}. Offsets were wrong here because
967
+ * liking a track while paging shifts every later page by one.
968
+ *
969
+ * End of list is a short page, as everywhere in this API. There is no count.
970
+ *
971
+ * Each row carries a whole {@link Song} with its credits, so 500 likes is a
972
+ * large response; the server preloads to avoid the N+1, but the bytes are
973
+ * still bytes. 100 is the size the clients actually use.
974
+ *
975
+ * @throws {OmsApiError} 400 `"Invalid before timestamp"` for a cursor
976
+ * `Time.zone.parse` cannot read. Pass a `Date` and this cannot happen.
977
+ */
978
+ listLiked(params?: ListLikedSongsParams, options?: RequestOptions): Promise<LikedSong[]>;
979
+ /**
980
+ * `GET /liked_songs/ids` - just the song ids, as integers.
981
+ *
982
+ * The cheap way to render heart icons over a listing. It plucks a single
983
+ * column with no pagination and no cap, so it returns EVERY like in one
984
+ * array - which is the point, and also means it grows without bound. At a few
985
+ * thousand likes it is still a handful of kilobytes.
986
+ *
987
+ * Note what the numbers are: `song_id`, not the id of the like. To unlike,
988
+ * that is exactly what {@link unlike} wants.
989
+ */
990
+ likedIds(options?: RequestOptions): Promise<number[]>;
991
+ /**
992
+ * `POST /liked_songs` - likes a track. Answers `201` with the new row.
993
+ *
994
+ * Genuinely idempotent: the controller is a `find_or_create_by!`, so liking
995
+ * twice returns the same row rather than erroring or creating a duplicate.
996
+ * That is why this is one of the few `POST`s in the SDK that opts INTO the
997
+ * retry policy - a replay after a lost answer cannot produce a second like,
998
+ * and the alternative is a heart that silently did nothing.
999
+ *
1000
+ * @throws {OmsApiError} 404 `"Song not found"` for a song that is not yours,
1001
+ * 400 when `song_id` is missing.
1002
+ */
1003
+ like(songId: SongId, options?: RequestOptions): Promise<LikedSong>;
1004
+ /**
1005
+ * `DELETE /liked_songs/:song_id` - unlikes a track. Answers 204.
1006
+ *
1007
+ * Keyed by the SONG id, not by the id of the like. Its sibling
1008
+ * `DELETE /playlist_songs/:id` is keyed by the join row, and mixing the two
1009
+ * up deletes the wrong thing or nothing at all - here it would simply 404,
1010
+ * because a like's own id will not match any `song_id` you own.
1011
+ *
1012
+ * The 404 is `"Not liked"` and is usually noise: an optimistic UI that
1013
+ * double-fires, or a rollback racing the user. Swallow it and treat the state
1014
+ * as reached. Not retried, for the same reason as every other destroy in this
1015
+ * SDK: a replay after a torn connection reports failure for a delete that
1016
+ * worked.
1017
+ */
1018
+ unlike(songId: SongId, options?: RequestOptions): Promise<void>;
1019
+ /**
1020
+ * `GET /lyrics?song_id=` - lyrics for a track, synced and plain.
1021
+ *
1022
+ * **`200` with both fields `null` is the "no lyrics" answer, not an error.**
1023
+ * A 404 here means the SONG is unknown, nothing else. Code that treats a
1024
+ * missing-lyrics response as a failure retries something the server has
1025
+ * already decided about.
1026
+ *
1027
+ * The first call for a track is slow and it is not the network: the request
1028
+ * blocks while the server searches lrclib and falls back to Genius, which
1029
+ * takes seconds. Hence the five-times-longer default deadline. A hit is
1030
+ * written onto the song row and cached for 30 days; a miss is negative-cached
1031
+ * for 24 hours, so hammering a track with no lyrics achieves nothing at all
1032
+ * for a day.
1033
+ *
1034
+ * **Rate limit:** 60/min, and it is the SHARED external-proxy bucket - the
1035
+ * same 60 requests that `/artists/*`, `/artist_metadata/*` and
1036
+ * `/music_radios/*` draw on. Prefetching lyrics for a queue is the classic
1037
+ * way to spend it and then have an artist page 429 for a minute.
1038
+ *
1039
+ * @throws {OmsQuotaError} 429 once that bucket is spent.
1040
+ * @throws {OmsApiError} 404 `"Song not found"`.
1041
+ */
1042
+ lyrics(songId: SongId, options?: RequestOptions): Promise<SongLyrics>;
1043
+ /**
1044
+ * `GET /lyrics/translation?song_id=&target=` - the same lyrics, translated
1045
+ * line for line.
1046
+ *
1047
+ * The LRC timestamps are preserved exactly, so the same parser handles the
1048
+ * original and the translation and the two align one to one for a karaoke
1049
+ * view. Cached per song, target and lyrics digest, which means the second
1050
+ * request for a translation is free and a lyrics refetch invalidates it.
1051
+ *
1052
+ * **Retries are disabled and this one really matters.** The hourly cap is
1053
+ * enforced by an `increment`-then-compare counter, so a rejected call STILL
1054
+ * INCREMENTS it: a client that retries a 429 three times has pushed itself
1055
+ * three further past the cap without ever getting an answer. The 429 also
1056
+ * carries no `Retry-After`, so the transport would back off by its own
1057
+ * schedule - a few hundred milliseconds - into a limit measured in hours.
1058
+ * Wait out the window instead. Pass `retry` explicitly if you disagree.
1059
+ *
1060
+ * **Two limits at once:** 60 fresh translations per user per HOUR (429,
1061
+ * app-level), on top of the shared 60/min external-proxy bucket that every
1062
+ * `/lyrics*` call draws on.
1063
+ *
1064
+ * @throws {OmsQuotaError} 429 from either budget.
1065
+ * @throws {OmsApiError} 400 `"Unsupported target"` for a locale outside
1066
+ * {@link LYRICS_TRANSLATION_TARGETS}, checked before the counter moves;
1067
+ * 404 `"No lyrics for this song"` when the song has neither synced nor
1068
+ * plain lyrics stored yet - fetch {@link lyrics} first, that is what fills
1069
+ * them in; 503 when the translator itself is down, which is transient but
1070
+ * still not worth an automatic retry.
1071
+ */
1072
+ lyricsTranslation(songId: SongId, target: LyricsTranslationTarget | string, options?: RequestOptions): Promise<SongLyricsTranslation>;
1073
+ /**
1074
+ * `POST /lyrics/sync` - generates LRC timestamps for plain-text lyrics.
1075
+ *
1076
+ * Answers `201 { job_id }` and does the work in the background: it separates
1077
+ * the vocals if there are no stems, transcribes them on the Whisper sidecar
1078
+ * and aligns the known lines against the segments. Minutes of machine time
1079
+ * per call, which is what the 10-per-hour cap is protecting.
1080
+ *
1081
+ * Wait for it either way: `oms.jobs.wait({ id: job_id })`, or simply re-read
1082
+ * {@link lyrics} until `synced` stops being `null`. A `GET /jobs/:id` that
1083
+ * 404s early in the run is normal - keep waiting.
1084
+ *
1085
+ * Retries are disabled for the same reason as
1086
+ * {@link lyricsTranslation}: the hourly counter increments on rejection, and
1087
+ * the 429 has no `Retry-After` to honour.
1088
+ *
1089
+ * @throws {OmsQuotaError} 429 past 10 syncs in an hour, and separately from
1090
+ * the shared 60/min `/lyrics*` bucket.
1091
+ * @throws {OmsApiError} 400 `"Lyrics are already synchronized"` when `synced`
1092
+ * is already set - check before calling, it is a wasted slot otherwise;
1093
+ * 404 `"Song not found"`.
1094
+ */
1095
+ syncLyrics(songId: SongId, options?: RequestOptions): Promise<LyricsSyncHandle>;
1096
+ /**
1097
+ * `GET /music/external_search` - searches Spotify, iTunes and YouTube for
1098
+ * something to import.
1099
+ *
1100
+ * **The trap: over its limit this endpoint answers `400 "Rate limit
1101
+ * exceeded"`, not `429`.** Every handler that routes by status code reads
1102
+ * that as a malformed query and does the worst possible thing - rewords it
1103
+ * and tries again, spending more of a budget that is already gone. The limit
1104
+ * is 30 requests per minute per user, counted in the controller rather than
1105
+ * in rack-attack, which is why it never reaches the 429 path.
1106
+ * {@link isMusicExternalSearchRateLimited} is the check to use, and the
1107
+ * transport will not retry a 400 on its own, so nothing recovers silently.
1108
+ *
1109
+ * Two more things the shape does not tell you:
1110
+ *
1111
+ * - **`albums` and `artists` are Spotify-only.** They are populated from a
1112
+ * Spotify search that only runs when the caller has a linked Spotify
1113
+ * identity; without one both arrive as `[]` forever, however good the
1114
+ * query. `tracks` is always populated, from iTunes and YouTube;
1115
+ * - **`kind` steers Spotify and nothing else.** iTunes and YouTube are
1116
+ * always queried for TRACKS, so `kind: "artist"` still returns tracks
1117
+ * alongside the artists. Tracks from all sources are deduplicated by
1118
+ * lowercased title-and-artist and capped at 12, Spotify first.
1119
+ *
1120
+ * A blank query short-circuits to three empty lists WITHOUT spending budget,
1121
+ * which makes it safe to wire straight to an input. A cached hit does spend
1122
+ * it: the rate check runs before the 15-minute cache is consulted. Debounce
1123
+ * and require two characters, as the clients do.
1124
+ *
1125
+ * Every failing upstream is swallowed server-side, so a partial answer and a
1126
+ * complete one are indistinguishable - an empty `tracks` may mean "no
1127
+ * results" or "YouTube timed out".
1128
+ *
1129
+ * @throws {OmsApiError} 400 - which is either a real problem or the rate
1130
+ * limit. Do not guess: call {@link isMusicExternalSearchRateLimited}.
1131
+ */
1132
+ externalSearch(params: MusicExternalSearchParams, options?: RequestOptions): Promise<MusicExternalSearchResult>;
1133
+ /**
1134
+ * `GET /artist_metadata/:name` - the legacy artist payload, by name or slug.
1135
+ *
1136
+ * **It never 404s.** An artist outside your roster comes back as `200` with
1137
+ * every field `null` except `name`, echoed back verbatim, and
1138
+ * `similar: []`. There is no error to catch and no flag to read: check
1139
+ * whether `id` is null. That branch also creates nothing - the shim used to
1140
+ * be handed slugs and joined display strings and would mint stub artists from
1141
+ * them.
1142
+ *
1143
+ * The payload has no `created_at` / `updated_at`, unlike essentially every
1144
+ * other record in this API: `ArtistMetadataBlueprint` inherits
1145
+ * `Blueprinter::Base` directly to pin the exact legacy key set.
1146
+ *
1147
+ * Reading a stale artist triggers a lazy background refresh from Last.fm and
1148
+ * MusicBrainz, so the first call after a while may be slower and the second
1149
+ * may answer with more.
1150
+ *
1151
+ * Kept alive on purpose, but it is a shim: `oms.music.artists.get()` returns
1152
+ * the modern record with the fields this one renames (`image_url` here is
1153
+ * `external_image_url` there) and the ones it omits.
1154
+ *
1155
+ * The name goes in the path, so it must be encoded - a slash in an artist
1156
+ * name would otherwise become a route segment and 404. This method does that
1157
+ * for you.
1158
+ *
1159
+ * @throws {OmsQuotaError} 429 from the shared 60/min external-proxy bucket.
1160
+ * @throws {OmsApiError} 400 `"name required"` for an empty name.
1161
+ */
1162
+ artistMetadata(name: string, options?: RequestOptions): Promise<ArtistMetadata>;
1163
+ /**
1164
+ * Builds the query for `/songs` and `/songs/albums`.
1165
+ *
1166
+ * `artist_role` is deliberately TOP LEVEL and not inside `exact_search`: the
1167
+ * controller reads it off bare `params`, and nesting it would both miss the
1168
+ * filter and trip the unknown-key check.
1169
+ */
1170
+ private songQuery;
1171
+ /** JSON body for an update. `null` stays `null`; the transport does not touch a body. */
1172
+ private updateBody;
1173
+ /**
1174
+ * Multipart fields for an update, with the two encoding differences fixed.
1175
+ *
1176
+ * `null` becomes the `\b` sentinel, which `CrudActions` decodes back to `nil`
1177
+ * for update params exactly as it does for filters - a form field has no
1178
+ * other way to say "clear this column". An empty `featured_artist_names`
1179
+ * becomes a single empty string, because appending an empty array appends
1180
+ * nothing and an absent key means the opposite thing.
1181
+ */
1182
+ private multipartUpdateFields;
1183
+ }