@omelhorsite/sdk 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -130
- package/dist/index.js +4800 -610
- package/dist/types/client.d.ts +60 -3
- package/dist/types/http.d.ts +444 -19
- package/dist/types/index.d.ts +5 -1
- package/dist/types/internal/attachments.d.ts +17 -0
- package/dist/types/internal/helpers.d.ts +9 -0
- package/dist/types/listing.d.ts +90 -0
- package/dist/types/resources/account.d.ts +71 -5
- package/dist/types/resources/admin/authorizedApplications.d.ts +133 -0
- package/dist/types/resources/admin/chests.d.ts +51 -0
- package/dist/types/resources/admin/eventAlerts.d.ts +60 -0
- package/dist/types/resources/admin/identities.d.ts +99 -0
- package/dist/types/resources/admin/index.d.ts +77 -0
- package/dist/types/resources/admin/jobs.d.ts +97 -0
- package/dist/types/resources/admin/myOauthApplications.d.ts +282 -0
- package/dist/types/resources/admin/notepads.d.ts +49 -0
- package/dist/types/resources/admin/oauthApplications.d.ts +309 -0
- package/dist/types/resources/admin/quotas.d.ts +114 -0
- package/dist/types/resources/admin/shortLinks.d.ts +201 -0
- package/dist/types/resources/admin/types.d.ts +222 -0
- package/dist/types/resources/admin/vocalSeparations.d.ts +98 -0
- package/dist/types/resources/admin.d.ts +1841 -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 +844 -0
- package/dist/types/resources/chests.d.ts +54 -3
- package/dist/types/resources/content/analysis.d.ts +62 -0
- package/dist/types/resources/content/blogs.d.ts +471 -0
- package/dist/types/resources/content/feedbacks.d.ts +263 -0
- package/dist/types/resources/content/index.d.ts +52 -0
- package/dist/types/resources/content/intel/articles.d.ts +230 -0
- package/dist/types/resources/content/intel/config.d.ts +135 -0
- package/dist/types/resources/content/intel/index.d.ts +53 -0
- package/dist/types/resources/content/intel/items.d.ts +91 -0
- package/dist/types/resources/content/intel/reports.d.ts +108 -0
- package/dist/types/resources/content/intel/scripts.d.ts +152 -0
- package/dist/types/resources/content/intel/sources.d.ts +236 -0
- package/dist/types/resources/content/intel/stats.d.ts +105 -0
- package/dist/types/resources/content/intel/types.d.ts +86 -0
- package/dist/types/resources/content/jokes.d.ts +93 -0
- package/dist/types/resources/content/notifications.d.ts +147 -0
- package/dist/types/resources/content/serviceUsages.d.ts +73 -0
- package/dist/types/resources/content/servicesStatus.d.ts +194 -0
- package/dist/types/resources/content/siteConfig.d.ts +43 -0
- package/dist/types/resources/content/spaceInvaders.d.ts +154 -0
- package/dist/types/resources/content.d.ts +2775 -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 +69 -25
- package/dist/types/resources/library/annotations.d.ts +162 -0
- package/dist/types/resources/library/books.d.ts +534 -0
- package/dist/types/resources/library/chat.d.ts +259 -0
- package/dist/types/resources/library/index.d.ts +39 -0
- package/dist/types/resources/library/shelves.d.ts +237 -0
- package/dist/types/resources/library/types.d.ts +120 -0
- package/dist/types/resources/library.d.ts +1433 -0
- package/dist/types/resources/linkTrees.d.ts +142 -30
- package/dist/types/resources/media.d.ts +351 -0
- package/dist/types/resources/movies/addons.d.ts +388 -0
- package/dist/types/resources/movies/collections.d.ts +352 -0
- package/dist/types/resources/movies/index.d.ts +29 -0
- package/dist/types/resources/movies/types.d.ts +50 -0
- package/dist/types/resources/movies/watchProgress.d.ts +277 -0
- package/dist/types/resources/movies.d.ts +1160 -0
- package/dist/types/resources/music/artists.d.ts +1068 -0
- package/dist/types/resources/music/imports.d.ts +941 -0
- package/dist/types/resources/music/index.d.ts +61 -0
- package/dist/types/resources/music/playlists.d.ts +1027 -0
- package/dist/types/resources/music/social.d.ts +1132 -0
- package/dist/types/resources/music/songs.d.ts +1167 -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 +49 -5
- package/dist/types/resources/social/groupChats.d.ts +458 -0
- package/dist/types/resources/social/index.d.ts +19 -0
- package/dist/types/resources/social/messages.d.ts +424 -0
- package/dist/types/resources/social/relationships.d.ts +322 -0
- package/dist/types/resources/social/types.d.ts +37 -0
- package/dist/types/resources/social.d.ts +1348 -0
- package/dist/types/resources/storage/upload.d.ts +158 -11
- package/dist/types/resources/storage.d.ts +111 -27
- package/dist/types/resources/tickets.d.ts +101 -13
- 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,941 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `music.imports` namespace: getting audio INTO the library, and keeping a
|
|
3
|
+
* Spotify account mirrored into it.
|
|
4
|
+
*
|
|
5
|
+
* Four unrelated backends sit behind one namespace because they are the four
|
|
6
|
+
* doors a track can walk through:
|
|
7
|
+
*
|
|
8
|
+
* | Route family | What it is | Who may call it |
|
|
9
|
+
* |---|---|---|
|
|
10
|
+
* | `/song_imports` | one track, downloaded by the yt-dlp sidecar | any signed-in user |
|
|
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 |
|
|
13
|
+
* | `/s_r_machine/*` | raw fetch + transcode helpers | **admins only** |
|
|
14
|
+
*
|
|
15
|
+
* ## An import is not a `Job`
|
|
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.
|
|
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`.
|
|
30
|
+
*
|
|
31
|
+
* Three scales, all called "progress", none of them the same number:
|
|
32
|
+
*
|
|
33
|
+
* - `Job.progress` is an INTEGER 0..100;
|
|
34
|
+
* - {@link SongImport.progress_pct} is a FLOAT 0..1;
|
|
35
|
+
* - a Spotify sync has no percentage at all, only per-playlist counters.
|
|
36
|
+
*
|
|
37
|
+
* {@link songImportProgress} does the conversion so a shared progress bar is
|
|
38
|
+
* not fed a 0.42 where it wanted a 42.
|
|
39
|
+
*
|
|
40
|
+
* ## Rate limits, per method
|
|
41
|
+
*
|
|
42
|
+
* Everything here sits under the general authenticated ceiling of 600/min
|
|
43
|
+
* 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.
|
|
47
|
+
*
|
|
48
|
+
* ## OAuth tokens cannot reach any of this
|
|
49
|
+
*
|
|
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.
|
|
54
|
+
*/
|
|
55
|
+
import { type ApiClient, Resource } from "../../http";
|
|
56
|
+
import type { ListParams } from "../../listing";
|
|
57
|
+
import type { BaseRecord, FileInput, FileOutput, Id, NativeFile, Paginated, Progress, RequestOptions, Timestamp, WaitOptions } from "../../types";
|
|
58
|
+
import type { DownloaderPreview } from "../tools/downloader";
|
|
59
|
+
/**
|
|
60
|
+
* Primary key of a song import. An **integer**, like `songs` and `playlists`
|
|
61
|
+
* and unlike `users` or `fs_nodes`.
|
|
62
|
+
*
|
|
63
|
+
* Both spellings are accepted as an argument because a caller that read the id
|
|
64
|
+
* out of a URL is holding a string, but the JSON these methods RETURN always
|
|
65
|
+
* carries a number.
|
|
66
|
+
*/
|
|
67
|
+
export type SongImportId = number | string;
|
|
68
|
+
/**
|
|
69
|
+
* Where a `song_imports` row is in its life.
|
|
70
|
+
*
|
|
71
|
+
* Four values, and note what is NOT among them: there is no `"canceled"`. A
|
|
72
|
+
* `jobs` row has one, a song import does not, so a `switch` ported from the
|
|
73
|
+
* jobs namespace has a dead branch here and a missing one there.
|
|
74
|
+
*/
|
|
75
|
+
export type SongImportState = "pending" | "processing" | "complete" | "failed";
|
|
76
|
+
/** The four states, spelled the way `SongImport::STATES` spells them. */
|
|
77
|
+
export declare const SONG_IMPORT_STATES: Readonly<{
|
|
78
|
+
readonly pending: "pending";
|
|
79
|
+
readonly processing: "processing";
|
|
80
|
+
readonly complete: "complete";
|
|
81
|
+
readonly failed: "failed";
|
|
82
|
+
}>;
|
|
83
|
+
/**
|
|
84
|
+
* The states `SongImport#terminal?` treats as final, and the ones
|
|
85
|
+
* {@link MusicImportsNamespace.wait} stops on.
|
|
86
|
+
*
|
|
87
|
+
* `"pending"` is deliberately absent even though a row can go BACK to it: see
|
|
88
|
+
* {@link isSongImportTerminal}.
|
|
89
|
+
*/
|
|
90
|
+
export declare const SONG_IMPORT_TERMINAL_STATES: readonly SongImportState[];
|
|
91
|
+
/**
|
|
92
|
+
* True once this state can never change again.
|
|
93
|
+
*
|
|
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.
|
|
100
|
+
*
|
|
101
|
+
* `"failed"`, by contrast, is only ever written when the job has decided NOT to
|
|
102
|
+
* retry, so it really is the end.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isSongImportTerminal(state: string): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* One row of `song_imports`.
|
|
107
|
+
*
|
|
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.
|
|
111
|
+
*
|
|
112
|
+
* ## Five columns you can write but never read back
|
|
113
|
+
*
|
|
114
|
+
* `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.
|
|
116
|
+
* `artwork_data_b64` likewise (and it is wiped from the row the moment the
|
|
117
|
+
* import settles, deduped or not). So an import created in search mode answers
|
|
118
|
+
* with `source_url: null` and no trace of what was searched for: if the caller
|
|
119
|
+
* needs to show "importing <artist> - <title>", it has to keep that string
|
|
120
|
+
* itself. This is the single most common surprise in this namespace.
|
|
121
|
+
*/
|
|
122
|
+
export interface SongImport extends Omit<BaseRecord, "id"> {
|
|
123
|
+
/** Integer primary key. See {@link SongImportId}. */
|
|
124
|
+
readonly id: number;
|
|
125
|
+
/** Owner. A string uuid, sitting next to an integer `id`. */
|
|
126
|
+
readonly user_id: Id;
|
|
127
|
+
/** Playlist the finished track is filed into, when one was asked for. */
|
|
128
|
+
readonly playlist_id: number | null;
|
|
129
|
+
/**
|
|
130
|
+
* The resulting song, once there is one. `null` while the import runs and
|
|
131
|
+
* forever if it fails.
|
|
132
|
+
*
|
|
133
|
+
* On a deduped import this is set at CREATE time, in the same response as the
|
|
134
|
+
* 201: see {@link SongImport.deduped}.
|
|
135
|
+
*/
|
|
136
|
+
readonly song_id: number | null;
|
|
137
|
+
/** `null` on a search-mode import, which is a legal way to create one. */
|
|
138
|
+
readonly source_url: string | null;
|
|
139
|
+
/** `"youtube"`, `"spotify"`, `"soundcloud"`, ... Free text, not an enum. */
|
|
140
|
+
readonly source_provider: string | null;
|
|
141
|
+
/** The provider's own id for the track. Half of the dedupe key. */
|
|
142
|
+
readonly source_id: string | null;
|
|
143
|
+
/**
|
|
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.
|
|
148
|
+
*/
|
|
149
|
+
readonly source_kind: string;
|
|
150
|
+
readonly override_title: string | null;
|
|
151
|
+
readonly override_artist: string | null;
|
|
152
|
+
readonly override_album: string | null;
|
|
153
|
+
/** Seconds, as a float. A hint the sidecar uses to pick between candidates. */
|
|
154
|
+
readonly expected_duration_s: number | null;
|
|
155
|
+
/** Requested position in {@link SongImport.playlist_id}. See the note on `create`. */
|
|
156
|
+
readonly position: number | null;
|
|
157
|
+
/** The yt-dlp sidecar's own job id. Diagnostics only; nothing here takes it. */
|
|
158
|
+
readonly sidecar_request_id: string | null;
|
|
159
|
+
readonly state: SongImportState;
|
|
160
|
+
/**
|
|
161
|
+
* Free text from the sidecar (`"starting"`, `"complete"`, `"a repetir"`, or
|
|
162
|
+
* whatever yt-dlp last said). Human-facing, not a state: switch on
|
|
163
|
+
* {@link SongImport.state}.
|
|
164
|
+
*/
|
|
165
|
+
readonly progress_message: string | null;
|
|
166
|
+
/**
|
|
167
|
+
* A FLOAT between 0 and 1, not a percentage. `0.05` means 5%.
|
|
168
|
+
*
|
|
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`.
|
|
171
|
+
* Multiply by 100 before showing it, or use {@link songImportProgress}.
|
|
172
|
+
*/
|
|
173
|
+
readonly progress_pct: number;
|
|
174
|
+
/** Set when `state === "failed"`. Truncated to ~240 characters server-side. */
|
|
175
|
+
readonly error_message: string | null;
|
|
176
|
+
/**
|
|
177
|
+
* The import matched a track already in the library and no download
|
|
178
|
+
* happened.
|
|
179
|
+
*
|
|
180
|
+
* Dedupe runs INSIDE the create request, before anything is enqueued, in
|
|
181
|
+
* this order: `isrc`, then `source_provider` + `source_id`, then
|
|
182
|
+
* `source_url`. On a hit the 201 body is ALREADY terminal -
|
|
183
|
+
* `state: "complete"`, `progress_pct: 1.0`, `song_id` set, `deduped: true` -
|
|
184
|
+
* and the existing song is filed into the target playlist. Polling such a row
|
|
185
|
+
* is harmless but pointless; {@link MusicImportsNamespace.createAndWait}
|
|
186
|
+
* short-circuits on it.
|
|
187
|
+
*/
|
|
188
|
+
readonly deduped: boolean;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Body of `POST /song_imports`.
|
|
192
|
+
*
|
|
193
|
+
* Exactly one of the two modes must be satisfied, and the server checks it in
|
|
194
|
+
* this order:
|
|
195
|
+
*
|
|
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"`.
|
|
199
|
+
* 2. **Search mode** - `searchArtist` AND `searchTitle` both non-blank, with no
|
|
200
|
+
* `sourceUrl`. The sidecar goes and finds the track itself.
|
|
201
|
+
*
|
|
202
|
+
* Neither one satisfied is `400 "source_url or (search_artist + search_title)
|
|
203
|
+
* required"`. One without the other counts as neither: `searchArtist` alone is
|
|
204
|
+
* not search mode.
|
|
205
|
+
*
|
|
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.
|
|
209
|
+
*/
|
|
210
|
+
export interface CreateSongImportInput {
|
|
211
|
+
/** URL mode. Public http(s) only; see the SSRF note above. */
|
|
212
|
+
readonly sourceUrl?: string;
|
|
213
|
+
/** Search mode. Needs {@link CreateSongImportInput.searchTitle} beside it. */
|
|
214
|
+
readonly searchArtist?: string;
|
|
215
|
+
/** Search mode. Needs {@link CreateSongImportInput.searchArtist} beside it. */
|
|
216
|
+
readonly searchTitle?: string;
|
|
217
|
+
/** Narrows a search. Optional even in search mode. */
|
|
218
|
+
readonly searchAlbum?: string;
|
|
219
|
+
/**
|
|
220
|
+
* Recording identifier. The FIRST dedupe key tried, and the reason a
|
|
221
|
+
* Spotify-driven re-sync finds the YouTube-sourced download it already has.
|
|
222
|
+
* Pass it whenever you have one.
|
|
223
|
+
*/
|
|
224
|
+
readonly isrc?: string;
|
|
225
|
+
/** Free text (`"spotify"`, `"youtube"`). Half of the second dedupe key. */
|
|
226
|
+
readonly sourceProvider?: string;
|
|
227
|
+
/** The provider's id. Only used for dedupe alongside `sourceProvider`. */
|
|
228
|
+
readonly sourceId?: string;
|
|
229
|
+
/**
|
|
230
|
+
* 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.
|
|
234
|
+
*/
|
|
235
|
+
readonly sourceKind?: string;
|
|
236
|
+
/** Tag overrides written onto the finished song instead of what yt-dlp read. */
|
|
237
|
+
readonly overrideTitle?: string;
|
|
238
|
+
readonly overrideArtist?: string;
|
|
239
|
+
readonly overrideAlbum?: string;
|
|
240
|
+
/** Cover to embed. SSRF-guarded exactly like `sourceUrl`. */
|
|
241
|
+
readonly artworkUrl?: string;
|
|
242
|
+
/**
|
|
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.
|
|
247
|
+
*/
|
|
248
|
+
readonly artworkDataB64?: string;
|
|
249
|
+
/** Seconds. A hint, not a constraint. */
|
|
250
|
+
readonly expectedDurationS?: number;
|
|
251
|
+
/**
|
|
252
|
+
* File the finished track into this playlist. It must be yours: a foreign or
|
|
253
|
+
* missing id is `404 "playlist not found"`, and one you cannot update is
|
|
254
|
+
* `401 "playlist not yours"` - see {@link MusicImportsNamespace.create} for
|
|
255
|
+
* why that 401 is worth knowing about.
|
|
256
|
+
*/
|
|
257
|
+
readonly playlistId?: number | string;
|
|
258
|
+
/**
|
|
259
|
+
* Position inside that playlist. Left out, the track lands after the last
|
|
260
|
+
* one - and on a `spotify_sync` playlist it lands in the manual block at or
|
|
261
|
+
* above `PLAYLIST_MANUAL_BLOCK_FLOOR` (100000), where the next sync will
|
|
262
|
+
* leave it alone.
|
|
263
|
+
*/
|
|
264
|
+
readonly position?: number;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Filters for {@link MusicImportsNamespace.list}.
|
|
268
|
+
*
|
|
269
|
+
* The controller allowlists six columns and nothing else. An unrecognised
|
|
270
|
+
* `search[...]` key is a `400` naming it, never a silently wider result.
|
|
271
|
+
*/
|
|
272
|
+
export interface ListSongImportsParams extends ListParams<(typeof SONG_IMPORT_FILTER_COLUMNS)[number]> {
|
|
273
|
+
readonly state?: SongImportState | readonly SongImportState[];
|
|
274
|
+
readonly playlistId?: number | string | ReadonlyArray<number | string>;
|
|
275
|
+
/** Only ever your own id: the listing is scoped to the caller. */
|
|
276
|
+
readonly userId?: Id;
|
|
277
|
+
readonly id?: SongImportId | readonly SongImportId[];
|
|
278
|
+
}
|
|
279
|
+
/** Filter columns of `GET /song_imports`. */
|
|
280
|
+
export declare const SONG_IMPORT_FILTER_COLUMNS: readonly ["id", "state", "playlist_id", "user_id", "created_at", "updated_at"];
|
|
281
|
+
/**
|
|
282
|
+
* 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.
|
|
285
|
+
*
|
|
286
|
+
* Check `kind` first: a `"playlist"` carries `count` and `tracks` and none of
|
|
287
|
+
* 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.
|
|
290
|
+
*/
|
|
291
|
+
export type PlaylistImportPreview = DownloaderPreview;
|
|
292
|
+
/**
|
|
293
|
+
* Sustained ceiling on `POST /playlist_imports/preview`: 60 an hour, keyed by
|
|
294
|
+
* user id. A controller-level `rate_limit`, not rack-attack.
|
|
295
|
+
*/
|
|
296
|
+
export declare const PLAYLIST_IMPORT_PREVIEW_HOURLY_LIMIT = 60;
|
|
297
|
+
/**
|
|
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/*`).
|
|
302
|
+
*/
|
|
303
|
+
export declare const PLAYLIST_IMPORT_PREVIEW_BURST_LIMIT_PER_MINUTE = 20;
|
|
304
|
+
/**
|
|
305
|
+
* How long `SongImportJob` polls the sidecar before giving up on one import:
|
|
306
|
+
* ten minutes. A sensible floor for `waitTimeoutMs`, and the reason a shorter
|
|
307
|
+
* one is a client-side deadline rather than a cancellation.
|
|
308
|
+
*/
|
|
309
|
+
export declare const SONG_IMPORT_SERVER_TIMEOUT_MS = 600000;
|
|
310
|
+
/**
|
|
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.
|
|
314
|
+
*/
|
|
315
|
+
export declare const SONG_IMPORT_POLL_INTERVAL_MS = 1500;
|
|
316
|
+
/**
|
|
317
|
+
* Renders a {@link SongImport} as the SDK's shared {@link Progress}.
|
|
318
|
+
*
|
|
319
|
+
* The whole point is the scale change: `progress_pct` is a 0..1 float and
|
|
320
|
+
* `Progress.loaded` is compared against `total`, so this multiplies by 100 and
|
|
321
|
+
* reports out of 100, matching what `oms.jobs` reports for a real job. A UI can
|
|
322
|
+
* then feed both to the same bar.
|
|
323
|
+
*/
|
|
324
|
+
export declare function songImportProgress(record: SongImport): Progress;
|
|
325
|
+
/** The `music.imports` namespace, reachable as `oms.music.imports`. */
|
|
326
|
+
export declare class MusicImportsNamespace extends Resource {
|
|
327
|
+
/** Spotify account mirroring. Allowlisted accounts only - see the class. */
|
|
328
|
+
readonly spotify: SpotifySyncNamespace;
|
|
329
|
+
/** Admin-only fetch and transcode helpers. */
|
|
330
|
+
readonly srMachine: SRMachineNamespace;
|
|
331
|
+
constructor(http: ApiClient);
|
|
332
|
+
/**
|
|
333
|
+
* `GET /song_imports` - your import history, newest first only if you ask.
|
|
334
|
+
*
|
|
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.
|
|
337
|
+
*
|
|
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
|
|
341
|
+
* `spotify_sync` rows on any account with the sync on - one per track per
|
|
342
|
+
* daily run, thousands a day - so filter by `state` or `playlistId` unless
|
|
343
|
+
* you actually want that.
|
|
344
|
+
*
|
|
345
|
+
* @throws {OmsApiError} 400 naming the key when a filter is not in
|
|
346
|
+
* {@link SONG_IMPORT_FILTER_COLUMNS}.
|
|
347
|
+
*/
|
|
348
|
+
list(params?: ListSongImportsParams, options?: RequestOptions): Promise<Paginated<SongImport>>;
|
|
349
|
+
/**
|
|
350
|
+
* `GET /song_imports/:id` - one read, no waiting.
|
|
351
|
+
*
|
|
352
|
+
* @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.
|
|
354
|
+
*/
|
|
355
|
+
get(id: SongImportId, options?: RequestOptions): Promise<SongImport>;
|
|
356
|
+
/**
|
|
357
|
+
* `POST /song_imports` - 201 with the row, before any downloading starts.
|
|
358
|
+
*
|
|
359
|
+
* 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.
|
|
362
|
+
*
|
|
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.
|
|
367
|
+
*
|
|
368
|
+
* ## The 401 that is really a 403
|
|
369
|
+
*
|
|
370
|
+
* `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
|
|
373
|
+
* {@link OmsApiError}, so a `catch` sorting by class puts it in the wrong
|
|
374
|
+
* pile; and if the client was built with a token provider that implements
|
|
375
|
+
* `onUnauthorized`, the transport spends one pointless refresh on it before
|
|
376
|
+
* giving up. Test the message, not just the status.
|
|
377
|
+
*
|
|
378
|
+
* @throws {TypeError} before any request when neither mode is satisfied.
|
|
379
|
+
* @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"`.
|
|
382
|
+
* @throws {OmsAuthError} 401 `"playlist not yours"`. See above.
|
|
383
|
+
*/
|
|
384
|
+
create(input: CreateSongImportInput, options?: RequestOptions): Promise<SongImport>;
|
|
385
|
+
/**
|
|
386
|
+
* Polls `GET /song_imports/:id` until the row is `"complete"` or `"failed"`,
|
|
387
|
+
* then returns it.
|
|
388
|
+
*
|
|
389
|
+
* This is `pollUntilTerminal` from the `jobs` namespace with a different
|
|
390
|
+
* `poll` - the same backoff, the same deadline handling, the same abort
|
|
391
|
+
* semantics. `onProgress` is fed through {@link songImportProgress}, so its
|
|
392
|
+
* `loaded` is out of 100 even though the wire value is out of 1.
|
|
393
|
+
*
|
|
394
|
+
* Resolves for `"failed"` as well as `"complete"`: a download the sidecar
|
|
395
|
+
* could not do is an ANSWER. Check `state` before reading `song_id`.
|
|
396
|
+
*
|
|
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
|
|
400
|
+
* here abandons the WAIT, never the import, which keeps running and can be
|
|
401
|
+
* read later with {@link get}.
|
|
402
|
+
*
|
|
403
|
+
* @throws {OmsTimeoutError} `code: "timeout"` when `waitTimeoutMs` elapses,
|
|
404
|
+
* `code: "aborted"` when `signal` fires.
|
|
405
|
+
* @throws {OmsApiError} 404 if the row is deleted while waiting.
|
|
406
|
+
*/
|
|
407
|
+
wait(id: SongImportId, options?: WaitOptions): Promise<SongImport>;
|
|
408
|
+
/**
|
|
409
|
+
* Yields the row on every poll until it settles, for a host that would rather
|
|
410
|
+
* render each step than take a callback.
|
|
411
|
+
*
|
|
412
|
+
* The terminal row is both the last value yielded and the generator's return
|
|
413
|
+
* value. Breaking out of the `for await` stops the loop and leaves nothing
|
|
414
|
+
* running client-side; the import itself carries on.
|
|
415
|
+
*/
|
|
416
|
+
watch(id: SongImportId, options?: WaitOptions): AsyncGenerator<SongImport, SongImport, undefined>;
|
|
417
|
+
/**
|
|
418
|
+
* {@link create} then {@link wait}, with the dedupe short-circuit already
|
|
419
|
+
* handled: a deduped 201 comes back `"complete"` and this returns it without
|
|
420
|
+
* a single extra request.
|
|
421
|
+
*
|
|
422
|
+
* The convenience most callers actually want. It is a plain composition of
|
|
423
|
+
* the two public methods and adds no polling of its own.
|
|
424
|
+
*/
|
|
425
|
+
createAndWait(input: CreateSongImportInput, options?: WaitOptions): Promise<SongImport>;
|
|
426
|
+
/**
|
|
427
|
+
* `POST /playlist_imports/preview` - read a URL's metadata without importing
|
|
428
|
+
* anything.
|
|
429
|
+
*
|
|
430
|
+
* Answers a `"track"` or a `"playlist"`; check `kind` before reading the rest.
|
|
431
|
+
* Nothing is written and nothing is enqueued, which is exactly why it is easy
|
|
432
|
+
* to mistake for cheap.
|
|
433
|
+
*
|
|
434
|
+
* ## It is not cheap. It has been used to take the API down.
|
|
435
|
+
*
|
|
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:
|
|
444
|
+
*
|
|
445
|
+
* - {@link PLAYLIST_IMPORT_PREVIEW_HOURLY_LIMIT} 60 an hour, keyed by user
|
|
446
|
+
* id, from the controller. This one does NOT set `Retry-After`;
|
|
447
|
+
* - {@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.
|
|
453
|
+
*
|
|
454
|
+
* So: one preview per user action, never one per row of a list, and never
|
|
455
|
+
* inside a retry loop. This method therefore does NOT retry by default -
|
|
456
|
+
* every attempt burns one of the 60 - and a 502 here means an upstream said
|
|
457
|
+
* no, which a replay will not change. Pass `retry: {}` to opt back in.
|
|
458
|
+
*
|
|
459
|
+
* @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
|
|
463
|
+
* {@link MusicImportsNamespace.spotify}; 502 carrying the first 200
|
|
464
|
+
* characters of whatever yt-dlp said.
|
|
465
|
+
* @throws {OmsQuotaError} 429 from either budget above.
|
|
466
|
+
*/
|
|
467
|
+
previewPlaylist(url: string, options?: RequestOptions): Promise<PlaylistImportPreview>;
|
|
468
|
+
/** The one description of "watching an import", shared by `wait` and `watch`. */
|
|
469
|
+
private pollPlan;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* State of a whole sync run, as it appears in
|
|
473
|
+
* {@link SpotifySyncProgress.state}.
|
|
474
|
+
*
|
|
475
|
+
* `"idle"` is what an identity that has never synced carries. There is no
|
|
476
|
+
* `"canceled"`: a run cannot be stopped once queued.
|
|
477
|
+
*/
|
|
478
|
+
export type SpotifySyncRunState = "idle" | "running" | "complete" | "failed";
|
|
479
|
+
/** State of one playlist inside a run. Note there is no `"idle"` at this level. */
|
|
480
|
+
export type SpotifySyncPlaylistState = "pending" | "running" | "complete" | "failed";
|
|
481
|
+
/**
|
|
482
|
+
* Per-playlist counters inside a running sync.
|
|
483
|
+
*
|
|
484
|
+
* `queued` and `skipped` count TRACKS, and together they are the walk's
|
|
485
|
+
* 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.
|
|
488
|
+
*/
|
|
489
|
+
export interface SpotifySyncPlaylistProgress {
|
|
490
|
+
/** Spotify's playlist id, or the literal `"liked"` for the liked-songs mirror. */
|
|
491
|
+
readonly id: string;
|
|
492
|
+
readonly name: string;
|
|
493
|
+
/** Track count Spotify reported. `null` for `"liked"`, which is not known up front. */
|
|
494
|
+
readonly total: number | null;
|
|
495
|
+
/** Tracks that produced a new import. */
|
|
496
|
+
readonly queued: number;
|
|
497
|
+
/** Tracks dedupe matched against a song already in the library. */
|
|
498
|
+
readonly skipped: number;
|
|
499
|
+
readonly state: SpotifySyncPlaylistState;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Progress of the last (or current) sync run, stored on the Spotify identity
|
|
503
|
+
* rather than in any job table.
|
|
504
|
+
*
|
|
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"`.
|
|
508
|
+
*/
|
|
509
|
+
export interface SpotifySyncProgress {
|
|
510
|
+
readonly state?: SpotifySyncRunState;
|
|
511
|
+
readonly started_at?: Timestamp | null;
|
|
512
|
+
readonly finished_at?: Timestamp | null;
|
|
513
|
+
/**
|
|
514
|
+
* Failure reason once `state === "failed"`. Two are worth telling apart by
|
|
515
|
+
* their text: `"Token refresh failed - please relink Spotify."` means the
|
|
516
|
+
* refresh token is dead and the user must go through the link flow again,
|
|
517
|
+
* while a stale run rewritten by {@link SpotifySyncNamespace.status} says
|
|
518
|
+
* `"Sincronização interrompida"` (the backend writes that one in Portuguese).
|
|
519
|
+
*/
|
|
520
|
+
readonly error?: string | null;
|
|
521
|
+
readonly playlists?: SpotifySyncPlaylistProgress[];
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The persisted sync settings.
|
|
525
|
+
*
|
|
526
|
+
* All three keys are absent on an identity that has never been configured, and
|
|
527
|
+
* absent is NOT `false` for any of them - each defaults differently:
|
|
528
|
+
*
|
|
529
|
+
* - `sync_liked` defaults to `true` (`fetch("sync_liked", true)`);
|
|
530
|
+
* - `enabled_playlists` absent or `null` means EVERY eligible playlist, not
|
|
531
|
+
* none. An empty array means none;
|
|
532
|
+
* - `auto_sync` absent means "on if this identity has ever synced before",
|
|
533
|
+
* which is why {@link SpotifySyncStatus.sync_settings} always carries a real
|
|
534
|
+
* boolean for it: `status` resolves that rule server-side before answering.
|
|
535
|
+
*/
|
|
536
|
+
export interface SpotifySyncSettings {
|
|
537
|
+
readonly sync_liked?: boolean;
|
|
538
|
+
/** `null` or absent means all eligible playlists. `[]` means none. */
|
|
539
|
+
readonly enabled_playlists?: string[] | null;
|
|
540
|
+
readonly auto_sync?: boolean;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* What `GET /spotify_syncs/status` answers.
|
|
544
|
+
*
|
|
545
|
+
* A discriminated union in practice: when `connected` is `false` that is the
|
|
546
|
+
* ONLY key in the body, so every other field has to be read behind a check on
|
|
547
|
+
* it.
|
|
548
|
+
*/
|
|
549
|
+
export interface SpotifySyncStatus {
|
|
550
|
+
/** `false` when the account has no linked Spotify identity. */
|
|
551
|
+
readonly connected: boolean;
|
|
552
|
+
/** Identity row id, a string uuid. Absent when disconnected. */
|
|
553
|
+
readonly identity_id?: Id;
|
|
554
|
+
/** Spotify display name as it was at link time; not refreshed. */
|
|
555
|
+
readonly spotify_user_name?: string | null;
|
|
556
|
+
/** Stamped only by a run that finished cleanly. */
|
|
557
|
+
readonly last_synced_at?: Timestamp | null;
|
|
558
|
+
/**
|
|
559
|
+
* The persisted settings, with `auto_sync` always resolved to a real boolean
|
|
560
|
+
* even when the stored hash has no such key.
|
|
561
|
+
*/
|
|
562
|
+
readonly sync_settings?: SpotifySyncSettings;
|
|
563
|
+
readonly sync_progress?: SpotifySyncProgress;
|
|
564
|
+
}
|
|
565
|
+
/** One row of the playlist toggle list from `GET /spotify_syncs/preview`. */
|
|
566
|
+
export interface SpotifyPlaylistOption {
|
|
567
|
+
readonly id: string;
|
|
568
|
+
readonly name: string;
|
|
569
|
+
/** `null` when Spotify did not report one. */
|
|
570
|
+
readonly track_count: number | null;
|
|
571
|
+
/** Owner's display name, not their id. */
|
|
572
|
+
readonly owner: string | null;
|
|
573
|
+
readonly cover_url: string | null;
|
|
574
|
+
/**
|
|
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`.
|
|
578
|
+
*/
|
|
579
|
+
readonly enabled: boolean;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* What `GET /spotify_syncs/preview` answers.
|
|
583
|
+
*
|
|
584
|
+
* The list is already filtered to what can actually be synced: playlists owned
|
|
585
|
+
* 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.
|
|
589
|
+
*/
|
|
590
|
+
export interface SpotifySyncPreview {
|
|
591
|
+
readonly sync_liked: boolean;
|
|
592
|
+
readonly playlists: SpotifyPlaylistOption[];
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Body of `PATCH /spotify_syncs/settings`.
|
|
596
|
+
*
|
|
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.
|
|
602
|
+
*
|
|
603
|
+
* The destruction is immediate and synchronous, inside the PATCH:
|
|
604
|
+
*
|
|
605
|
+
* - `enabledPlaylists` **destroys the local copy** of every synced playlist
|
|
606
|
+
* 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
|
|
608
|
+
* re-creates it from scratch on the next sync;
|
|
609
|
+
* - `syncLiked: false` **destroys the local "liked" mirror** the same way.
|
|
610
|
+
*
|
|
611
|
+
* Confirm with the user before sending either. There is no undo and no
|
|
612
|
+
* soft-delete.
|
|
613
|
+
*/
|
|
614
|
+
export interface UpdateSpotifySyncSettingsInput {
|
|
615
|
+
/** The COMPLETE list of Spotify playlist ids to keep synced. See above. */
|
|
616
|
+
readonly enabledPlaylists?: string[];
|
|
617
|
+
/** Mirror the user's Spotify liked songs. Turning it off deletes the mirror. */
|
|
618
|
+
readonly syncLiked?: boolean;
|
|
619
|
+
/** Whether the nightly dispatcher picks this identity up. */
|
|
620
|
+
readonly autoSync?: boolean;
|
|
621
|
+
}
|
|
622
|
+
/** What `PATCH /spotify_syncs/settings` answers: `ok` plus the persisted hash. */
|
|
623
|
+
export interface SpotifySyncSettingsResult {
|
|
624
|
+
readonly ok: boolean;
|
|
625
|
+
readonly sync_settings: SpotifySyncSettings;
|
|
626
|
+
}
|
|
627
|
+
/** What `POST /spotify_syncs` answers. Note there is no id of any kind. */
|
|
628
|
+
export interface SpotifySyncQueued {
|
|
629
|
+
readonly ok: boolean;
|
|
630
|
+
/** Server clock at enqueue time. Not a handle: nothing accepts it back. */
|
|
631
|
+
readonly queued_at: Timestamp;
|
|
632
|
+
}
|
|
633
|
+
/** Body of `POST /spotify_syncs`. */
|
|
634
|
+
export interface StartSpotifySyncInput {
|
|
635
|
+
/**
|
|
636
|
+
* Sync only these Spotify playlist ids, ignoring the saved selection for this
|
|
637
|
+
* one run. Omit to use the saved `enabled_playlists`, and note that an
|
|
638
|
+
* unconfigured identity therefore means "all of them".
|
|
639
|
+
*
|
|
640
|
+
* This does not persist: the next automatic run reads the saved settings
|
|
641
|
+
* again.
|
|
642
|
+
*/
|
|
643
|
+
readonly playlistIds?: string[];
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* A `"running"` sync older than this is treated as lost. Two hours, matching
|
|
647
|
+
* `SpotifySyncsController::STALE_RUNNING_AFTER`.
|
|
648
|
+
*/
|
|
649
|
+
export declare const SPOTIFY_SYNC_STALE_AFTER_MS = 7200000;
|
|
650
|
+
/** True while a sync run is in flight. Anything else - `"idle"` included - is not. */
|
|
651
|
+
export declare function isSpotifySyncRunning(status: SpotifySyncStatus): boolean;
|
|
652
|
+
/**
|
|
653
|
+
* Spotify account mirroring, reachable as `oms.music.imports.spotify`.
|
|
654
|
+
*
|
|
655
|
+
* ## Every method here 403s unless the account is Dev-Mode allowlisted
|
|
656
|
+
*
|
|
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:
|
|
661
|
+
*
|
|
662
|
+
* 1. **The database flag.** Without it, `GET /auth/link/spotify` refuses before
|
|
663
|
+
* it ever redirects to Spotify: the user is bounced straight back with
|
|
664
|
+
* `?error=spotify_not_allowlisted`, having seen no Spotify screen at all.
|
|
665
|
+
* 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
|
|
673
|
+
* 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.
|
|
676
|
+
*
|
|
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
|
|
680
|
+
* "never tried" and "tried and Spotify said no".
|
|
681
|
+
*
|
|
682
|
+
* ## There is no sync id
|
|
683
|
+
*
|
|
684
|
+
* `POST /spotify_syncs` answers `{ ok, queued_at }`. Progress lives on the
|
|
685
|
+
* identity, one slot, overwritten by each run, and is read back through
|
|
686
|
+
* {@link SpotifySyncNamespace.status}. So two runs cannot be told apart, a
|
|
687
|
+
* finished run's report is destroyed by the next one starting, and there is
|
|
688
|
+
* nothing to hand to `oms.jobs`. {@link SpotifySyncNamespace.waitForSync} works
|
|
689
|
+
* within that limit rather than pretending otherwise.
|
|
690
|
+
*/
|
|
691
|
+
export declare class SpotifySyncNamespace extends Resource {
|
|
692
|
+
/**
|
|
693
|
+
* `GET /spotify_syncs/status` - the whole state of the link in one call.
|
|
694
|
+
*
|
|
695
|
+
* **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
|
|
699
|
+
* {@link SpotifySyncNamespace.start} refuses to queue another while one is
|
|
700
|
+
* "in flight". Calling this is therefore how a stuck account gets unstuck -
|
|
701
|
+
* which also means it is not safe to treat as a cacheable read.
|
|
702
|
+
*
|
|
703
|
+
* @throws {OmsApiError} 403 `"Spotify is not enabled for this account"` when
|
|
704
|
+
* the account lacks the Dev-Mode flag. See the class comment.
|
|
705
|
+
*/
|
|
706
|
+
status(options?: RequestOptions): Promise<SpotifySyncStatus>;
|
|
707
|
+
/**
|
|
708
|
+
* `GET /spotify_syncs/preview` - the user's syncable playlists, each with its
|
|
709
|
+
* current toggle state.
|
|
710
|
+
*
|
|
711
|
+
* Walks Spotify's playlist pages live, so it is slow (seconds, and more on a
|
|
712
|
+
* large account) and it is the only method here that can fail because of
|
|
713
|
+
* somebody else's outage. Not retried by default for that reason: a 502 means
|
|
714
|
+
* Spotify said no, and hammering it is how an app-wide Spotify rate limit
|
|
715
|
+
* gets hit. Pass `retry: {}` to opt back in.
|
|
716
|
+
*
|
|
717
|
+
* @throws {OmsApiError} 404 `"link your Spotify account first"` when there is
|
|
718
|
+
* no identity; 502 `"Spotify auth failed: ..."` when the refresh token is
|
|
719
|
+
* dead (the user must relink); 502 with Spotify's own message for anything
|
|
720
|
+
* else upstream; 403 without the Dev-Mode flag.
|
|
721
|
+
*/
|
|
722
|
+
preview(options?: RequestOptions): Promise<SpotifySyncPreview>;
|
|
723
|
+
/**
|
|
724
|
+
* `PATCH /spotify_syncs/settings` - **destructive**. Read
|
|
725
|
+
* {@link UpdateSpotifySyncSettingsInput} before calling.
|
|
726
|
+
*
|
|
727
|
+
* Deselecting a playlist deletes its local copy in this same request, and
|
|
728
|
+
* `syncLiked: false` deletes the liked mirror. Only the keys you pass are
|
|
729
|
+
* touched, and `enabledPlaylists` is a full replacement rather than a delta,
|
|
730
|
+
* so building it from a stale preview is how a user loses playlists they
|
|
731
|
+
* never deselected. Read {@link SpotifySyncNamespace.preview} first and send
|
|
732
|
+
* back the ids you got from it.
|
|
733
|
+
*
|
|
734
|
+
* @throws {OmsApiError} 404 `"link your Spotify account first"`; 403 without
|
|
735
|
+
* the Dev-Mode flag.
|
|
736
|
+
*/
|
|
737
|
+
updateSettings(input: UpdateSpotifySyncSettingsInput, options?: RequestOptions): Promise<SpotifySyncSettingsResult>;
|
|
738
|
+
/**
|
|
739
|
+
* `POST /spotify_syncs` - queue a manual sync.
|
|
740
|
+
*
|
|
741
|
+
* The progress slot is flipped to `"running"` synchronously, BEFORE the
|
|
742
|
+
* response is written, so a poll that starts the moment this resolves is
|
|
743
|
+
* guaranteed to see `"running"` and never a stale `"complete"` from the
|
|
744
|
+
* previous run. That is what makes {@link SpotifySyncNamespace.waitForSync}
|
|
745
|
+
* race-free when it follows a `start()`.
|
|
746
|
+
*
|
|
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.
|
|
752
|
+
*
|
|
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.
|
|
757
|
+
*
|
|
758
|
+
* @throws {OmsApiError} 409 `"a sync is already running"` - unless the
|
|
759
|
+
* running one is over two hours old, in which case
|
|
760
|
+
* {@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.
|
|
763
|
+
*/
|
|
764
|
+
start(input?: StartSpotifySyncInput, options?: RequestOptions): Promise<SpotifySyncQueued>;
|
|
765
|
+
/**
|
|
766
|
+
* Polls {@link SpotifySyncNamespace.status} until the run stops being
|
|
767
|
+
* `"running"`, then returns the final status.
|
|
768
|
+
*
|
|
769
|
+
* Same engine as everything else that waits in this SDK -
|
|
770
|
+
* `pollUntilTerminal` from the `jobs` namespace - with `poll` bound to the
|
|
771
|
+
* status endpoint. There is no job id to watch, so this watches the one
|
|
772
|
+
* progress slot on the identity.
|
|
773
|
+
*
|
|
774
|
+
* Two consequences of that slot being the only handle:
|
|
775
|
+
*
|
|
776
|
+
* - **Call it after {@link SpotifySyncNamespace.start}, not before.** Started
|
|
777
|
+
* on an idle account it returns on the FIRST poll, because `"idle"` is not
|
|
778
|
+
* `"running"` and honestly reporting "nothing is running" beats hanging
|
|
779
|
+
* until the deadline. An account with no Spotify identity at all is
|
|
780
|
+
* terminal for the same reason: `{ connected: false }` carries no progress,
|
|
781
|
+
* so this answers it straight back rather than waiting for a run that
|
|
782
|
+
* cannot start;
|
|
783
|
+
* - it cannot tell your run from one the nightly dispatcher started thirty
|
|
784
|
+
* seconds earlier. If both are somehow in flight, this returns when
|
|
785
|
+
* whichever one owns the slot finishes.
|
|
786
|
+
*
|
|
787
|
+
* `onProgress` reports tracks seen against tracks expected, summed across
|
|
788
|
+
* playlists, with `total` left `undefined` while any playlist has a `null`
|
|
789
|
+
* count (the liked mirror always does until it finishes).
|
|
790
|
+
*
|
|
791
|
+
* 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.
|
|
794
|
+
*
|
|
795
|
+
* @throws {OmsTimeoutError} `code: "timeout"` / `"aborted"`, as everywhere.
|
|
796
|
+
* @throws {OmsApiError} 403 without the Dev-Mode flag, on the first poll.
|
|
797
|
+
*/
|
|
798
|
+
waitForSync(options?: WaitOptions): Promise<SpotifySyncStatus>;
|
|
799
|
+
/**
|
|
800
|
+
* Yields the status on every poll while a sync runs, for a host rendering a
|
|
801
|
+
* per-playlist progress list.
|
|
802
|
+
*
|
|
803
|
+
* The terminal status is both the last value yielded and the return value.
|
|
804
|
+
*/
|
|
805
|
+
watchSync(options?: WaitOptions): AsyncGenerator<SpotifySyncStatus, SpotifySyncStatus, undefined>;
|
|
806
|
+
/**
|
|
807
|
+
* {@link SpotifySyncNamespace.start} then
|
|
808
|
+
* {@link SpotifySyncNamespace.waitForSync}, which is race-free in that order
|
|
809
|
+
* for the reason given on `start`.
|
|
810
|
+
*/
|
|
811
|
+
startAndWait(input?: StartSpotifySyncInput, options?: WaitOptions): Promise<SpotifySyncStatus>;
|
|
812
|
+
/** The one description of "watching a sync", shared by the two watchers. */
|
|
813
|
+
private syncPollPlan;
|
|
814
|
+
}
|
|
815
|
+
/** What `GET /s_r_machine/metadata` answers. Two keys, both nullable. */
|
|
816
|
+
export interface SRMachineMetadata {
|
|
817
|
+
readonly title: string | null;
|
|
818
|
+
readonly artist: string | null;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* The SR Machine helpers, reachable as `oms.music.imports.srMachine`.
|
|
822
|
+
*
|
|
823
|
+
* ## Admin only, and the check is blunt
|
|
824
|
+
*
|
|
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?`.
|
|
828
|
+
*
|
|
829
|
+
* ## What it is
|
|
830
|
+
*
|
|
831
|
+
* Four unrelated primitives left over from the "slowed + reverb" video tool:
|
|
832
|
+
* fetch the artwork behind a URL, fetch its audio, read its title and artist,
|
|
833
|
+
* and transcode an arbitrary audio blob to Opus. Nothing here touches the
|
|
834
|
+
* library - no song is created, nothing is stored, and the bytes come back in
|
|
835
|
+
* the response body. If you want a track IN the library, use
|
|
836
|
+
* {@link MusicImportsNamespace.create} instead; this is the raw pipe.
|
|
837
|
+
*
|
|
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.
|
|
844
|
+
*
|
|
845
|
+
* A YouTube URL carrying `?list=` is truncated at the `?list=` before the fetch,
|
|
846
|
+
* so a link copied from inside a playlist resolves to the single video rather
|
|
847
|
+
* than the playlist. That happens server-side, in all three fetchers.
|
|
848
|
+
*
|
|
849
|
+
* All three fetchers respond with `Content-Disposition: attachment` and a
|
|
850
|
+
* fixed filename (`artwork.jpg`, `audio.opus`), so `FileOutput.filename` is
|
|
851
|
+
* that constant rather than anything derived from the source.
|
|
852
|
+
*/
|
|
853
|
+
export declare class SRMachineNamespace extends Resource {
|
|
854
|
+
/**
|
|
855
|
+
* `GET /s_r_machine/metadata` - the title and artist yt-dlp reads off a URL.
|
|
856
|
+
*
|
|
857
|
+
* Both keys can be `null` for a source that carries no tags; it is a
|
|
858
|
+
* best-effort read, not a lookup.
|
|
859
|
+
*
|
|
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.
|
|
862
|
+
*
|
|
863
|
+
* @throws {OmsAuthError} 403 `"You SHALL NOT use this resource"` for a
|
|
864
|
+
* 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.
|
|
869
|
+
*/
|
|
870
|
+
metadata(url: string, options?: RequestOptions): Promise<SRMachineMetadata>;
|
|
871
|
+
/**
|
|
872
|
+
* `GET /s_r_machine/artwork` - the cover behind a URL, as `image/jpeg`.
|
|
873
|
+
*
|
|
874
|
+
* Buffered fully into memory in every runtime, React Native included. It is a
|
|
875
|
+
* cover, so that is fine; {@link SRMachineNamespace.audio} is where it is not.
|
|
876
|
+
*
|
|
877
|
+
* @throws {OmsAuthError} 403 for a non-admin.
|
|
878
|
+
* @throws {OmsApiError} 400 `"url is not allowed"`; 500 on a fetch failure.
|
|
879
|
+
*/
|
|
880
|
+
artwork(url: string, options?: RequestOptions): Promise<FileOutput>;
|
|
881
|
+
/**
|
|
882
|
+
* `GET /s_r_machine/audio` - the audio behind a URL, as `audio/opus`.
|
|
883
|
+
*
|
|
884
|
+
* The whole track is downloaded server-side, held in memory there, and sent
|
|
885
|
+
* back in one body which this then buffers into memory again on the client.
|
|
886
|
+
* Nothing streams. On a phone that is a whole track in the JavaScript heap,
|
|
887
|
+
* and there is no signed-URL alternative here the way there is for library
|
|
888
|
+
* media - this endpoint has no storage node behind it. Reach for it on
|
|
889
|
+
* desktop, think twice on React Native, and never for a batch.
|
|
890
|
+
*
|
|
891
|
+
* Generous `timeoutMs` by default because a long track legitimately takes
|
|
892
|
+
* minutes.
|
|
893
|
+
*
|
|
894
|
+
* @throws {OmsAuthError} 403 for a non-admin.
|
|
895
|
+
* @throws {OmsApiError} 400 `"url is not allowed"`; 500 on a fetch failure.
|
|
896
|
+
*/
|
|
897
|
+
audio(url: string, options?: RequestOptions): Promise<FileOutput>;
|
|
898
|
+
/**
|
|
899
|
+
* `POST /s_r_machine/convert-opus` - transcode an audio file to Opus.
|
|
900
|
+
*
|
|
901
|
+
* Multipart, field name `file`, and the only method in this namespace that
|
|
902
|
+
* uploads. The response is `audio/opus` bytes, not JSON.
|
|
903
|
+
*
|
|
904
|
+
* Works on all three clients: a React Native `{ uri, name, type }` descriptor
|
|
905
|
+
* goes into the `FormData` verbatim and is streamed off disk by the native
|
|
906
|
+
* layer, while a browser or Bun caller passes a `FileInput` carrying a Blob
|
|
907
|
+
* or a `Uint8Array`. A `ReadableStream` is buffered first, because
|
|
908
|
+
* `FormData` has no streaming entry.
|
|
909
|
+
*
|
|
910
|
+
* 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.
|
|
915
|
+
*
|
|
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.
|
|
919
|
+
*
|
|
920
|
+
* @throws {OmsAuthError} 403 `"You SHALL NOT use this resource"` for a
|
|
921
|
+
* 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.
|
|
925
|
+
* @throws {TypeError} when a React Native descriptor is passed on a runtime
|
|
926
|
+
* whose `FormData` is the web one, which would otherwise upload the literal
|
|
927
|
+
* text `"[object Object]"` and answer 500.
|
|
928
|
+
*/
|
|
929
|
+
convertToOpus(file: FileInput | NativeFile, options?: RequestOptions): Promise<FileOutput>;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Renders a sync run as the SDK's shared {@link Progress}.
|
|
933
|
+
*
|
|
934
|
+
* `loaded` is tracks SEEN (`queued + skipped`) summed over every playlist, and
|
|
935
|
+
* `total` is the sum of the reported track counts - left `undefined` as soon as
|
|
936
|
+
* one playlist reports `null`, which the liked-songs mirror always does until
|
|
937
|
+
* it has been walked. An undefined total is what a UI should read as "spinner,
|
|
938
|
+
* not bar"; faking it with the partial sum would draw a bar that jumps
|
|
939
|
+
* backwards when the liked mirror starts.
|
|
940
|
+
*/
|
|
941
|
+
export declare function spotifySyncProgress(status: SpotifySyncStatus): Progress;
|