@omelhorsite/sdk 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +4 -4
  3. package/dist/types/auth/device.d.ts +1 -1
  4. package/dist/types/auth/index.d.ts +2 -2
  5. package/dist/types/auth/tokens.d.ts +15 -15
  6. package/dist/types/client.d.ts +10 -10
  7. package/dist/types/errors.d.ts +12 -15
  8. package/dist/types/http.d.ts +74 -118
  9. package/dist/types/index.d.ts +1 -2
  10. package/dist/types/local/qr.d.ts +1 -1
  11. package/dist/types/local/wordlist.d.ts +2 -3
  12. package/dist/types/resources/account.d.ts +14 -17
  13. package/dist/types/resources/auth/index.d.ts +1 -1
  14. package/dist/types/resources/auth/passkeys.d.ts +127 -163
  15. package/dist/types/resources/auth/sessions.d.ts +110 -152
  16. package/dist/types/resources/chests.d.ts +27 -31
  17. package/dist/types/resources/dynamicQrs.d.ts +29 -45
  18. package/dist/types/resources/forms.d.ts +37 -58
  19. package/dist/types/resources/jobs.d.ts +28 -40
  20. package/dist/types/resources/media.d.ts +48 -61
  21. package/dist/types/resources/music/artists.d.ts +179 -245
  22. package/dist/types/resources/music/imports.d.ts +181 -210
  23. package/dist/types/resources/music/index.d.ts +8 -7
  24. package/dist/types/resources/music/playlists.d.ts +77 -110
  25. package/dist/types/resources/music/social.d.ts +153 -228
  26. package/dist/types/resources/music/songs.d.ts +160 -206
  27. package/dist/types/resources/realtime.d.ts +75 -88
  28. package/dist/types/resources/shortLinks.d.ts +33 -45
  29. package/dist/types/resources/storage/upload.d.ts +42 -56
  30. package/dist/types/resources/storage.d.ts +71 -104
  31. package/dist/types/resources/tools/backgroundRemoval.d.ts +11 -13
  32. package/dist/types/resources/tools/captions.d.ts +107 -135
  33. package/dist/types/resources/tools/upscale.d.ts +12 -16
  34. package/dist/types/types.d.ts +29 -38
  35. package/package.json +1 -1
@@ -2,31 +2,28 @@
2
2
  * The `music.imports` namespace: getting audio INTO the library, and keeping a
3
3
  * Spotify account mirrored into it.
4
4
  *
5
- * Four unrelated backends sit behind one namespace because they are the four
5
+ * Four route families sit behind one namespace because they are the four
6
6
  * doors a track can walk through:
7
7
  *
8
8
  * | Route family | What it is | Who may call it |
9
9
  * |---|---|---|
10
- * | `/song_imports` | one track, downloaded by the yt-dlp sidecar | any signed-in user |
10
+ * | `/song_imports` | one track, downloaded from a URL or found by search | any signed-in user |
11
11
  * | `/playlist_imports/preview` | read a playlist URL without importing it | any signed-in user |
12
- * | `/spotify_syncs/*` | mirror a Spotify account into playlists | Dev-Mode allowlisted users only |
12
+ * | `/spotify_syncs/*` | mirror a Spotify account into playlists | Spotify-enabled accounts only |
13
13
  * | `/s_r_machine/*` | raw fetch + transcode helpers | **admins only** |
14
14
  *
15
15
  * ## An import is not a `Job`
16
16
  *
17
- * `POST /song_imports` answers a row of the `song_imports` table, not a row of
18
- * the generic `jobs` table, and there is no `job_id` anywhere on it. So
19
- * `oms.jobs.get()` will never find it and `watch_token` means nothing here.
17
+ * `POST /song_imports` answers a song import, not a generic job, and there is
18
+ * no `job_id` anywhere on it. So `oms.jobs.get()` will never find it and
19
+ * `watch_token` means nothing here.
20
20
  *
21
- * What this namespace DOES take from `oms.jobs` is the loop: {@link
22
- * MusicImportsNamespace.wait} and {@link MusicImportsNamespace.watch} are
23
- * `pollUntilTerminal` / `watchUntilTerminal` from `../jobs` with `poll` bound
24
- * to `GET /song_imports/:id` and `terminal` bound to
25
- * {@link isSongImportTerminal}. Same backoff, same `waitTimeoutMs`, same
26
- * `signal` semantics, same two `OmsTimeoutError` codes. There is exactly one
27
- * polling engine in this SDK and this is not a second one. The same is true of
28
- * {@link SpotifySyncNamespace.waitForSync}, which binds `poll` to
29
- * `GET /spotify_syncs/status`.
21
+ * What this namespace DOES share with `oms.jobs` is the polling loop:
22
+ * {@link MusicImportsNamespace.wait} and {@link MusicImportsNamespace.watch}
23
+ * poll `GET /song_imports/:id` until {@link isSongImportTerminal}, with the
24
+ * same backoff, the same `waitTimeoutMs`, the same `signal` semantics and the
25
+ * same two `OmsTimeoutError` codes. {@link SpotifySyncNamespace.waitForSync}
26
+ * does the same against `GET /spotify_syncs/status`.
30
27
  *
31
28
  * Three scales, all called "progress", none of them the same number:
32
29
  *
@@ -41,16 +38,13 @@
41
38
  *
42
39
  * Everything here sits under the general authenticated ceiling of 600/min
43
40
  * except {@link MusicImportsNamespace.previewPlaylist}, which carries TWO
44
- * budgets of its own and is the only endpoint in this file that has already
45
- * been used to take the site down. Read its doc comment before writing a loop
46
- * around it.
41
+ * budgets of its own. Read its doc comment before writing a loop around it.
47
42
  *
48
43
  * ## OAuth tokens cannot reach any of this
49
44
  *
50
- * None of these controllers declares an `oauth_scope`, and
51
- * `enforce_oauth_scope!` denies by omission, so an OAuth access token gets
52
- * `403 {"error":"insufficient_scope"}` before the action runs. Imports need a
53
- * session cookie or a personal token, like the rest of music.
45
+ * An OAuth access token gets `403 {"error":"insufficient_scope"}` on every
46
+ * route here. Imports need a session cookie or a personal token, like the rest
47
+ * of music.
54
48
  */
