@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.
- package/dist/index.js +4939 -552
- package/dist/types/client.d.ts +60 -3
- package/dist/types/http.d.ts +444 -19
- package/dist/types/index.d.ts +4 -1
- package/dist/types/resources/account.d.ts +66 -3
- package/dist/types/resources/admin.d.ts +1837 -0
- package/dist/types/resources/auth/index.d.ts +39 -0
- package/dist/types/resources/auth/passkeys.d.ts +652 -0
- package/dist/types/resources/auth/sessions.d.ts +847 -0
- package/dist/types/resources/chests.d.ts +54 -3
- package/dist/types/resources/content.d.ts +2970 -0
- package/dist/types/resources/dynamicQrs.d.ts +39 -3
- package/dist/types/resources/forms.d.ts +176 -35
- package/dist/types/resources/index.d.ts +19 -8
- package/dist/types/resources/ipLookup.d.ts +20 -4
- package/dist/types/resources/jobs.d.ts +62 -21
- package/dist/types/resources/library.d.ts +1435 -0
- package/dist/types/resources/linkTrees.d.ts +142 -30
- package/dist/types/resources/media.d.ts +351 -0
- package/dist/types/resources/movies.d.ts +1186 -0
- package/dist/types/resources/music/artists.d.ts +1066 -0
- package/dist/types/resources/music/imports.d.ts +940 -0
- package/dist/types/resources/music/index.d.ts +61 -0
- package/dist/types/resources/music/playlists.d.ts +1026 -0
- package/dist/types/resources/music/social.d.ts +1132 -0
- package/dist/types/resources/music/songs.d.ts +1183 -0
- package/dist/types/resources/notepads.d.ts +4 -1
- package/dist/types/resources/quotas.d.ts +7 -1
- package/dist/types/resources/realtime.d.ts +855 -0
- package/dist/types/resources/shortLinks.d.ts +45 -4
- package/dist/types/resources/social.d.ts +1330 -0
- package/dist/types/resources/storage/upload.d.ts +158 -11
- package/dist/types/resources/storage.d.ts +88 -22
- package/dist/types/resources/tickets.d.ts +82 -3
- package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
- package/dist/types/resources/tools/captions.d.ts +448 -21
- package/dist/types/resources/tools/downloader.d.ts +21 -0
- package/dist/types/resources/tools/index.d.ts +57 -15
- package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
- package/dist/types/resources/tools/transcription.d.ts +35 -13
- package/dist/types/resources/tools/upscale.d.ts +23 -3
- package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
- package/dist/types/types.d.ts +249 -17
- 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
|
+
}
|