@ddysiodev/js-sdk 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.js ADDED
@@ -0,0 +1,553 @@
1
+ const DEFAULT_BASE_URL = 'https://ddys.io/api/v1';
2
+ const DEFAULT_TIMEOUT_MS = 15000;
3
+ const DEFAULT_USER_AGENT = '@ddysiodev/js-sdk/0.1.0';
4
+
5
+ class DdysApiError extends Error {
6
+ constructor(message, options = {}) {
7
+ super(message);
8
+ this.name = 'DdysApiError';
9
+ this.status = options.status;
10
+ this.method = options.method || 'GET';
11
+ this.endpoint = options.endpoint || '';
12
+ this.response = options.response;
13
+ this.cause = options.cause;
14
+ }
15
+ }
16
+
17
+ class DdysTimeoutError extends DdysApiError {
18
+ constructor(message, options = {}) {
19
+ super(message, options);
20
+ this.name = 'DdysTimeoutError';
21
+ }
22
+ }
23
+
24
+ class DdysNetworkError extends DdysApiError {
25
+ constructor(message, options = {}) {
26
+ super(message, options);
27
+ this.name = 'DdysNetworkError';
28
+ }
29
+ }
30
+
31
+ class DdysParseError extends DdysApiError {
32
+ constructor(message, options = {}) {
33
+ super(message, options);
34
+ this.name = 'DdysParseError';
35
+ }
36
+ }
37
+
38
+ function createDdysClient(options = {}) {
39
+ return new DdysClient(options);
40
+ }
41
+
42
+ class DdysClient {
43
+ constructor(options = {}) {
44
+ this.baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL);
45
+ this.apiKey = options.apiKey || '';
46
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
47
+ this.fetchImpl = options.fetch || globalThis.fetch;
48
+ this.headers = { ...(options.headers || {}) };
49
+ this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
50
+ this.retry = options.retry ?? false;
51
+
52
+ if (typeof this.fetchImpl !== 'function') {
53
+ throw new DdysApiError('No fetch implementation available. Pass options.fetch or use a runtime with global fetch.');
54
+ }
55
+
56
+ this.movies = createMovieEndpoints(this);
57
+ this.dictionaries = createDictionaryEndpoints(this);
58
+ this.collections = createCollectionEndpoints(this);
59
+ this.shares = createShareEndpoints(this);
60
+ this.requests = createRequestEndpoints(this);
61
+ this.activities = createActivityEndpoints(this);
62
+ this.users = createUserEndpoints(this);
63
+ this.comments = createCommentEndpoints(this);
64
+ this.reports = createReportEndpoints(this);
65
+ this.follow = createFollowEndpoints(this);
66
+ }
67
+
68
+ async request(path, options = {}) {
69
+ const method = (options.method || 'GET').toUpperCase();
70
+ const endpoint = normalizePath(path);
71
+ const auth = Boolean(options.auth);
72
+
73
+ if (auth && !this.apiKey) {
74
+ throw new DdysApiError('DDYS API key is required for this endpoint.', {
75
+ status: 401,
76
+ method,
77
+ endpoint
78
+ });
79
+ }
80
+
81
+ const retryOptions = normalizeRetryOptions(options.retry ?? this.retry, method);
82
+ const maxAttempts = retryOptions ? retryOptions.retries + 1 : 1;
83
+ let attempt = 0;
84
+ let lastError;
85
+
86
+ while (attempt < maxAttempts) {
87
+ attempt++;
88
+ try {
89
+ return await this.requestOnce(endpoint, {
90
+ ...options,
91
+ method,
92
+ auth
93
+ });
94
+ } catch (error) {
95
+ lastError = error;
96
+ if (!shouldRetry(error, retryOptions, method, attempt, maxAttempts)) {
97
+ throw error;
98
+ }
99
+ await delay(retryOptions.delayMs * attempt);
100
+ }
101
+ }
102
+
103
+ throw lastError;
104
+ }
105
+
106
+ async requestOnce(endpoint, options) {
107
+ const method = options.method;
108
+ const query = normalizeQuery(options.query);
109
+ const url = buildUrl(this.baseUrl, endpoint, query);
110
+ const headers = buildHeaders(this, options);
111
+ const timeout = createTimeoutController(options.signal, options.timeoutMs ?? this.timeoutMs);
112
+
113
+ const init = {
114
+ method,
115
+ headers,
116
+ signal: timeout.signal
117
+ };
118
+
119
+ if (options.body !== undefined) {
120
+ init.body = JSON.stringify(options.body);
121
+ }
122
+
123
+ let response;
124
+ try {
125
+ response = await this.fetchImpl(url, init);
126
+ } catch (error) {
127
+ if (timeout.timedOut()) {
128
+ throw new DdysTimeoutError(`Request timed out after ${options.timeoutMs ?? this.timeoutMs}ms.`, {
129
+ method,
130
+ endpoint,
131
+ cause: error
132
+ });
133
+ }
134
+ throw new DdysNetworkError(error?.message || 'Network request failed.', {
135
+ method,
136
+ endpoint,
137
+ cause: error
138
+ });
139
+ } finally {
140
+ timeout.cleanup();
141
+ }
142
+
143
+ const text = await response.text();
144
+ let json;
145
+ try {
146
+ json = text ? JSON.parse(text) : {};
147
+ } catch (error) {
148
+ throw new DdysParseError('Failed to parse DDYS API response as JSON.', {
149
+ status: response.status,
150
+ method,
151
+ endpoint,
152
+ response: text,
153
+ cause: error
154
+ });
155
+ }
156
+
157
+ if (!response.ok || json?.success === false) {
158
+ throw new DdysApiError(json?.message || `HTTP ${response.status}`, {
159
+ status: response.status,
160
+ method,
161
+ endpoint,
162
+ response: json
163
+ });
164
+ }
165
+
166
+ if (json?.success !== true) {
167
+ throw new DdysParseError('DDYS API response is missing success=true.', {
168
+ status: response.status,
169
+ method,
170
+ endpoint,
171
+ response: json
172
+ });
173
+ }
174
+
175
+ return json;
176
+ }
177
+
178
+ async get(path, query, options = {}) {
179
+ return this.request(path, { ...options, method: 'GET', query });
180
+ }
181
+
182
+ async post(path, body, options = {}) {
183
+ return this.request(path, { ...options, method: 'POST', body });
184
+ }
185
+
186
+ async delete(path, options = {}) {
187
+ return this.request(path, { ...options, method: 'DELETE' });
188
+ }
189
+
190
+ async search(params) {
191
+ return unwrapPaginated(await this.get('/search', params));
192
+ }
193
+
194
+ async suggest(q) {
195
+ return unwrapData(await this.get('/suggest', { q }));
196
+ }
197
+
198
+ async hot() {
199
+ return unwrapData(await this.get('/hot'));
200
+ }
201
+
202
+ async latest(params = {}) {
203
+ return unwrapData(await this.get('/latest', params));
204
+ }
205
+
206
+ async calendar(params = {}) {
207
+ return unwrapData(await this.get('/calendar', params));
208
+ }
209
+
210
+ async me() {
211
+ return unwrapData(await this.get('/me', undefined, { auth: true }));
212
+ }
213
+ }
214
+
215
+ function createMovieEndpoints(client) {
216
+ return {
217
+ list(params = {}) {
218
+ return client.get('/movies', normalizePagination(params)).then(unwrapPaginated);
219
+ },
220
+ detail(slug) {
221
+ assertNonEmpty(slug, 'slug');
222
+ return client.get(`/movies/${encodePathSegment(slug)}`).then(unwrapData);
223
+ },
224
+ sources(slug) {
225
+ assertNonEmpty(slug, 'slug');
226
+ return client.get(`/movies/${encodePathSegment(slug)}/sources`).then(unwrapData);
227
+ },
228
+ related(slug) {
229
+ assertNonEmpty(slug, 'slug');
230
+ return client.get(`/movies/${encodePathSegment(slug)}/related`).then(unwrapData);
231
+ },
232
+ comments(slug, params = {}) {
233
+ assertNonEmpty(slug, 'slug');
234
+ return client.get(`/movies/${encodePathSegment(slug)}/comments`, normalizePagination(params)).then(unwrapPaginated);
235
+ }
236
+ };
237
+ }
238
+
239
+ function createDictionaryEndpoints(client) {
240
+ return {
241
+ types() {
242
+ return client.get('/types').then(unwrapData);
243
+ },
244
+ genres() {
245
+ return client.get('/genres').then(unwrapData);
246
+ },
247
+ regions() {
248
+ return client.get('/regions').then(unwrapData);
249
+ }
250
+ };
251
+ }
252
+
253
+ function createCollectionEndpoints(client) {
254
+ return {
255
+ list(params = {}) {
256
+ return client.get('/collections', normalizePagination(params)).then(unwrapPaginated);
257
+ },
258
+ detail(slug, params = {}) {
259
+ assertNonEmpty(slug, 'slug');
260
+ return client.get(`/collections/${encodePathSegment(slug)}`, normalizePagination(params)).then((envelope) => ({
261
+ ...unwrapData(envelope),
262
+ meta: envelope.meta
263
+ }));
264
+ }
265
+ };
266
+ }
267
+
268
+ function createShareEndpoints(client) {
269
+ return {
270
+ list(params = {}) {
271
+ return client.get('/shares', normalizePagination(params)).then(unwrapPaginated);
272
+ },
273
+ detail(id) {
274
+ assertPositiveInteger(id, 'id');
275
+ return client.get(`/shares/${id}`).then(unwrapData);
276
+ }
277
+ };
278
+ }
279
+
280
+ function createRequestEndpoints(client) {
281
+ return {
282
+ list(params = {}) {
283
+ return client.get('/requests', normalizePagination(params)).then(unwrapPaginated);
284
+ },
285
+ create(input) {
286
+ assertObject(input, 'input');
287
+ return client.post('/requests', input, { auth: true }).then(unwrapData);
288
+ }
289
+ };
290
+ }
291
+
292
+ function createActivityEndpoints(client) {
293
+ return {
294
+ list(params = {}) {
295
+ return client.get('/activities', normalizePagination(params)).then(unwrapPaginated);
296
+ }
297
+ };
298
+ }
299
+
300
+ function createUserEndpoints(client) {
301
+ return {
302
+ profile(username) {
303
+ assertNonEmpty(username, 'username');
304
+ return client.get(`/user/${encodePathSegment(username)}`).then(unwrapData);
305
+ }
306
+ };
307
+ }
308
+
309
+ function createCommentEndpoints(client) {
310
+ return {
311
+ create(input) {
312
+ assertObject(input, 'input');
313
+ return client.post('/comments', input, { auth: true }).then(unwrapData);
314
+ },
315
+ delete(id) {
316
+ assertPositiveInteger(id, 'id');
317
+ return client.delete(`/comments/${id}`, { auth: true }).then(unwrapData);
318
+ }
319
+ };
320
+ }
321
+
322
+ function createReportEndpoints(client) {
323
+ return {
324
+ invalidResource(input) {
325
+ assertObject(input, 'input');
326
+ return client.post('/report', input, { auth: true }).then(unwrapData);
327
+ }
328
+ };
329
+ }
330
+
331
+ function createFollowEndpoints(client) {
332
+ return {
333
+ set(input) {
334
+ assertObject(input, 'input');
335
+ return client.post('/follow', input, { auth: true }).then(unwrapData);
336
+ },
337
+ follow(username) {
338
+ assertNonEmpty(username, 'username');
339
+ return client.post('/follow', { username, action: 'follow' }, { auth: true }).then(unwrapData);
340
+ },
341
+ unfollow(username) {
342
+ assertNonEmpty(username, 'username');
343
+ return client.post('/follow', { username, action: 'unfollow' }, { auth: true }).then(unwrapData);
344
+ }
345
+ };
346
+ }
347
+
348
+ function normalizeBaseUrl(baseUrl) {
349
+ return String(baseUrl).replace(/\/+$/, '');
350
+ }
351
+
352
+ function normalizePath(path) {
353
+ const value = String(path || '');
354
+ return value.startsWith('/') ? value : `/${value}`;
355
+ }
356
+
357
+ function encodePathSegment(value) {
358
+ return encodeURIComponent(String(value));
359
+ }
360
+
361
+ function buildUrl(baseUrl, path, query) {
362
+ const url = new URL(`${baseUrl}${normalizePath(path)}`);
363
+ for (const [key, value] of Object.entries(query || {})) {
364
+ if (value === undefined || value === null || value === '') {
365
+ continue;
366
+ }
367
+ if (Array.isArray(value)) {
368
+ for (const item of value) {
369
+ if (item !== undefined && item !== null && item !== '') {
370
+ url.searchParams.append(key, String(item));
371
+ }
372
+ }
373
+ continue;
374
+ }
375
+ url.searchParams.set(key, String(value));
376
+ }
377
+ return url.toString();
378
+ }
379
+
380
+ function normalizeQuery(query) {
381
+ if (!query) {
382
+ return {};
383
+ }
384
+ return normalizePagination(query);
385
+ }
386
+
387
+ function normalizePagination(params = {}) {
388
+ const normalized = { ...params };
389
+ if (normalized.perPage !== undefined && normalized.per_page === undefined) {
390
+ normalized.per_page = normalized.perPage;
391
+ }
392
+ delete normalized.perPage;
393
+ return normalized;
394
+ }
395
+
396
+ function buildHeaders(client, options) {
397
+ const headers = {
398
+ Accept: 'application/json',
399
+ ...client.headers,
400
+ ...(options.headers || {})
401
+ };
402
+
403
+ if (options.body !== undefined) {
404
+ headers['Content-Type'] = headers['Content-Type'] || 'application/json';
405
+ }
406
+
407
+ if (options.auth) {
408
+ headers.Authorization = `Bearer ${client.apiKey}`;
409
+ }
410
+
411
+ if (client.userAgent && isNodeRuntime()) {
412
+ headers['User-Agent'] = client.userAgent;
413
+ }
414
+
415
+ return headers;
416
+ }
417
+
418
+ function isBrowserRuntime() {
419
+ return typeof window !== 'undefined' && typeof window.document !== 'undefined';
420
+ }
421
+
422
+ function isNodeRuntime() {
423
+ return typeof process !== 'undefined' && Boolean(process.versions?.node) && !isBrowserRuntime();
424
+ }
425
+
426
+ function createTimeoutController(externalSignal, timeoutMs) {
427
+ if (typeof AbortController !== 'function') {
428
+ return {
429
+ signal: externalSignal,
430
+ cleanup() {},
431
+ timedOut() {
432
+ return false;
433
+ }
434
+ };
435
+ }
436
+
437
+ const controller = new AbortController();
438
+ let didTimeout = false;
439
+ let timeoutId;
440
+
441
+ const abortFromExternal = () => {
442
+ controller.abort(externalSignal?.reason);
443
+ };
444
+
445
+ if (externalSignal?.aborted) {
446
+ abortFromExternal();
447
+ } else if (externalSignal) {
448
+ externalSignal.addEventListener('abort', abortFromExternal, { once: true });
449
+ }
450
+
451
+ if (timeoutMs > 0) {
452
+ timeoutId = setTimeout(() => {
453
+ didTimeout = true;
454
+ controller.abort(new Error(`Request timed out after ${timeoutMs}ms.`));
455
+ }, timeoutMs);
456
+ }
457
+
458
+ return {
459
+ signal: controller.signal,
460
+ cleanup() {
461
+ if (timeoutId) {
462
+ clearTimeout(timeoutId);
463
+ }
464
+ if (externalSignal) {
465
+ externalSignal.removeEventListener('abort', abortFromExternal);
466
+ }
467
+ },
468
+ timedOut() {
469
+ return didTimeout;
470
+ }
471
+ };
472
+ }
473
+
474
+ function normalizeRetryOptions(retry, method) {
475
+ if (!retry || method !== 'GET') {
476
+ return false;
477
+ }
478
+ if (retry === true) {
479
+ return {
480
+ retries: 1,
481
+ delayMs: 250,
482
+ statuses: [500, 502, 503, 504]
483
+ };
484
+ }
485
+ return {
486
+ retries: Math.max(0, Number(retry.retries ?? 1)),
487
+ delayMs: Math.max(0, Number(retry.delayMs ?? 250)),
488
+ statuses: retry.statuses || [500, 502, 503, 504]
489
+ };
490
+ }
491
+
492
+ function shouldRetry(error, retryOptions, method, attempt, maxAttempts) {
493
+ if (!retryOptions || method !== 'GET' || attempt >= maxAttempts) {
494
+ return false;
495
+ }
496
+ if (error instanceof DdysNetworkError || error instanceof DdysTimeoutError) {
497
+ return true;
498
+ }
499
+ return retryOptions.statuses.includes(error?.status);
500
+ }
501
+
502
+ function delay(ms) {
503
+ if (!ms) {
504
+ return Promise.resolve();
505
+ }
506
+ return new Promise((resolve) => setTimeout(resolve, ms));
507
+ }
508
+
509
+ function unwrapData(envelope) {
510
+ return envelope.data;
511
+ }
512
+
513
+ function unwrapPaginated(envelope) {
514
+ return {
515
+ data: envelope.data || [],
516
+ meta: envelope.meta || {
517
+ total: 0,
518
+ page: 1,
519
+ per_page: Array.isArray(envelope.data) ? envelope.data.length : 0,
520
+ total_pages: 1
521
+ }
522
+ };
523
+ }
524
+
525
+ function assertNonEmpty(value, name) {
526
+ if (value === undefined || value === null || String(value).trim() === '') {
527
+ throw new DdysApiError(`${name} is required.`);
528
+ }
529
+ }
530
+
531
+ function assertPositiveInteger(value, name) {
532
+ if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
533
+ throw new DdysApiError(`${name} must be a positive integer.`);
534
+ }
535
+ }
536
+
537
+ function assertObject(value, name) {
538
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
539
+ throw new DdysApiError(`${name} must be an object.`);
540
+ }
541
+ }
542
+
543
+ export {
544
+ DEFAULT_BASE_URL,
545
+ DdysApiError,
546
+ DdysNetworkError,
547
+ DdysParseError,
548
+ DdysTimeoutError,
549
+ DdysClient,
550
+ createDdysClient
551
+ };
552
+
553
+ export default createDdysClient;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ddysiodev/js-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official JavaScript SDK for the DDYS Open API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "README.zh-CN.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "scripts": {
28
+ "build": "node scripts/build.mjs",
29
+ "test": "node scripts/build.mjs && node --test test/*.test.mjs",
30
+ "prepack": "node scripts/build.mjs && node --test test/*.test.mjs",
31
+ "smoke:live": "node test/live-smoke.mjs"
32
+ },
33
+ "keywords": [
34
+ "ddys",
35
+ "movie",
36
+ "sdk",
37
+ "api",
38
+ "typescript",
39
+ "fetch"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/ddysiodev/ddys-js-sdk.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/ddysiodev/ddys-js-sdk/issues"
47
+ },
48
+ "homepage": "https://github.com/ddysiodev/ddys-js-sdk#readme",
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": false
52
+ }
53
+ }