55
49
  import { type ApiClient, Resource } from "../../http";
56
50
  import type { ListParams } from "../../listing";
@@ -73,7 +67,7 @@ export type SongImportId = number | string;
73
67
  * jobs namespace has a dead branch here and a missing one there.
74
68
  */
75
69
  export type SongImportState = "pending" | "processing" | "complete" | "failed";
76
- /** The four states, spelled the way `SongImport::STATES` spells them. */
70
+ /** The four states. */
77
71
  export declare const SONG_IMPORT_STATES: Readonly<{
78
72
  readonly pending: "pending";
79
73
  readonly processing: "processing";
@@ -81,8 +75,8 @@ export declare const SONG_IMPORT_STATES: Readonly<{
81
75
  readonly failed: "failed";
82
76
  }>;
83
77
  /**
84
- * The states `SongImport#terminal?` treats as final, and the ones
85
- * {@link MusicImportsNamespace.wait} stops on.
78
+ * The states that are final, and the ones {@link MusicImportsNamespace.wait}
79
+ * stops on.
86
80
  *
87
81
  * `"pending"` is deliberately absent even though a row can go BACK to it: see
88
82
  * {@link isSongImportTerminal}.
@@ -91,28 +85,26 @@ export declare const SONG_IMPORT_TERMINAL_STATES: readonly SongImportState[];
91
85
  /**
92
86
  * True once this state can never change again.
93
87
  *
94
- * The state machine is NOT monotonic on the way there. `SongImportJob` rescues
95
- * a transient sidecar error by writing the row back to `"pending"` with
96
- * `progress_pct: 0.0` so the retried run passes its own `terminal?` guard, so a
97
- * poller legitimately observes `processing -> pending -> processing ->
98
- * complete` and a progress bar legitimately goes backwards. Never latch a UI on
99
- * "it was processing, so it cannot be pending again"; read the state every time.
88
+ * The state machine is NOT monotonic on the way there. A transient download
89
+ * error puts the row back to `"pending"` with `progress_pct: 0.0` before a
90
+ * retry, so a poller legitimately observes `processing -> pending ->
91
+ * processing -> complete` and a progress bar legitimately goes backwards.
92
+ * Never latch a UI on "it was processing, so it cannot be pending again"; read
93
+ * the state every time.
100
94
  *
101
- * `"failed"`, by contrast, is only ever written when the job has decided NOT to
102
- * retry, so it really is the end.
95
+ * `"failed"`, by contrast, is only ever written when the server has decided
96
+ * NOT to retry, so it really is the end.
103
97
  */
104
98
  export declare function isSongImportTerminal(state: string): boolean;
105
99
  /**
106
- * One row of `song_imports`.
100
+ * One song import.
107
101
  *
108
- * `SongImportBlueprint` declares nineteen fields plus the three from
109
- * `ApplicationBlueprint`, so every key below is present on every response;
110
- * what varies is the value.
102
+ * Every key below is present on every response; what varies is the value.
111
103
  *
112
- * ## Five columns you can write but never read back
104
+ * ## Five fields you can write but never read back
113
105
  *
114
106
  * `search_artist`, `search_title`, `search_album`, `isrc` and `artwork_url` are
115
- * all accepted by `POST /song_imports` and NONE of them is in the blueprint.
107
+ * all accepted by `POST /song_imports` and NONE of them is echoed back.
116
108
  * `artwork_data_b64` likewise (and it is wiped from the row the moment the
117
109
  * import settles, deduped or not). So an import created in search mode answers
118
110
  * with `source_url: null` and no trace of what was searched for: if the caller
@@ -141,33 +133,32 @@ export interface SongImport extends Omit<BaseRecord, "id"> {
141
133
  /** The provider's own id for the track. Half of the dedupe key. */
142
134
  readonly source_id: string | null;
143
135
  /**
144
- * `"yt_dlp"` (the column default, and what every user-driven import is) or
145
- * `"spotify_sync"` (written by `SpotifyPlaylistSyncJob`, one row per track
146
- * per run). Never `"upload"` - that is a `Song.source_kind` value, not one of
147
- * these.
136
+ * `"yt_dlp"` (the default, and what every user-driven import is) or
137
+ * `"spotify_sync"` (written by the Spotify sync, one row per track per run).
138
+ * Never `"upload"` - that is a `Song.source_kind` value, not one of these.
148
139
  */
149
140
  readonly source_kind: string;
150
141
  readonly override_title: string | null;
151
142
  readonly override_artist: string | null;
152
143
  readonly override_album: string | null;
153
- /** Seconds, as a float. A hint the sidecar uses to pick between candidates. */
144
+ /** Seconds, as a float. A hint the downloader uses to pick between candidates. */
154
145
  readonly expected_duration_s: number | null;
155
146
  /** Requested position in {@link SongImport.playlist_id}. See the note on `create`. */
156
147
  readonly position: number | null;
157
- /** The yt-dlp sidecar's own job id. Diagnostics only; nothing here takes it. */
148
+ /** The downloader's own request id. Diagnostics only; nothing here takes it. */
158
149
  readonly sidecar_request_id: string | null;
159
150
  readonly state: SongImportState;
160
151
  /**
161
- * Free text from the sidecar (`"starting"`, `"complete"`, `"a repetir"`, or
162
- * whatever yt-dlp last said). Human-facing, not a state: switch on
152
+ * Free text from the downloader (`"starting"`, `"complete"`, `"a repetir"`,
153
+ * or whatever it last said). Human-facing, not a state: switch on
163
154
  * {@link SongImport.state}.
164
155
  */
165
156
  readonly progress_message: string | null;
166
157
  /**
167
158
  * A FLOAT between 0 and 1, not a percentage. `0.05` means 5%.
168
159
  *
169
- * The column is `float DEFAULT 0.0`, so it is a real number from the moment
170
- * the row exists. It can go DOWN - a transient retry resets it to `0.0`.
160
+ * It is a real number from the moment the row exists. It can go DOWN - a
161
+ * transient retry resets it to `0.0`.
171
162
  * Multiply by 100 before showing it, or use {@link songImportProgress}.
172
163
  */
173
164
  readonly progress_pct: number;
@@ -193,22 +184,21 @@ export interface SongImport extends Omit<BaseRecord, "id"> {
193
184
  * Exactly one of the two modes must be satisfied, and the server checks it in
194
185
  * this order:
195
186
  *
196
- * 1. **URL mode** - `sourceUrl` present. It must pass `SsrfGuard.
197
- * public_http_url?`: http(s) only, public DNS only, no redirect to a private
198
- * address. Anything else is `400 "source_url is not allowed"`.
187
+ * 1. **URL mode** - `sourceUrl` present. It must be a public http(s) URL:
188
+ * public DNS only, no redirect to a private address. Anything else is
189
+ * `400 "source_url is not allowed"`.
199
190
  * 2. **Search mode** - `searchArtist` AND `searchTitle` both non-blank, with no
200
- * `sourceUrl`. The sidecar goes and finds the track itself.
191
+ * `sourceUrl`. The server goes and finds the track itself.
201
192
  *
202
193
  * Neither one satisfied is `400 "source_url or (search_artist + search_title)
203
194
  * required"`. One without the other counts as neither: `searchArtist` alone is
204
195
  * not search mode.
205
196
  *
206
- * Every other field is optional and every unknown field is dropped in silence
207
- * by the controller's `create_params` allowlist, so a typo here is a
208
- * successful import that ignored what you asked for.
197
+ * Every other field is optional and every unknown field is dropped in silence,
198
+ * so a typo here is a successful import that ignored what you asked for.
209
199
  */
210
200
  export interface CreateSongImportInput {
211
- /** URL mode. Public http(s) only; see the SSRF note above. */
201
+ /** URL mode. Public http(s) only; see the note above. */
212
202
  readonly sourceUrl?: string;
213
203
  /** Search mode. Needs {@link CreateSongImportInput.searchTitle} beside it. */
214
204
  readonly searchArtist?: string;
@@ -228,22 +218,21 @@ export interface CreateSongImportInput {
228
218
  readonly sourceId?: string;
229
219
  /**
230
220
  * Defaults server-side to `"yt_dlp"`. Passing `"spotify_sync"` by hand is a
231
- * bad idea: it changes the queue the job runs on (the slow bulk lane), it
232
- * suppresses the Discord alert, and it makes the row eligible for the
233
- * playlist-position rewriting the sync does.
221
+ * bad idea: it moves the import onto the slow bulk lane and makes the row
222
+ * eligible for the playlist-position rewriting the sync does.
234
223
  */
235
224
  readonly sourceKind?: string;
236
- /** Tag overrides written onto the finished song instead of what yt-dlp read. */
225
+ /** Tag overrides written onto the finished song instead of what the download carried. */
237
226
  readonly overrideTitle?: string;
238
227
  readonly overrideArtist?: string;
239
228
  readonly overrideAlbum?: string;
240
- /** Cover to embed. SSRF-guarded exactly like `sourceUrl`. */
229
+ /** Cover to embed. Must be a public http(s) URL, exactly like `sourceUrl`. */
241
230
  readonly artworkUrl?: string;
242
231
  /**
243
- * Cover as base64, for a picture that has no public URL. Goes into a `text`
244
- * column and is wiped as soon as the import settles, so keep it small: it is
245
- * a JSON body, not a multipart upload, and Cloudflare caps a request body at
246
- * roughly 100 MB well before Rails complains.
232
+ * Cover as base64, for a picture that has no public URL. Wiped as soon as
233
+ * the import settles. Keep it small: it is a JSON body, not a multipart
234
+ * upload, and the CDN in front of the API caps a request body at roughly
235
+ * 100 MB.
247
236
  */
248
237
  readonly artworkDataB64?: string;
249
238
  /** Seconds. A hint, not a constraint. */
@@ -266,7 +255,7 @@ export interface CreateSongImportInput {
266
255
  /**
267
256
  * Filters for {@link MusicImportsNamespace.list}.
268
257
  *
269
- * The controller allowlists six columns and nothing else. An unrecognised
258
+ * The server allowlists six columns and nothing else. An unrecognised
270
259
  * `search[...]` key is a `400` naming it, never a silently wider result.
271
260
  */
272
261
  export interface ListSongImportsParams extends ListParams<(typeof SONG_IMPORT_FILTER_COLUMNS)[number]> {
@@ -280,37 +269,36 @@ export interface ListSongImportsParams extends ListParams<(typeof SONG_IMPORT_FI
280
269
  export declare const SONG_IMPORT_FILTER_COLUMNS: readonly ["id", "state", "playlist_id", "user_id", "created_at", "updated_at"];
281
270
  /**
282
271
  * What `POST /playlist_imports/preview` answers: the SAME `DownloaderPreview`
283
- * shape `oms.tools.downloader.preview()` returns, because both call
284
- * `YtDlpClient.fetch_metadata` on the same sidecar.
272
+ * shape `oms.tools.downloader.preview()` returns, because both read metadata
273
+ * the same way.
285
274
  *
286
275
  * Check `kind` first: a `"playlist"` carries `count` and `tracks` and none of
287
276
  * the track fields, a `"track"` carries the track fields and neither of those.
288
- * Note this endpoint asks for `include_formats: false`, so `formats` is absent
289
- * even on a `"track"` - unlike the downloader's own preview.
277
+ * Note that `formats` is absent here even on a `"track"` - unlike the
278
+ * downloader's own preview.
290
279
  */
291
280
  export type PlaylistImportPreview = DownloaderPreview;
292
281
  /**
293
282
  * Sustained ceiling on `POST /playlist_imports/preview`: 60 an hour, keyed by
294
- * user id. A controller-level `rate_limit`, not rack-attack.
283
+ * user id.
295
284
  */
296
285
  export declare const PLAYLIST_IMPORT_PREVIEW_HOURLY_LIMIT = 60;
297
286
  /**
298
- * Burst ceiling on the same route: 20 a minute, from rack-attack's
299
- * `expensive_tools/client` bucket, SHARED with every other expensive tool
300
- * (`/upscales`, `/vocal_separations`, `/transcriptions`, `/caption_jobs`,
301
- * `/jumpstyle_jobs`, `/songs/:id/separate`, `/tools_downloader/*`).
287
+ * Burst ceiling on the same route: 20 a minute, from a bucket SHARED with
288
+ * every other expensive tool (`/upscales`, `/vocal_separations`,
289
+ * `/transcriptions`, `/caption_jobs`, `/jumpstyle_jobs`, `/songs/:id/separate`,
290
+ * `/tools_downloader/*`).
302
291
  */
303
292
  export declare const PLAYLIST_IMPORT_PREVIEW_BURST_LIMIT_PER_MINUTE = 20;
304
293
  /**
305
- * How long `SongImportJob` polls the sidecar before giving up on one import:
294
+ * How long the server waits on a download before giving up on one import:
306
295
  * ten minutes. A sensible floor for `waitTimeoutMs`, and the reason a shorter
307
296
  * one is a client-side deadline rather than a cancellation.
308
297
  */
309
298
  export declare const SONG_IMPORT_SERVER_TIMEOUT_MS = 600000;
310
299
  /**
311
- * Default pause between polls of a song import: 1.5s, which is what the app
312
- * uses. Slower than the jobs default because `SongImportJob` only writes
313
- * progress every 3 seconds anyway.
300
+ * Default pause between polls of a song import: 1.5s. Slower than the jobs
301
+ * default because progress is only written every 3 seconds anyway.
314
302
  */
315
303
  export declare const SONG_IMPORT_POLL_INTERVAL_MS = 1500;
316
304
  /**
@@ -324,7 +312,7 @@ export declare const SONG_IMPORT_POLL_INTERVAL_MS = 1500;
324
312
  export declare function songImportProgress(record: SongImport): Progress;
325
313
  /** The `music.imports` namespace, reachable as `oms.music.imports`. */
326
314
  export declare class MusicImportsNamespace extends Resource {
327
- /** Spotify account mirroring. Allowlisted accounts only - see the class. */
315
+ /** Spotify account mirroring. Spotify-enabled accounts only - see the class. */
328
316
  readonly spotify: SpotifySyncNamespace;
329
317
  /** Admin-only fetch and transcode helpers. */
330
318
  readonly srMachine: SRMachineNamespace;
@@ -332,12 +320,12 @@ export declare class MusicImportsNamespace extends Resource {
332
320
  /**
333
321
  * `GET /song_imports` - your import history, newest first only if you ask.
334
322
  *
335
- * Scoped `where(user: current_user)`, so `userId` can only ever narrow to
336
- * yourself and a foreign one answers an empty page rather than a 403.
323
+ * Scoped to the caller, so `userId` can only ever narrow to yourself and a
324
+ * foreign one answers an empty page rather than a 403.
337
325
  *
338
- * **The table is pruned.** `SongImportPruneJob` deletes `complete` and
339
- * `deduped` rows older than 30 days and `failed` rows older than 90, so this
340
- * is a recent-activity feed, not an archive. It is also dominated by
326
+ * **The list is pruned.** `complete` and `deduped` rows older than 30 days
327
+ * and `failed` rows older than 90 are deleted, so this is a recent-activity
328
+ * feed, not an archive. It is also dominated by
341
329
  * `spotify_sync` rows on any account with the sync on - one per track per
342
330
  * daily run, thousands a day - so filter by `state` or `playlistId` unless
343
331
  * you actually want that.
@@ -350,26 +338,26 @@ export declare class MusicImportsNamespace extends Resource {
350
338
  * `GET /song_imports/:id` - one read, no waiting.
351
339
  *
352
340
  * @throws {OmsApiError} 404 `"song import not found"` for an id that is not
353
- * yours, never a 403 - and for one the prune job has already deleted.
341
+ * yours, never a 403 - and for one that has already been pruned.
354
342
  */
355
343
  get(id: SongImportId, options?: RequestOptions): Promise<SongImport>;
356
344
  /**
357
345
  * `POST /song_imports` - 201 with the row, before any downloading starts.
358
346
  *
359
347
  * Returns immediately in every case. Either the row is already terminal
360
- * because dedupe hit ({@link SongImport.deduped}), or it is `"pending"` and a
361
- * `SongImportJob` has been enqueued. Nothing about this call waits for audio.
348
+ * because dedupe hit ({@link SongImport.deduped}), or it is `"pending"` and
349
+ * the download has been queued. Nothing about this call waits for audio.
362
350
  *
363
- * Which queue that job runs on depends on `sourceKind`: anything but
364
- * `"spotify_sync"` goes on the `:interactive` lane precisely so a person
365
- * watching a spinner is not queued behind the thousands of sync imports on
366
- * `:default`. Leave `sourceKind` alone and you get the good lane.
351
+ * Which lane the download runs on depends on `sourceKind`: anything but
352
+ * `"spotify_sync"` goes on the interactive lane precisely so a person
353
+ * watching a spinner is not queued behind thousands of sync imports. Leave
354
+ * `sourceKind` alone and you get the good lane.
367
355
  *
368
356
  * ## The 401 that is really a 403
369
357
  *
370
358
  * `playlistId` pointing at a playlist you cannot update answers
371
- * `401 "playlist not yours"` - the controller uses `unauthorized!` where it
372
- * means `forbidden!`. Two consequences: it is an {@link OmsAuthError}, not an
359
+ * `401 "playlist not yours"`, an authorization failure wearing an
360
+ * authentication status. Two consequences: it is an {@link OmsAuthError}, not an
373
361
  * {@link OmsApiError}, so a `catch` sorting by class puts it in the wrong
374
362
  * pile; and if the client was built with a token provider that implements
375
363
  * `onUnauthorized`, the transport spends one pointless refresh on it before
@@ -377,8 +365,8 @@ export declare class MusicImportsNamespace extends Resource {
377
365
  *
378
366
  * @throws {TypeError} before any request when neither mode is satisfied.
379
367
  * @throws {OmsApiError} 400 `"source_url is not allowed"` /
380
- * `"artwork_url is not allowed"` from the SSRF guard, `400` naming the
381
- * missing mode, `404 "playlist not found"`.
368
+ * `"artwork_url is not allowed"` for a URL that is not public http(s),
369
+ * `400` naming the missing mode, `404 "playlist not found"`.
382
370
  * @throws {OmsAuthError} 401 `"playlist not yours"`. See above.
383
371
  */
384
372
  create(input: CreateSongImportInput, options?: RequestOptions): Promise<SongImport>;
@@ -391,12 +379,12 @@ export declare class MusicImportsNamespace extends Resource {
391
379
  * semantics. `onProgress` is fed through {@link songImportProgress}, so its
392
380
  * `loaded` is out of 100 even though the wire value is out of 1.
393
381
  *
394
- * Resolves for `"failed"` as well as `"complete"`: a download the sidecar
382
+ * Resolves for `"failed"` as well as `"complete"`: a download the server
395
383
  * could not do is an ANSWER. Check `state` before reading `song_id`.
396
384
  *
397
- * `waitTimeoutMs` has no default here either. `SongImportJob` gives the
398
- * sidecar ten minutes ({@link SONG_IMPORT_SERVER_TIMEOUT_MS}) and may then be
399
- * retried, so a real import can outlive any deadline you pick; a deadline
385
+ * `waitTimeoutMs` has no default here either. The server gives a download
386
+ * ten minutes ({@link SONG_IMPORT_SERVER_TIMEOUT_MS}) and may then retry it,
387
+ * so a real import can outlive any deadline you pick; a deadline
400
388
  * here abandons the WAIT, never the import, which keeps running and can be
401
389
  * read later with {@link get}.
402
390
  *
@@ -431,25 +419,20 @@ export declare class MusicImportsNamespace extends Resource {
431
419
  * Nothing is written and nothing is enqueued, which is exactly why it is easy
432
420
  * to mistake for cheap.
433
421
  *
434
- * ## It is not cheap. It has been used to take the API down.
422
+ * ## It is not cheap
435
423
  *
436
- * Each call shells out to yt-dlp against a URL the CALLER chose and parks a
437
- * Puma thread for up to the sidecar's 60-second metadata timeout. There are
438
- * only `RAILS_MAX_THREADS` of those, shared with every other request the site
439
- * serves. On 2026-07-27 a load generator drove this endpoint at roughly 900
440
- * requests a minute with each one parked 20-40 seconds, and the whole API -
441
- * health check included - went down with it. At the time it was the one
442
- * yt-dlp-backed route with neither a rack-attack rule nor a controller-level
443
- * limit. It now has both:
424
+ * Each call fetches metadata for a URL the CALLER chose and holds a server
425
+ * thread for up to 60 seconds while it does. It therefore carries two
426
+ * budgets:
444
427
  *
445
428
  * - {@link PLAYLIST_IMPORT_PREVIEW_HOURLY_LIMIT} 60 an hour, keyed by user
446
- * id, from the controller. This one does NOT set `Retry-After`;
429
+ * id. This one does NOT set `Retry-After`;
447
430
  * - {@link PLAYLIST_IMPORT_PREVIEW_BURST_LIMIT_PER_MINUTE} 20 a minute, from
448
- * rack-attack's `expensive_tools` bucket, SHARED with upscale, background
449
- * removal, transcription, vocal separation, captions, jumpstyle,
450
- * `/songs/:id/separate` and the downloader. Importing a playlist while a
451
- * separation is running spends the same budget. This one does set
452
- * `Retry-After`, which the transport honours.
431
+ * a bucket SHARED with upscale, background removal, transcription, vocal
432
+ * separation, captions, jumpstyle, `/songs/:id/separate` and the
433
+ * downloader. Importing a playlist while a separation is running spends
434
+ * the same budget. This one does set `Retry-After`, which the transport
435
+ * honours.
453
436
  *
454
437
  * So: one preview per user action, never one per row of a list, and never
455
438
  * inside a retry loop. This method therefore does NOT retry by default -
@@ -457,11 +440,11 @@ export declare class MusicImportsNamespace extends Resource {
457
440
  * no, which a replay will not change. Pass `retry: {}` to opt back in.
458
441
  *
459
442
  * @throws {OmsApiError} 400 `"url is required"` when blank; 400
460
- * `"url is not allowed"` from the SSRF guard; 400 with a message pointing
461
- * at `/account/dashboard` for ANY `open.spotify.com` or `spotify.com` URL -
462
- * Spotify is never previewed here, it goes through
443
+ * `"url is not allowed"` for a URL that is not public http(s); 400 with a
444
+ * message pointing at `/account/dashboard` for ANY `open.spotify.com` or
445
+ * `spotify.com` URL - Spotify is never previewed here, it goes through
463
446
  * {@link MusicImportsNamespace.spotify}; 502 carrying the first 200
464
- * characters of whatever yt-dlp said.
447
+ * characters of the downloader's own error.
465
448
  * @throws {OmsQuotaError} 429 from either budget above.
466
449
  */
467
450
  previewPlaylist(url: string, options?: RequestOptions): Promise<PlaylistImportPreview>;
@@ -483,8 +466,8 @@ export type SpotifySyncPlaylistState = "pending" | "running" | "complete" | "fai
483
466
  *
484
467
  * `queued` and `skipped` count TRACKS, and together they are the walk's
485
468
  * progress against `total`. What they are not is downloads: `queued` means a
486
- * `song_imports` row was created and a `SongImportJob` enqueued on the slow
487
- * `:default` lane. The audio arrives minutes or hours later.
469
+ * song import was created on the slow bulk lane. The audio arrives minutes or
470
+ * hours later.
488
471
  */
489
472
  export interface SpotifySyncPlaylistProgress {
490
473
  /** Spotify's playlist id, or the literal `"liked"` for the liked-songs mirror. */
@@ -500,11 +483,10 @@ export interface SpotifySyncPlaylistProgress {
500
483
  }
501
484
  /**
502
485
  * Progress of the last (or current) sync run, stored on the Spotify identity
503
- * rather than in any job table.
486
+ * rather than as a job.
504
487
  *
505
- * Every key is optional here because a freshly linked identity carries `{}` -
506
- * the column defaults to an empty hash and nothing writes it until the first
507
- * sync starts. Treat a missing `state` as `"idle"`.
488
+ * Every key is optional here because a freshly linked identity carries `{}`
489
+ * until the first sync starts. Treat a missing `state` as `"idle"`.
508
490
  */
509
491
  export interface SpotifySyncProgress {
510
492
  readonly state?: SpotifySyncRunState;
@@ -515,7 +497,7 @@ export interface SpotifySyncProgress {
515
497
  * their text: `"Token refresh failed - please relink Spotify."` means the
516
498
  * refresh token is dead and the user must go through the link flow again,
517
499
  * while a stale run rewritten by {@link SpotifySyncNamespace.status} says
518
- * `"Sincronização interrompida"` (the backend writes that one in Portuguese).
500
+ * `"Sincronização interrompida"` (the server writes that one in Portuguese).
519
501
  */
520
502
  readonly error?: string | null;
521
503
  readonly playlists?: SpotifySyncPlaylistProgress[];
@@ -526,7 +508,7 @@ export interface SpotifySyncProgress {
526
508
  * All three keys are absent on an identity that has never been configured, and
527
509
  * absent is NOT `false` for any of them - each defaults differently:
528
510
  *
529
- * - `sync_liked` defaults to `true` (`fetch("sync_liked", true)`);
511
+ * - `sync_liked` defaults to `true`;
530
512
  * - `enabled_playlists` absent or `null` means EVERY eligible playlist, not
531
513
  * none. An empty array means none;
532
514
  * - `auto_sync` absent means "on if this identity has ever synced before",
@@ -572,9 +554,8 @@ export interface SpotifyPlaylistOption {
572
554
  readonly owner: string | null;
573
555
  readonly cover_url: string | null;
574
556
  /**
575
- * Whether this playlist is currently selected. Computed as
576
- * `enabled_playlists.nil? || enabled_playlists.include?(id)`, so on an
577
- * unconfigured identity EVERY row comes back `true`.
557
+ * Whether this playlist is currently selected. On an unconfigured identity
558
+ * (no `enabled_playlists` saved) EVERY row comes back `true`.
578
559
  */
579
560
  readonly enabled: boolean;
580
561
  }
@@ -583,9 +564,9 @@ export interface SpotifyPlaylistOption {
583
564
  *
584
565
  * The list is already filtered to what can actually be synced: playlists owned
585
566
  * by `"spotify"` (the editorial ones) and other people's non-collaborative
586
- * playlists are dropped, because Dev Mode plus the February 2026 API change
587
- * left us unable to read their tracks. A playlist the user can see in the
588
- * Spotify app and not here is that rule, not a bug.
567
+ * playlists are dropped, because Spotify does not let this integration read
568
+ * their tracks. A playlist the user can see in the Spotify app and not here is
569
+ * that rule, not a bug.
589
570
  */
590
571
  export interface SpotifySyncPreview {
591
572
  readonly sync_liked: boolean;
@@ -594,17 +575,16 @@ export interface SpotifySyncPreview {
594
575
  /**
595
576
  * Body of `PATCH /spotify_syncs/settings`.
596
577
  *
597
- * **Presence-sensitive, and two of the three keys delete data.** The updater
598
- * tests `params.key?`, so an omitted key is left alone and a present one is
599
- * applied - which means you cannot express "leave enabled_playlists alone" by
600
- * sending `null`, and you must send the WHOLE list every time rather than a
601
- * delta.
578
+ * **Presence-sensitive, and two of the three keys delete data.** An omitted
579
+ * key is left alone and a present one is applied - which means you cannot
580
+ * express "leave enabled_playlists alone" by sending `null`, and you must send
581
+ * the WHOLE list every time rather than a delta.
602
582
  *
603
583
  * The destruction is immediate and synchronous, inside the PATCH:
604
584
  *
605
585
  * - `enabledPlaylists` **destroys the local copy** of every synced playlist
606
586
  * whose Spotify id is not in the new list. Songs stay in the library; the
607
- * playlist row and its `playlist_songs` do not. Re-enabling it later
587
+ * playlist and its rows do not. Re-enabling it later
608
588
  * re-creates it from scratch on the next sync;
609
589
  * - `syncLiked: false` **destroys the local "liked" mirror** the same way.
610
590
  *
@@ -642,41 +622,36 @@ export interface StartSpotifySyncInput {
642
622
  */
643
623
  readonly playlistIds?: string[];
644
624
  }
645
- /**
646
- * A `"running"` sync older than this is treated as lost. Two hours, matching
647
- * `SpotifySyncsController::STALE_RUNNING_AFTER`.
648
- */
625
+ /** A `"running"` sync older than this is treated as lost. Two hours. */
649
626
  export declare const SPOTIFY_SYNC_STALE_AFTER_MS = 7200000;
650
627
  /** True while a sync run is in flight. Anything else - `"idle"` included - is not. */
651
628
  export declare function isSpotifySyncRunning(status: SpotifySyncStatus): boolean;
652
629
  /**
653
630
  * Spotify account mirroring, reachable as `oms.music.imports.spotify`.
654
631
  *
655
- * ## Every method here 403s unless the account is Dev-Mode allowlisted
632
+ * ## Every method here 403s unless Spotify is enabled for the account
656
633
  *
657
- * `before_action :require_spotify_allowed` gates the whole controller on the
658
- * `users.allowed_to_use_spotify` column and answers
659
- * `403 "Spotify is not enabled for this account"` without it. That is step one
660
- * of two, and the two fail in completely different places:
634
+ * Every route answers `403 "Spotify is not enabled for this account"` unless
635
+ * an administrator has enabled Spotify for the account. That is step one of
636
+ * two, and the two fail in completely different places:
661
637
  *
662
- * 1. **The database flag.** Without it, `GET /auth/link/spotify` refuses before
638
+ * 1. **The account flag.** Without it, `GET /auth/link/spotify` refuses before
663
639
  * it ever redirects to Spotify: the user is bounced straight back with
664
640
  * `?error=spotify_not_allowlisted`, having seen no Spotify screen at all.
665
641
  * Every method in this class also 403s.
666
- * 2. **The email in the Spotify dashboard's User Management list.** Our app is
667
- * still in Spotify's Development Mode, which admits at most 25 named users.
668
- * With the flag set but the email missing, the link flow LOOKS right up to
669
- * the last moment: the redirect to Spotify happens, the user signs in, and
670
- * then Spotify refuses the authorization itself. OmniAuth lands on
671
- * `/auth/failure` with `"not registered for this application"` and the user
672
- * is redirected back with the same `?error=spotify_not_allowlisted`, never
642
+ * 2. **Spotify's own allowlist.** The integration runs in Spotify's
643
+ * Development Mode, which admits at most 25 named users. With the flag set
644
+ * but the user not registered on Spotify's side, the link flow LOOKS right
645
+ * up to the last moment: the redirect to Spotify happens, the user signs
646
+ * in, and then Spotify refuses the authorization itself. The user is
647
+ * redirected back with the same `?error=spotify_not_allowlisted`, never
673
648
  * linked, with nothing in this API having recorded an attempt. From the
674
- * outside it looks like the login silently died - and the fix is not in this
675
- * codebase at all, it is in the Spotify dashboard.
649
+ * outside it looks like the login silently died, and the fix is on
650
+ * Spotify's side.
676
651
  *
677
- * Because step 2 fails outside our API, {@link SpotifySyncNamespace.status}
678
- * answering `{ connected: false }` on an allowlisted account is the normal
679
- * symptom of it. `connected: false` means "no identity row", which covers both
652
+ * Because step 2 fails outside this API, {@link SpotifySyncNamespace.status}
653
+ * answering `{ connected: false }` on an enabled account is the normal symptom
654
+ * of it. `connected: false` means "no linked identity", which covers both
680
655
  * "never tried" and "tried and Spotify said no".
681
656
  *
682
657
  * ## There is no sync id
@@ -693,15 +668,15 @@ export declare class SpotifySyncNamespace extends Resource {
693
668
  * `GET /spotify_syncs/status` - the whole state of the link in one call.
694
669
  *
695
670
  * **This GET writes.** If the stored progress says `"running"` and started
696
- * more than {@link SPOTIFY_SYNC_STALE_AFTER_MS} ago, the controller rewrites
697
- * it to `"failed"` before answering, because a worker that was SIGKILLed
698
- * leaves an eternal `"running"` behind and
671
+ * more than {@link SPOTIFY_SYNC_STALE_AFTER_MS} ago, the server rewrites it
672
+ * to `"failed"` before answering, because a run that died leaves an eternal
673
+ * `"running"` behind and
699
674
  * {@link SpotifySyncNamespace.start} refuses to queue another while one is
700
675
  * "in flight". Calling this is therefore how a stuck account gets unstuck -
701
676
  * which also means it is not safe to treat as a cacheable read.
702
677
  *
703
678
  * @throws {OmsApiError} 403 `"Spotify is not enabled for this account"` when
704
- * the account lacks the Dev-Mode flag. See the class comment.
679
+ * Spotify is not enabled for the account. See the class comment.
705
680
  */
706
681
  status(options?: RequestOptions): Promise<SpotifySyncStatus>;
707
682
  /**
@@ -717,7 +692,7 @@ export declare class SpotifySyncNamespace extends Resource {
717
692
  * @throws {OmsApiError} 404 `"link your Spotify account first"` when there is
718
693
  * no identity; 502 `"Spotify auth failed: ..."` when the refresh token is
719
694
  * dead (the user must relink); 502 with Spotify's own message for anything
720
- * else upstream; 403 without the Dev-Mode flag.
695
+ * else upstream; 403 when Spotify is not enabled for the account.
721
696
  */
722
697
  preview(options?: RequestOptions): Promise<SpotifySyncPreview>;
723
698
  /**
@@ -731,8 +706,8 @@ export declare class SpotifySyncNamespace extends Resource {
731
706
  * never deselected. Read {@link SpotifySyncNamespace.preview} first and send
732
707
  * back the ids you got from it.
733
708
  *
734
- * @throws {OmsApiError} 404 `"link your Spotify account first"`; 403 without
735
- * the Dev-Mode flag.
709
+ * @throws {OmsApiError} 404 `"link your Spotify account first"`; 403 when
710
+ * Spotify is not enabled for the account.
736
711
  */
737
712
  updateSettings(input: UpdateSpotifySyncSettingsInput, options?: RequestOptions): Promise<SpotifySyncSettingsResult>;
738
713
  /**
@@ -744,22 +719,22 @@ export declare class SpotifySyncNamespace extends Resource {
744
719
  * previous run. That is what makes {@link SpotifySyncNamespace.waitForSync}
745
720
  * race-free when it follows a `start()`.
746
721
  *
747
- * A manual run is not the same as the nightly one. It ignores the
748
- * `snapshot_id` shortcut and re-walks every enabled playlist even when
749
- * Spotify says nothing changed - it is the "I think something is out of step"
750
- * button - which is why it is much more expensive than the automatic sync and
751
- * why it should be user-initiated, never polled into.
722
+ * A manual run is not the same as the nightly one. It re-walks every
723
+ * enabled playlist even when Spotify says nothing changed - it is the "I
724
+ * think something is out of step" button - which is why it is much more
725
+ * expensive than the automatic sync and why it should be user-initiated,
726
+ * never polled into.
752
727
  *
753
- * What finishing means: `"complete"` says the walk is done and a
754
- * `song_imports` row exists for every new track. The downloads themselves are
755
- * still queued behind however many thousand rows the run just created, on the
756
- * `:default` lane. The library fills in for a long time afterwards.
728
+ * What finishing means: `"complete"` says the walk is done and a song import
729
+ * exists for every new track. The downloads themselves are still queued
730
+ * behind however many thousand imports the run just created. The library
731
+ * fills in for a long time afterwards.
757
732
  *
758
733
  * @throws {OmsApiError} 409 `"a sync is already running"` - unless the
759
734
  * running one is over two hours old, in which case
760
735
  * {@link SpotifySyncNamespace.status} rewrites it to failed first and this
761
- * then succeeds; 404 `"link your Spotify account first"`; 403 without the
762
- * Dev-Mode flag.
736
+ * then succeeds; 404 `"link your Spotify account first"`; 403 when Spotify
737
+ * is not enabled for the account.
763
738
  */
764
739
  start(input?: StartSpotifySyncInput, options?: RequestOptions): Promise<SpotifySyncQueued>;
765
740
  /**
@@ -789,11 +764,12 @@ export declare class SpotifySyncNamespace extends Resource {
789
764
  * count (the liked mirror always does until it finishes).
790
765
  *
791
766
  * Polling here is also what clears a lost `"running"`, since `status` rewrites
792
- * a stale one - so a wait against a dead worker ends after two hours rather
793
- * than never.
767
+ * a stale one - so a wait against a run that died ends after two hours
768
+ * rather than never.
794
769
  *
795
770
  * @throws {OmsTimeoutError} `code: "timeout"` / `"aborted"`, as everywhere.
796
- * @throws {OmsApiError} 403 without the Dev-Mode flag, on the first poll.
771
+ * @throws {OmsApiError} 403 when Spotify is not enabled for the account, on
772
+ * the first poll.
797
773
  */
798
774
  waitForSync(options?: WaitOptions): Promise<SpotifySyncStatus>;
799
775
  /**
@@ -822,9 +798,9 @@ export interface SRMachineMetadata {
822
798
  *
823
799
  * ## Admin only, and the check is blunt
824
800
  *
825
- * `before_action :gatekeep` answers `403 "You SHALL NOT use this resource"` to
826
- * anyone who is not an administrator. There is no allowlist, no scope and no
827
- * per-user quota; the whole guard is `Current.user.admin?`.
801
+ * Every route answers `403 "You SHALL NOT use this resource"` to anyone who is
802
+ * not an administrator. There is no allowlist, no scope and no per-user
803
+ * quota.
828
804
  *
829
805
  * ## What it is
830
806
  *
@@ -835,12 +811,11 @@ export interface SRMachineMetadata {
835
811
  * the response body. If you want a track IN the library, use
836
812
  * {@link MusicImportsNamespace.create} instead; this is the raw pipe.
837
813
  *
838
- * Three of the four shell out to yt-dlp against a caller-supplied URL and hold
839
- * a Puma thread while they do it, exactly like
840
- * {@link MusicImportsNamespace.previewPlaylist} - but note they are NOT in
841
- * rack-attack's `expensive_tools` pattern and have no controller-level limit
842
- * either, so the only ceiling is the general 600/min. The admin gate is what
843
- * stands in for a budget here. Do not build a batch loop on top of these.
814
+ * Three of the four fetch a caller-supplied URL and hold a server thread while
815
+ * they do it, exactly like {@link MusicImportsNamespace.previewPlaylist} - but
816
+ * they have no budget of their own, so the only ceiling is the general
817
+ * 600/min. The admin gate is what stands in for a budget here. Do not build a
818
+ * batch loop on top of these.
844
819
  *
845
820
  * A YouTube URL carrying `?list=` is truncated at the `?list=` before the fetch,
846
821
  * so a link copied from inside a playlist resolves to the single video rather
@@ -852,20 +827,19 @@ export interface SRMachineMetadata {
852
827
  */
853
828
  export declare class SRMachineNamespace extends Resource {
854
829
  /**
855
- * `GET /s_r_machine/metadata` - the title and artist yt-dlp reads off a URL.
830
+ * `GET /s_r_machine/metadata` - the title and artist read off a URL.
856
831
  *
857
832
  * Both keys can be `null` for a source that carries no tags; it is a
858
833
  * best-effort read, not a lookup.
859
834
  *
860
- * Not retried by default: it parks a thread for up to the sidecar's
861
- * 60-second metadata timeout, and a failure means the source refused.
835
+ * Not retried by default: it parks a server thread for up to 60 seconds,
836
+ * and a failure means the source refused.
862
837
  *
863
838
  * @throws {OmsAuthError} 403 `"You SHALL NOT use this resource"` for a
864
839
  * non-admin.
865
- * @throws {OmsApiError} 400 `"url is not allowed"` from the SSRF guard; 500
866
- * when yt-dlp itself blows up - this controller has no `bad_gateway!`
867
- * rescue, so an upstream failure surfaces as a server error AND fires a
868
- * Discord alert.
840
+ * @throws {OmsApiError} 400 `"url is not allowed"` for a URL that is not
841
+ * public http(s); 500 when the fetch itself fails - an upstream failure
842
+ * surfaces as a server error here, not as a 502.
869
843
  */
870
844
  metadata(url: string, options?: RequestOptions): Promise<SRMachineMetadata>;
871
845
  /**
@@ -901,27 +875,24 @@ export declare class SRMachineNamespace extends Resource {
901
875
  * Multipart, field name `file`, and the only method in this namespace that
902
876
  * uploads. The response is `audio/opus` bytes, not JSON.
903
877
  *
904
- * Works on all three clients: a React Native `{ uri, name, type }` descriptor
878
+ * Works on all three runtimes: a React Native `{ uri, name, type }` descriptor
905
879
  * goes into the `FormData` verbatim and is streamed off disk by the native
906
880
  * layer, while a browser or Bun caller passes a `FileInput` carrying a Blob
907
881
  * or a `Uint8Array`. A `ReadableStream` is buffered first, because
908
882
  * `FormData` has no streaming entry.
909
883
  *
910
884
  * The upload has no client-side size cap here because the server declares
911
- * none - but Cloudflare sits in front of production and rejects a request
912
- * body over roughly 100 MB with a 413 that never reaches Rails. There is no
913
- * chunked path for this route, so a file above that simply cannot go through
914
- * it.
885
+ * none - but the CDN in front of the API rejects a request body over roughly
886
+ * 100 MB with a 413 that never reaches it. There is no chunked path for this
887
+ * route, so a file above that simply cannot go through it.
915
888
  *
916
- * The server reads the whole part into memory (`params[:file].read`) and
917
- * hands it to ffmpeg, so a large input is a large allocation on the Mac Mini
918
- * as well as on the caller.
889
+ * The server reads the whole part into memory before transcoding, so a large
890
+ * input is a large allocation on both sides.
919
891
  *
920
892
  * @throws {OmsAuthError} 403 `"You SHALL NOT use this resource"` for a
921
893
  * non-admin.
922
- * @throws {OmsApiError} 500 when no `file` part was sent (`params[:file]` is
923
- * `nil` and `.read` raises) or when ffmpeg refuses the input. Neither is a
924
- * graceful 400.
894
+ * @throws {OmsApiError} 500 when no `file` part was sent or when the input
895
+ * cannot be transcoded. Neither is a graceful 400.
925
896
  * @throws {TypeError} when a React Native descriptor is passed on a runtime
926
897
  * whose `FormData` is the web one, which would otherwise upload the literal
927
898
  * text `"[object Object]"` and answer 500.