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