@syra.fm/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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @syra.fm/sdk
2
+
3
+ Headless, isomorphic client for the public [Syra](https://syra.fm) API. Runs on
4
+ Node 18+, browsers, and React Native — no React, React Native, or DOM
5
+ dependencies; the only runtime dependency is [`zod`](https://zod.dev).
6
+ **Public reads only** (no authentication in this version).
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ bun add @syra.fm/sdk
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { createSyraClient } from '@syra.fm/sdk';
18
+
19
+ const syra = createSyraClient(); // defaults to https://api.syra.fm
20
+
21
+ // Search the catalog (only tracks with a public preview are returned)
22
+ const tracks = await syra.searchTracks('lofi beats', { limit: 10 });
23
+
24
+ // Fetch a single track
25
+ const track = await syra.getTrack(tracks[0].id);
26
+
27
+ // Build a public 30s preview URL (directly playable MP3)
28
+ const url = syra.previewUrl(track.id); // .../api/preview/<id>.mp3?start=0
29
+ const hook = syra.previewUrl(track.id, 45); // start 45s in
30
+
31
+ // Resolve artwork to an absolute URL
32
+ const cover = syra.artworkUrl(track, 'large');
33
+ ```
34
+
35
+ ## Options
36
+
37
+ ```ts
38
+ createSyraClient({
39
+ baseURL: 'https://api.syra.fm', // override the API origin
40
+ fetch, // inject a fetch implementation (e.g. node-fetch)
41
+ });
42
+ ```
43
+
44
+ `fetch` defaults to the global `fetch`. It is the seam through which an
45
+ authenticated transport can be layered in a future version.
46
+
47
+ ## API
48
+
49
+ | Method | Description |
50
+ | --- | --- |
51
+ | `searchTracks(query, { limit })` | Preview-available `TrackSummary[]` matching `query`. |
52
+ | `getTrack(id)` | A single `TrackSummary`, schema-validated. |
53
+ | `previewUrl(id, startSec = 0)` | Public 30s preview URL. |
54
+ | `artworkUrl(trackOrCoverArt, size?)` | Absolute artwork URL, or `undefined`. |
55
+
56
+ Responses are validated at runtime with the package's own self-contained Zod
57
+ schemas (`trackSummarySchema`), so there are no shared internal dependencies.
58
+ `SyraApiError` (with a `status`) is thrown on non-2xx responses.
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_SYRA_BASE_URL = void 0;
4
+ exports.createSyraClient = createSyraClient;
5
+ const schema_1 = require("./schema");
6
+ const errors_1 = require("./errors");
7
+ /** Default base URL of the public Syra API. */
8
+ exports.DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
9
+ /** Order used to pick the best available artwork variant when none is named. */
10
+ const ARTWORK_FALLBACK_ORDER = [
11
+ 'original',
12
+ 'xxlarge',
13
+ 'xlarge',
14
+ 'large',
15
+ 'medium',
16
+ 'small',
17
+ ];
18
+ const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
19
+ /**
20
+ * Create a headless client for the public Syra API. Public reads only — there
21
+ * is no authentication in this version.
22
+ */
23
+ function createSyraClient(options = {}) {
24
+ const baseURL = (options.baseURL ?? exports.DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
25
+ function resolveFetch() {
26
+ if (options.fetch) {
27
+ return options.fetch;
28
+ }
29
+ const globalFetch = globalThis.fetch;
30
+ if (typeof globalFetch === 'function') {
31
+ return globalFetch.bind(globalThis);
32
+ }
33
+ throw new Error('@syra.fm/sdk: no global fetch is available. Pass `fetch` in createSyraClient options, ' +
34
+ 'or run on Node 18+, a browser, or React Native.');
35
+ }
36
+ async function getJson(path) {
37
+ const doFetch = resolveFetch();
38
+ const response = await doFetch(`${baseURL}${path}`, {
39
+ headers: { Accept: 'application/json' },
40
+ });
41
+ if (!response.ok) {
42
+ throw new errors_1.SyraApiError(response.status, `Syra API request failed: ${response.status} ${response.statusText} (${path})`);
43
+ }
44
+ return response.json();
45
+ }
46
+ function resolveImageRef(ref) {
47
+ if (!ref) {
48
+ return undefined;
49
+ }
50
+ if (/^https?:\/\//i.test(ref)) {
51
+ return ref;
52
+ }
53
+ if (ref.startsWith('/api/images/')) {
54
+ return `${baseURL}${ref}`;
55
+ }
56
+ if (OBJECT_ID_PATTERN.test(ref)) {
57
+ return `${baseURL}/api/images/${ref}`;
58
+ }
59
+ return undefined;
60
+ }
61
+ return {
62
+ async searchTracks(query, searchOptions = {}) {
63
+ const params = new URLSearchParams({ q: query, category: 'tracks' });
64
+ if (typeof searchOptions.limit === 'number') {
65
+ params.set('limit', String(searchOptions.limit));
66
+ }
67
+ const json = (await getJson(`/api/search?${params.toString()}`));
68
+ const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
69
+ const tracks = [];
70
+ for (const raw of rawTracks) {
71
+ // A single malformed catalog row must not fail the whole search.
72
+ const parsed = schema_1.trackSummarySchema.safeParse(raw);
73
+ if (parsed.success && parsed.data.previewAvailable === true) {
74
+ tracks.push(parsed.data);
75
+ }
76
+ }
77
+ return tracks;
78
+ },
79
+ async getTrack(id) {
80
+ const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
81
+ return schema_1.trackSummarySchema.parse(json);
82
+ },
83
+ previewUrl(id, startSec = 0) {
84
+ const safeStart = Number.isFinite(startSec) ? Math.max(0, Math.trunc(startSec)) : 0;
85
+ return `${baseURL}/api/preview/${encodeURIComponent(id)}.mp3?start=${safeStart}`;
86
+ },
87
+ artworkUrl(source, size) {
88
+ if (typeof source === 'string') {
89
+ return resolveImageRef(source);
90
+ }
91
+ if (size && source.coverArtSizes) {
92
+ const resolved = resolveImageRef(source.coverArtSizes[size]?.url);
93
+ if (resolved) {
94
+ return resolved;
95
+ }
96
+ }
97
+ const fromCoverArt = resolveImageRef(source.coverArt);
98
+ if (fromCoverArt) {
99
+ return fromCoverArt;
100
+ }
101
+ if (source.coverArtSizes) {
102
+ for (const key of ARTWORK_FALLBACK_ORDER) {
103
+ const resolved = resolveImageRef(source.coverArtSizes[key]?.url);
104
+ if (resolved) {
105
+ return resolved;
106
+ }
107
+ }
108
+ }
109
+ return undefined;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyraApiError = void 0;
4
+ /**
5
+ * Error thrown when the Syra API returns a non-2xx response.
6
+ */
7
+ class SyraApiError extends Error {
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.name = 'SyraApiError';
11
+ this.status = status;
12
+ // Restore the prototype chain when targeting ES5-ish runtimes.
13
+ Object.setPrototypeOf(this, SyraApiError.prototype);
14
+ }
15
+ }
16
+ exports.SyraApiError = SyraApiError;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyraApiError = exports.coverArtVariantSchema = exports.coverArtSizesSchema = exports.trackSummarySchema = exports.DEFAULT_SYRA_BASE_URL = exports.createSyraClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "createSyraClient", { enumerable: true, get: function () { return client_1.createSyraClient; } });
6
+ Object.defineProperty(exports, "DEFAULT_SYRA_BASE_URL", { enumerable: true, get: function () { return client_1.DEFAULT_SYRA_BASE_URL; } });
7
+ var schema_1 = require("./schema");
8
+ Object.defineProperty(exports, "trackSummarySchema", { enumerable: true, get: function () { return schema_1.trackSummarySchema; } });
9
+ Object.defineProperty(exports, "coverArtSizesSchema", { enumerable: true, get: function () { return schema_1.coverArtSizesSchema; } });
10
+ Object.defineProperty(exports, "coverArtVariantSchema", { enumerable: true, get: function () { return schema_1.coverArtVariantSchema; } });
11
+ var errors_1 = require("./errors");
12
+ Object.defineProperty(exports, "SyraApiError", { enumerable: true, get: function () { return errors_1.SyraApiError; } });
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.trackSummarySchema = exports.coverArtSizesSchema = exports.coverArtVariantSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ /**
6
+ * Minimal, self-contained schemas for the public Syra API response shapes this
7
+ * SDK consumes. Intentionally NOT shared with the Syra backend's internal
8
+ * types — the SDK validates only the fields it returns, and tolerantly strips
9
+ * everything else (Zod object schemas drop unknown keys by default), so the API
10
+ * can evolve without breaking external consumers.
11
+ */
12
+ /** A single artwork variant. The backend serializes `url` as `/api/images/:id`. */
13
+ exports.coverArtVariantSchema = zod_1.z.object({
14
+ id: zod_1.z.string().optional(),
15
+ url: zod_1.z.string(),
16
+ width: zod_1.z.number().optional(),
17
+ height: zod_1.z.number().optional(),
18
+ });
19
+ /** Named artwork variants keyed by size. */
20
+ exports.coverArtSizesSchema = zod_1.z.object({
21
+ small: exports.coverArtVariantSchema.optional(),
22
+ medium: exports.coverArtVariantSchema.optional(),
23
+ large: exports.coverArtVariantSchema.optional(),
24
+ xlarge: exports.coverArtVariantSchema.optional(),
25
+ xxlarge: exports.coverArtVariantSchema.optional(),
26
+ original: exports.coverArtVariantSchema.optional(),
27
+ });
28
+ /**
29
+ * The summary view of a track returned by the public catalog endpoints — just
30
+ * enough to render a song row and play its preview.
31
+ */
32
+ exports.trackSummarySchema = zod_1.z.object({
33
+ id: zod_1.z.string(),
34
+ title: zod_1.z.string(),
35
+ artistId: zod_1.z.string().optional(),
36
+ artistName: zod_1.z.string(),
37
+ albumId: zod_1.z.string().optional(),
38
+ albumName: zod_1.z.string().optional(),
39
+ duration: zod_1.z.number(),
40
+ coverArt: zod_1.z.string().optional(),
41
+ coverArtSizes: exports.coverArtSizesSchema.optional(),
42
+ previewAvailable: zod_1.z.boolean().optional(),
43
+ });
@@ -0,0 +1,108 @@
1
+ import { trackSummarySchema, } from './schema.js';
2
+ import { SyraApiError } from './errors.js';
3
+ /** Default base URL of the public Syra API. */
4
+ export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
5
+ /** Order used to pick the best available artwork variant when none is named. */
6
+ const ARTWORK_FALLBACK_ORDER = [
7
+ 'original',
8
+ 'xxlarge',
9
+ 'xlarge',
10
+ 'large',
11
+ 'medium',
12
+ 'small',
13
+ ];
14
+ const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
15
+ /**
16
+ * Create a headless client for the public Syra API. Public reads only — there
17
+ * is no authentication in this version.
18
+ */
19
+ export function createSyraClient(options = {}) {
20
+ const baseURL = (options.baseURL ?? DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
21
+ function resolveFetch() {
22
+ if (options.fetch) {
23
+ return options.fetch;
24
+ }
25
+ const globalFetch = globalThis.fetch;
26
+ if (typeof globalFetch === 'function') {
27
+ return globalFetch.bind(globalThis);
28
+ }
29
+ throw new Error('@syra.fm/sdk: no global fetch is available. Pass `fetch` in createSyraClient options, ' +
30
+ 'or run on Node 18+, a browser, or React Native.');
31
+ }
32
+ async function getJson(path) {
33
+ const doFetch = resolveFetch();
34
+ const response = await doFetch(`${baseURL}${path}`, {
35
+ headers: { Accept: 'application/json' },
36
+ });
37
+ if (!response.ok) {
38
+ throw new SyraApiError(response.status, `Syra API request failed: ${response.status} ${response.statusText} (${path})`);
39
+ }
40
+ return response.json();
41
+ }
42
+ function resolveImageRef(ref) {
43
+ if (!ref) {
44
+ return undefined;
45
+ }
46
+ if (/^https?:\/\//i.test(ref)) {
47
+ return ref;
48
+ }
49
+ if (ref.startsWith('/api/images/')) {
50
+ return `${baseURL}${ref}`;
51
+ }
52
+ if (OBJECT_ID_PATTERN.test(ref)) {
53
+ return `${baseURL}/api/images/${ref}`;
54
+ }
55
+ return undefined;
56
+ }
57
+ return {
58
+ async searchTracks(query, searchOptions = {}) {
59
+ const params = new URLSearchParams({ q: query, category: 'tracks' });
60
+ if (typeof searchOptions.limit === 'number') {
61
+ params.set('limit', String(searchOptions.limit));
62
+ }
63
+ const json = (await getJson(`/api/search?${params.toString()}`));
64
+ const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
65
+ const tracks = [];
66
+ for (const raw of rawTracks) {
67
+ // A single malformed catalog row must not fail the whole search.
68
+ const parsed = trackSummarySchema.safeParse(raw);
69
+ if (parsed.success && parsed.data.previewAvailable === true) {
70
+ tracks.push(parsed.data);
71
+ }
72
+ }
73
+ return tracks;
74
+ },
75
+ async getTrack(id) {
76
+ const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
77
+ return trackSummarySchema.parse(json);
78
+ },
79
+ previewUrl(id, startSec = 0) {
80
+ const safeStart = Number.isFinite(startSec) ? Math.max(0, Math.trunc(startSec)) : 0;
81
+ return `${baseURL}/api/preview/${encodeURIComponent(id)}.mp3?start=${safeStart}`;
82
+ },
83
+ artworkUrl(source, size) {
84
+ if (typeof source === 'string') {
85
+ return resolveImageRef(source);
86
+ }
87
+ if (size && source.coverArtSizes) {
88
+ const resolved = resolveImageRef(source.coverArtSizes[size]?.url);
89
+ if (resolved) {
90
+ return resolved;
91
+ }
92
+ }
93
+ const fromCoverArt = resolveImageRef(source.coverArt);
94
+ if (fromCoverArt) {
95
+ return fromCoverArt;
96
+ }
97
+ if (source.coverArtSizes) {
98
+ for (const key of ARTWORK_FALLBACK_ORDER) {
99
+ const resolved = resolveImageRef(source.coverArtSizes[key]?.url);
100
+ if (resolved) {
101
+ return resolved;
102
+ }
103
+ }
104
+ }
105
+ return undefined;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Error thrown when the Syra API returns a non-2xx response.
3
+ */
4
+ export class SyraApiError extends Error {
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.name = 'SyraApiError';
8
+ this.status = status;
9
+ // Restore the prototype chain when targeting ES5-ish runtimes.
10
+ Object.setPrototypeOf(this, SyraApiError.prototype);
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ export { createSyraClient, DEFAULT_SYRA_BASE_URL, } from './client.js';
2
+ export { trackSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema.js';
3
+ export { SyraApiError } from './errors.js';
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Minimal, self-contained schemas for the public Syra API response shapes this
4
+ * SDK consumes. Intentionally NOT shared with the Syra backend's internal
5
+ * types — the SDK validates only the fields it returns, and tolerantly strips
6
+ * everything else (Zod object schemas drop unknown keys by default), so the API
7
+ * can evolve without breaking external consumers.
8
+ */
9
+ /** A single artwork variant. The backend serializes `url` as `/api/images/:id`. */
10
+ export const coverArtVariantSchema = z.object({
11
+ id: z.string().optional(),
12
+ url: z.string(),
13
+ width: z.number().optional(),
14
+ height: z.number().optional(),
15
+ });
16
+ /** Named artwork variants keyed by size. */
17
+ export const coverArtSizesSchema = z.object({
18
+ small: coverArtVariantSchema.optional(),
19
+ medium: coverArtVariantSchema.optional(),
20
+ large: coverArtVariantSchema.optional(),
21
+ xlarge: coverArtVariantSchema.optional(),
22
+ xxlarge: coverArtVariantSchema.optional(),
23
+ original: coverArtVariantSchema.optional(),
24
+ });
25
+ /**
26
+ * The summary view of a track returned by the public catalog endpoints — just
27
+ * enough to render a song row and play its preview.
28
+ */
29
+ export const trackSummarySchema = z.object({
30
+ id: z.string(),
31
+ title: z.string(),
32
+ artistId: z.string().optional(),
33
+ artistName: z.string(),
34
+ albumId: z.string().optional(),
35
+ albumName: z.string().optional(),
36
+ duration: z.number(),
37
+ coverArt: z.string().optional(),
38
+ coverArtSizes: coverArtSizesSchema.optional(),
39
+ previewAvailable: z.boolean().optional(),
40
+ });
@@ -0,0 +1,43 @@
1
+ import { type TrackSummary, type CoverArtSizes, type ArtworkSize } from './schema';
2
+ /** Default base URL of the public Syra API. */
3
+ export declare const DEFAULT_SYRA_BASE_URL = "https://api.syra.fm";
4
+ export interface SyraClientOptions {
5
+ /** Base URL of the Syra API. Defaults to {@link DEFAULT_SYRA_BASE_URL}. */
6
+ baseURL?: string;
7
+ /**
8
+ * `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
9
+ * React Native). Inject one (e.g. `node-fetch`) when no global is available.
10
+ * This is the seam where an authenticated transport can be layered in later.
11
+ */
12
+ fetch?: typeof fetch;
13
+ }
14
+ export interface SearchTracksOptions {
15
+ /** Maximum number of tracks to request from the API. */
16
+ limit?: number;
17
+ }
18
+ /** Minimal shape from which artwork URLs can be derived. */
19
+ export interface ArtworkSource {
20
+ coverArt?: string | null;
21
+ coverArtSizes?: CoverArtSizes | null;
22
+ }
23
+ export interface SyraClient {
24
+ /**
25
+ * Search the public catalog for tracks. Results are validated against the
26
+ * track-summary schema and filtered to those that expose a public preview.
27
+ */
28
+ searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary[]>;
29
+ /** Fetch a single track by id, validated against the track-summary schema. */
30
+ getTrack(id: string): Promise<TrackSummary>;
31
+ /** Build the public 30s preview URL for a track at the given start offset. */
32
+ previewUrl(id: string, startSec?: number): string;
33
+ /**
34
+ * Resolve an absolute artwork URL from a track / cover-art reference. Returns
35
+ * `undefined` when no artwork can be derived.
36
+ */
37
+ artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
38
+ }
39
+ /**
40
+ * Create a headless client for the public Syra API. Public reads only — there
41
+ * is no authentication in this version.
42
+ */
43
+ export declare function createSyraClient(options?: SyraClientOptions): SyraClient;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Error thrown when the Syra API returns a non-2xx response.
3
+ */
4
+ export declare class SyraApiError extends Error {
5
+ /** HTTP status code returned by the API. */
6
+ readonly status: number;
7
+ constructor(status: number, message: string);
8
+ }
@@ -0,0 +1,5 @@
1
+ export { createSyraClient, DEFAULT_SYRA_BASE_URL, } from './client';
2
+ export type { SyraClient, SyraClientOptions, SearchTracksOptions, ArtworkSource, } from './client';
3
+ export { trackSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema';
4
+ export type { TrackSummary, CoverArtSizes, CoverArtVariant, ArtworkSize, } from './schema';
5
+ export { SyraApiError } from './errors';