@royalti/syynk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,548 @@
1
+ // src/errors.ts
2
+ var SyynkError = class extends Error {
3
+ /** Error code for programmatic handling */
4
+ code;
5
+ /** HTTP status code */
6
+ status;
7
+ /** Additional error details from the API */
8
+ details;
9
+ constructor(message, code, status, details) {
10
+ super(message);
11
+ this.name = "SyynkError";
12
+ this.code = code;
13
+ this.status = status;
14
+ this.details = details;
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, this.constructor);
17
+ }
18
+ }
19
+ /**
20
+ * Convert error to a plain object for serialization
21
+ */
22
+ toJSON() {
23
+ return {
24
+ name: this.name,
25
+ message: this.message,
26
+ code: this.code,
27
+ status: this.status,
28
+ details: this.details
29
+ };
30
+ }
31
+ };
32
+ var AuthenticationError = class extends SyynkError {
33
+ constructor(message = "Invalid or missing API key", details) {
34
+ super(message, "AUTHENTICATION_ERROR", 401, details);
35
+ this.name = "AuthenticationError";
36
+ }
37
+ };
38
+ var AuthorizationError = class extends SyynkError {
39
+ constructor(message = "Insufficient permissions for this operation", details) {
40
+ super(message, "AUTHORIZATION_ERROR", 403, details);
41
+ this.name = "AuthorizationError";
42
+ }
43
+ };
44
+ var RateLimitError = class extends SyynkError {
45
+ /** Unix timestamp when the rate limit resets */
46
+ resetAt;
47
+ /** Seconds until the rate limit resets */
48
+ retryAfter;
49
+ constructor(message = "Rate limit exceeded", resetAt, details) {
50
+ super(message, "RATE_LIMIT_ERROR", 429, details);
51
+ this.name = "RateLimitError";
52
+ this.resetAt = resetAt;
53
+ this.retryAfter = Math.max(0, Math.ceil(resetAt - Date.now() / 1e3));
54
+ }
55
+ /**
56
+ * Get the Date object for when the rate limit resets
57
+ */
58
+ getResetDate() {
59
+ return new Date(this.resetAt * 1e3);
60
+ }
61
+ toJSON() {
62
+ return {
63
+ ...super.toJSON(),
64
+ resetAt: this.resetAt,
65
+ retryAfter: this.retryAfter
66
+ };
67
+ }
68
+ };
69
+ var ValidationError = class extends SyynkError {
70
+ /** Field-level validation errors */
71
+ fieldErrors;
72
+ constructor(message = "Invalid request data", details, fieldErrors) {
73
+ super(message, "VALIDATION_ERROR", 400, details);
74
+ this.name = "ValidationError";
75
+ this.fieldErrors = fieldErrors;
76
+ }
77
+ toJSON() {
78
+ return {
79
+ ...super.toJSON(),
80
+ fieldErrors: this.fieldErrors
81
+ };
82
+ }
83
+ };
84
+ var NotFoundError = class extends SyynkError {
85
+ /** Type of resource that wasn't found */
86
+ resourceType;
87
+ /** ID of the resource that wasn't found */
88
+ resourceId;
89
+ constructor(message = "Resource not found", resourceType, resourceId, details) {
90
+ super(message, "NOT_FOUND_ERROR", 404, details);
91
+ this.name = "NotFoundError";
92
+ this.resourceType = resourceType;
93
+ this.resourceId = resourceId;
94
+ }
95
+ toJSON() {
96
+ return {
97
+ ...super.toJSON(),
98
+ resourceType: this.resourceType,
99
+ resourceId: this.resourceId
100
+ };
101
+ }
102
+ };
103
+ var ServerError = class extends SyynkError {
104
+ /** Request ID for debugging */
105
+ requestId;
106
+ constructor(message = "Internal server error", status = 500, requestId, details) {
107
+ super(message, "SERVER_ERROR", status, details);
108
+ this.name = "ServerError";
109
+ this.requestId = requestId;
110
+ }
111
+ toJSON() {
112
+ return {
113
+ ...super.toJSON(),
114
+ requestId: this.requestId
115
+ };
116
+ }
117
+ };
118
+ var NetworkError = class extends SyynkError {
119
+ /** The original error that caused this network error */
120
+ cause;
121
+ constructor(message = "Network request failed", cause) {
122
+ super(message, "NETWORK_ERROR", 0);
123
+ this.name = "NetworkError";
124
+ this.cause = cause;
125
+ }
126
+ toJSON() {
127
+ return {
128
+ ...super.toJSON(),
129
+ cause: this.cause?.message
130
+ };
131
+ }
132
+ };
133
+ var TimeoutError = class extends SyynkError {
134
+ /** The timeout duration in milliseconds */
135
+ timeoutMs;
136
+ constructor(message = "Request timed out", timeoutMs) {
137
+ super(message, "TIMEOUT_ERROR", 0);
138
+ this.name = "TimeoutError";
139
+ this.timeoutMs = timeoutMs;
140
+ }
141
+ toJSON() {
142
+ return {
143
+ ...super.toJSON(),
144
+ timeoutMs: this.timeoutMs
145
+ };
146
+ }
147
+ };
148
+ function isSyynkError(error) {
149
+ return error instanceof SyynkError;
150
+ }
151
+ function isRateLimitError(error) {
152
+ return error instanceof RateLimitError;
153
+ }
154
+ function isRetryableError(error) {
155
+ if (error instanceof RateLimitError) return true;
156
+ if (error instanceof NetworkError) return true;
157
+ if (error instanceof TimeoutError) return true;
158
+ if (error instanceof ServerError && error.status >= 500) return true;
159
+ return false;
160
+ }
161
+
162
+ // src/client.ts
163
+ var DEFAULT_BASE_URL = "https://syynk.to";
164
+ var DEFAULT_TIMEOUT = 3e4;
165
+ var DEFAULT_MAX_RETRIES = 3;
166
+ var INITIAL_RETRY_DELAY = 1e3;
167
+ var SyynkClient = class {
168
+ apiKey;
169
+ baseUrl;
170
+ timeout;
171
+ maxRetries;
172
+ /** Last rate limit info received from the API */
173
+ lastRateLimitInfo = null;
174
+ /**
175
+ * Create a new Syynk API client
176
+ *
177
+ * @param options - Client configuration options
178
+ * @throws {Error} If apiKey is not provided
179
+ */
180
+ constructor(options) {
181
+ if (!options.apiKey) {
182
+ throw new Error("API key is required");
183
+ }
184
+ this.apiKey = options.apiKey;
185
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
186
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
187
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
188
+ }
189
+ /**
190
+ * Get the current rate limit information
191
+ *
192
+ * @returns The last rate limit info received, or null if no requests have been made
193
+ */
194
+ getRateLimitInfo() {
195
+ return this.lastRateLimitInfo;
196
+ }
197
+ /**
198
+ * Transcribe audio from a URL
199
+ *
200
+ * @param options - Transcription options
201
+ * @returns Transcription result with segments and words
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * const result = await client.transcribe({
206
+ * audioUrl: 'https://example.com/song.mp3',
207
+ * language: 'en',
208
+ * });
209
+ * console.log(`Transcribed ${result.segments.length} segments`);
210
+ * ```
211
+ */
212
+ async transcribe(options) {
213
+ return this.request("/api/v1/transcribe", {
214
+ method: "POST",
215
+ body: JSON.stringify({
216
+ audioUrl: options.audioUrl,
217
+ language: options.language,
218
+ projectName: options.projectName
219
+ })
220
+ });
221
+ }
222
+ /**
223
+ * Transcribe audio from a file upload
224
+ *
225
+ * Works in both Node.js and browser environments.
226
+ *
227
+ * @param file - Audio file as Blob/File (browser) or ArrayBuffer/Uint8Array (Node.js Buffer works as it extends Uint8Array)
228
+ * @param options - Optional transcription options
229
+ * @returns Transcription result with segments and words
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * // Browser
234
+ * const file = document.querySelector('input[type="file"]').files[0];
235
+ * const result = await client.transcribeFile(file);
236
+ *
237
+ * // Node.js
238
+ * const buffer = fs.readFileSync('song.mp3');
239
+ * const result = await client.transcribeFile(buffer, { filename: 'song.mp3' });
240
+ * ```
241
+ */
242
+ async transcribeFile(file, options) {
243
+ const formData = new FormData();
244
+ const filename = options?.filename ?? (file instanceof Blob && "name" in file ? file.name : null) ?? "audio";
245
+ if (file instanceof Blob) {
246
+ formData.append("file", file, filename);
247
+ } else {
248
+ const blob = new Blob([file]);
249
+ formData.append("file", blob, filename);
250
+ }
251
+ if (options?.language) {
252
+ formData.append("language", options.language);
253
+ }
254
+ if (options?.projectName) {
255
+ formData.append("projectName", options.projectName);
256
+ }
257
+ return this.request("/api/v1/transcribe/upload", {
258
+ method: "POST",
259
+ body: formData
260
+ // Don't set Content-Type header - let the browser set it with boundary
261
+ });
262
+ }
263
+ /**
264
+ * Export segments to a specific format
265
+ *
266
+ * @param options - Export options including format and segments
267
+ * @returns Export result with content and metadata
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * const result = await client.export({
272
+ * format: 'lrc',
273
+ * segments: transcription.segments,
274
+ * projectName: 'My Song',
275
+ * });
276
+ *
277
+ * // Save the file
278
+ * fs.writeFileSync(`output.${result.extension}`, result.content);
279
+ * ```
280
+ */
281
+ async export(options) {
282
+ return this.request("/api/v1/export", {
283
+ method: "POST",
284
+ body: JSON.stringify({
285
+ format: options.format,
286
+ segments: options.segments,
287
+ words: options.words,
288
+ projectName: options.projectName
289
+ })
290
+ });
291
+ }
292
+ /**
293
+ * Get information about all available export formats
294
+ *
295
+ * @returns Array of format information objects
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * const formats = await client.getFormats();
300
+ * const wordTimingFormats = formats.filter(f => f.supportsWordTiming);
301
+ * ```
302
+ */
303
+ async getFormats() {
304
+ return this.request("/api/v1/formats", {
305
+ method: "GET"
306
+ });
307
+ }
308
+ /**
309
+ * List projects with optional filtering and pagination
310
+ *
311
+ * @param options - Listing options
312
+ * @returns Paginated list of projects
313
+ *
314
+ * @example
315
+ * ```typescript
316
+ * const result = await client.listProjects({
317
+ * status: 'ready',
318
+ * limit: 10,
319
+ * });
320
+ * console.log(`Found ${result.total} projects`);
321
+ * ```
322
+ */
323
+ async listProjects(options) {
324
+ const params = new URLSearchParams();
325
+ if (options?.limit !== void 0) {
326
+ params.set("limit", String(options.limit));
327
+ }
328
+ if (options?.offset !== void 0) {
329
+ params.set("offset", String(options.offset));
330
+ }
331
+ if (options?.status) {
332
+ params.set("status", options.status);
333
+ }
334
+ if (options?.type) {
335
+ params.set("type", options.type);
336
+ }
337
+ const queryString = params.toString();
338
+ const path = queryString ? `/api/v1/projects?${queryString}` : "/api/v1/projects";
339
+ return this.request(path, {
340
+ method: "GET"
341
+ });
342
+ }
343
+ /**
344
+ * Get a single project by ID
345
+ *
346
+ * @param id - Project ID
347
+ * @param options - Options for including segments/words
348
+ * @returns Project with segments and words
349
+ *
350
+ * @example
351
+ * ```typescript
352
+ * const project = await client.getProject('project-id', {
353
+ * includeWords: true,
354
+ * });
355
+ * console.log(`Project has ${project.segments.length} segments`);
356
+ * ```
357
+ */
358
+ async getProject(id, options) {
359
+ const params = new URLSearchParams();
360
+ if (options?.includeWords !== void 0) {
361
+ params.set("includeWords", String(options.includeWords));
362
+ }
363
+ if (options?.includeSegments !== void 0) {
364
+ params.set("includeSegments", String(options.includeSegments));
365
+ }
366
+ const queryString = params.toString();
367
+ const path = queryString ? `/api/v1/projects/${id}?${queryString}` : `/api/v1/projects/${id}`;
368
+ return this.request(path, {
369
+ method: "GET"
370
+ });
371
+ }
372
+ /**
373
+ * Delete a project by ID
374
+ *
375
+ * @param id - Project ID
376
+ *
377
+ * @example
378
+ * ```typescript
379
+ * await client.deleteProject('project-id');
380
+ * ```
381
+ */
382
+ async deleteProject(id) {
383
+ await this.request(`/api/v1/projects/${id}`, {
384
+ method: "DELETE"
385
+ });
386
+ }
387
+ /**
388
+ * Make an authenticated API request with automatic retry
389
+ *
390
+ * @param path - API path (starting with /)
391
+ * @param options - Fetch request options
392
+ * @returns Parsed response data
393
+ */
394
+ async request(path, options, retryCount = 0) {
395
+ const url = `${this.baseUrl}${path}`;
396
+ const headers = {
397
+ Authorization: `Bearer ${this.apiKey}`,
398
+ Accept: "application/json"
399
+ };
400
+ if (!(options.body instanceof FormData)) {
401
+ headers["Content-Type"] = "application/json";
402
+ }
403
+ const requestHeaders = {
404
+ ...headers,
405
+ ...options.headers
406
+ };
407
+ const controller = new AbortController();
408
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
409
+ try {
410
+ const response = await fetch(url, {
411
+ ...options,
412
+ headers: requestHeaders,
413
+ signal: controller.signal
414
+ });
415
+ clearTimeout(timeoutId);
416
+ this.parseRateLimitHeaders(response.headers);
417
+ if (response.ok) {
418
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
419
+ return void 0;
420
+ }
421
+ const json = await response.json();
422
+ if (json && typeof json === "object" && "data" in json) {
423
+ return json.data;
424
+ }
425
+ return json;
426
+ }
427
+ const error = await this.handleErrorResponse(response);
428
+ if (isRetryableError(error) && retryCount < this.maxRetries) {
429
+ const delay = this.calculateRetryDelay(error, retryCount);
430
+ await this.sleep(delay);
431
+ return this.request(path, options, retryCount + 1);
432
+ }
433
+ throw error;
434
+ } catch (err) {
435
+ clearTimeout(timeoutId);
436
+ if (err instanceof Error && err.name === "AbortError") {
437
+ const timeoutError = new TimeoutError(
438
+ `Request timed out after ${this.timeout}ms`,
439
+ this.timeout
440
+ );
441
+ if (retryCount < this.maxRetries) {
442
+ const delay = this.calculateRetryDelay(timeoutError, retryCount);
443
+ await this.sleep(delay);
444
+ return this.request(path, options, retryCount + 1);
445
+ }
446
+ throw timeoutError;
447
+ }
448
+ if (err instanceof TypeError && err.message.includes("fetch")) {
449
+ const networkError = new NetworkError("Network request failed", err);
450
+ if (retryCount < this.maxRetries) {
451
+ const delay = this.calculateRetryDelay(networkError, retryCount);
452
+ await this.sleep(delay);
453
+ return this.request(path, options, retryCount + 1);
454
+ }
455
+ throw networkError;
456
+ }
457
+ if (err instanceof SyynkError) {
458
+ throw err;
459
+ }
460
+ throw new NetworkError(
461
+ err instanceof Error ? err.message : "Unknown error occurred",
462
+ err instanceof Error ? err : void 0
463
+ );
464
+ }
465
+ }
466
+ /**
467
+ * Parse rate limit headers from response
468
+ */
469
+ parseRateLimitHeaders(headers) {
470
+ const limit = headers.get("X-RateLimit-Limit");
471
+ const remaining = headers.get("X-RateLimit-Remaining");
472
+ const reset = headers.get("X-RateLimit-Reset");
473
+ if (limit && remaining && reset) {
474
+ this.lastRateLimitInfo = {
475
+ limit: parseInt(limit, 10),
476
+ remaining: parseInt(remaining, 10),
477
+ reset: parseInt(reset, 10)
478
+ };
479
+ }
480
+ }
481
+ /**
482
+ * Handle error responses and convert to typed errors
483
+ */
484
+ async handleErrorResponse(response) {
485
+ let errorData = null;
486
+ try {
487
+ errorData = await response.json();
488
+ } catch {
489
+ }
490
+ const message = errorData?.error?.message ?? response.statusText;
491
+ const code = errorData?.error?.code ?? "UNKNOWN_ERROR";
492
+ const details = errorData?.error?.details;
493
+ switch (response.status) {
494
+ case 400:
495
+ return new ValidationError(message, details);
496
+ case 401:
497
+ return new AuthenticationError(message, details);
498
+ case 403:
499
+ return new AuthorizationError(message, details);
500
+ case 404:
501
+ return new NotFoundError(message, void 0, void 0, details);
502
+ case 429: {
503
+ const resetHeader = response.headers.get("X-RateLimit-Reset");
504
+ const resetAt = resetHeader ? parseInt(resetHeader, 10) : Math.floor(Date.now() / 1e3) + 60;
505
+ return new RateLimitError(message, resetAt, details);
506
+ }
507
+ default:
508
+ if (response.status >= 500) {
509
+ const requestId = response.headers.get("X-Request-Id") ?? void 0;
510
+ return new ServerError(message, response.status, requestId, details);
511
+ }
512
+ return new SyynkError(message, code, response.status, details);
513
+ }
514
+ }
515
+ /**
516
+ * Calculate retry delay with exponential backoff
517
+ */
518
+ calculateRetryDelay(error, retryCount) {
519
+ if (error instanceof RateLimitError && error.retryAfter > 0) {
520
+ const jitter2 = Math.random() * 1e3;
521
+ return error.retryAfter * 1e3 + jitter2;
522
+ }
523
+ const baseDelay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
524
+ const jitter = Math.random() * baseDelay * 0.25;
525
+ return Math.min(baseDelay + jitter, 3e4);
526
+ }
527
+ /**
528
+ * Sleep for the specified duration
529
+ */
530
+ sleep(ms) {
531
+ return new Promise((resolve) => setTimeout(resolve, ms));
532
+ }
533
+ };
534
+ export {
535
+ AuthenticationError,
536
+ AuthorizationError,
537
+ NetworkError,
538
+ NotFoundError,
539
+ RateLimitError,
540
+ ServerError,
541
+ SyynkClient,
542
+ SyynkError,
543
+ TimeoutError,
544
+ ValidationError,
545
+ isRateLimitError,
546
+ isRetryableError,
547
+ isSyynkError
548
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@royalti/syynk",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for Syynk API - transcribe audio and export synchronized lyrics/subtitles",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format cjs,esm --dts",
20
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
21
+ "clean": "rm -rf dist",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "peerDependencies": {
25
+ "typescript": ">=5.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "tsup": "^8.0.0",
29
+ "typescript": "^5.0.0"
30
+ },
31
+ "keywords": [
32
+ "syynk",
33
+ "api",
34
+ "transcription",
35
+ "lyrics",
36
+ "karaoke",
37
+ "subtitles",
38
+ "lrc",
39
+ "srt",
40
+ "vtt",
41
+ "whisper"
42
+ ],
43
+ "author": "Syynk",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/Royalti-io/syynk-to"
48
+ },
49
+ "homepage": "https://syynk.to/docs",
50
+ "bugs": {
51
+ "url": "https://github.com/Royalti-io/syynk-to/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ }
56
+ }