@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.
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { createSyraClient, SyraApiError, DEFAULT_SYRA_BASE_URL } from './index';
3
+
4
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
5
+
6
+ function makeTrack(overrides: Record<string, unknown> = {}): Record<string, unknown> {
7
+ return {
8
+ id: '507f1f77bcf86cd799439011',
9
+ title: 'Test Track',
10
+ artistId: 'artist-1',
11
+ artistName: 'Test Artist',
12
+ duration: 180,
13
+ isExplicit: false,
14
+ isAvailable: true,
15
+ source: 'upload',
16
+ status: 'ready',
17
+ createdAt: '2026-01-01T00:00:00.000Z',
18
+ updatedAt: '2026-01-01T00:00:00.000Z',
19
+ previewAvailable: true,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ interface FetchCall {
25
+ url: string;
26
+ }
27
+
28
+ function fakeFetch(
29
+ handler: (url: string) => { status?: number; body: unknown },
30
+ ): { fetch: typeof fetch; calls: FetchCall[] } {
31
+ const calls: FetchCall[] = [];
32
+ const fetchImpl = (async (input: string | URL | Request) => {
33
+ const url = typeof input === 'string' ? input : input.toString();
34
+ calls.push({ url });
35
+ const { status = 200, body } = handler(url);
36
+ return {
37
+ ok: status >= 200 && status < 300,
38
+ status,
39
+ statusText: status === 200 ? 'OK' : 'Error',
40
+ json: async () => body,
41
+ } as Response;
42
+ }) as typeof fetch;
43
+ return { fetch: fetchImpl, calls };
44
+ }
45
+
46
+ // ── searchTracks ──────────────────────────────────────────────────────────────
47
+
48
+ describe('createSyraClient.searchTracks', () => {
49
+ it('calls /api/search with category=tracks and the limit, returns preview-available tracks', async () => {
50
+ const { fetch, calls } = fakeFetch(() => ({
51
+ body: {
52
+ results: {
53
+ tracks: [
54
+ makeTrack({ id: '507f1f77bcf86cd799439011', previewAvailable: true }),
55
+ makeTrack({ id: '507f1f77bcf86cd799439012', previewAvailable: false }),
56
+ ],
57
+ },
58
+ },
59
+ }));
60
+
61
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
62
+ const tracks = await client.searchTracks('hello', { limit: 10 });
63
+
64
+ expect(tracks).toHaveLength(1);
65
+ expect(tracks[0].id).toBe('507f1f77bcf86cd799439011');
66
+
67
+ expect(calls).toHaveLength(1);
68
+ const url = new URL(calls[0].url);
69
+ expect(url.pathname).toBe('/api/search');
70
+ expect(url.searchParams.get('q')).toBe('hello');
71
+ expect(url.searchParams.get('category')).toBe('tracks');
72
+ expect(url.searchParams.get('limit')).toBe('10');
73
+ });
74
+
75
+ it('drops malformed rows without throwing', async () => {
76
+ const { fetch } = fakeFetch(() => ({
77
+ body: {
78
+ results: {
79
+ tracks: [
80
+ { id: 'broken' }, // missing required fields → safeParse fails
81
+ makeTrack({ previewAvailable: true }),
82
+ ],
83
+ },
84
+ },
85
+ }));
86
+
87
+ const client = createSyraClient({ fetch });
88
+ const tracks = await client.searchTracks('x');
89
+ expect(tracks).toHaveLength(1);
90
+ });
91
+
92
+ it('returns an empty array when results.tracks is absent', async () => {
93
+ const { fetch } = fakeFetch(() => ({ body: {} }));
94
+ const client = createSyraClient({ fetch });
95
+ expect(await client.searchTracks('x')).toEqual([]);
96
+ });
97
+ });
98
+
99
+ // ── getTrack ──────────────────────────────────────────────────────────────────
100
+
101
+ describe('createSyraClient.getTrack', () => {
102
+ it('fetches /api/tracks/:id and validates the response', async () => {
103
+ const { fetch, calls } = fakeFetch(() => ({ body: makeTrack() }));
104
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
105
+
106
+ const track = await client.getTrack('507f1f77bcf86cd799439011');
107
+ expect(track.title).toBe('Test Track');
108
+ expect(calls[0].url).toBe('https://api.example.test/api/tracks/507f1f77bcf86cd799439011');
109
+ });
110
+
111
+ it('throws SyraApiError on a non-2xx response', async () => {
112
+ const { fetch } = fakeFetch(() => ({ status: 404, body: { error: 'not found' } }));
113
+ const client = createSyraClient({ fetch });
114
+
115
+ await expect(client.getTrack('507f1f77bcf86cd799439011')).rejects.toBeInstanceOf(SyraApiError);
116
+ });
117
+
118
+ it('throws when the response fails schema validation', async () => {
119
+ const { fetch } = fakeFetch(() => ({ body: { id: 'x' } }));
120
+ const client = createSyraClient({ fetch });
121
+ await expect(client.getTrack('x')).rejects.toThrow();
122
+ });
123
+ });
124
+
125
+ // ── previewUrl ────────────────────────────────────────────────────────────────
126
+
127
+ describe('createSyraClient.previewUrl', () => {
128
+ it('builds the preview URL with a default start of 0', () => {
129
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
130
+ expect(client.previewUrl('abc')).toBe('https://api.example.test/api/preview/abc.mp3?start=0');
131
+ });
132
+
133
+ it('uses the provided start offset and clamps to an integer >= 0', () => {
134
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
135
+ expect(client.previewUrl('abc', 42.9)).toBe('https://api.example.test/api/preview/abc.mp3?start=42');
136
+ expect(client.previewUrl('abc', -5)).toBe('https://api.example.test/api/preview/abc.mp3?start=0');
137
+ });
138
+
139
+ it('defaults to the production base URL', () => {
140
+ const client = createSyraClient();
141
+ expect(client.previewUrl('abc')).toBe(`${DEFAULT_SYRA_BASE_URL}/api/preview/abc.mp3?start=0`);
142
+ });
143
+ });
144
+
145
+ // ── artworkUrl ────────────────────────────────────────────────────────────────
146
+
147
+ describe('createSyraClient.artworkUrl', () => {
148
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
149
+
150
+ it('resolves a bare ObjectId string to an absolute images URL', () => {
151
+ expect(client.artworkUrl('507f1f77bcf86cd799439011')).toBe(
152
+ 'https://api.example.test/api/images/507f1f77bcf86cd799439011',
153
+ );
154
+ });
155
+
156
+ it('prefixes a relative /api/images path', () => {
157
+ expect(client.artworkUrl('/api/images/507f1f77bcf86cd799439011')).toBe(
158
+ 'https://api.example.test/api/images/507f1f77bcf86cd799439011',
159
+ );
160
+ });
161
+
162
+ it('passes through an absolute http(s) URL', () => {
163
+ expect(client.artworkUrl('https://cdn.example.com/x.jpg')).toBe('https://cdn.example.com/x.jpg');
164
+ });
165
+
166
+ it('prefers a named size from coverArtSizes', () => {
167
+ const url = client.artworkUrl(
168
+ {
169
+ coverArt: '/api/images/507f1f77bcf86cd799439011',
170
+ coverArtSizes: {
171
+ large: {
172
+ id: '507f1f77bcf86cd799439012',
173
+ url: '/api/images/507f1f77bcf86cd799439012',
174
+ width: 600,
175
+ height: 600,
176
+ },
177
+ },
178
+ },
179
+ 'large',
180
+ );
181
+ expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439012');
182
+ });
183
+
184
+ it('falls back to coverArt when the requested size is missing', () => {
185
+ const url = client.artworkUrl(
186
+ { coverArt: '/api/images/507f1f77bcf86cd799439011', coverArtSizes: {} },
187
+ 'large',
188
+ );
189
+ expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439011');
190
+ });
191
+
192
+ it('returns undefined when nothing resolvable is present', () => {
193
+ expect(client.artworkUrl({})).toBeUndefined();
194
+ expect(client.artworkUrl('not-an-id')).toBeUndefined();
195
+ });
196
+ });
package/src/client.ts ADDED
@@ -0,0 +1,178 @@
1
+ import {
2
+ trackSummarySchema,
3
+ type TrackSummary,
4
+ type CoverArtSizes,
5
+ type ArtworkSize,
6
+ } from './schema';
7
+ import { SyraApiError } from './errors';
8
+
9
+ /** Default base URL of the public Syra API. */
10
+ export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
11
+
12
+ export interface SyraClientOptions {
13
+ /** Base URL of the Syra API. Defaults to {@link DEFAULT_SYRA_BASE_URL}. */
14
+ baseURL?: string;
15
+ /**
16
+ * `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
17
+ * React Native). Inject one (e.g. `node-fetch`) when no global is available.
18
+ * This is the seam where an authenticated transport can be layered in later.
19
+ */
20
+ fetch?: typeof fetch;
21
+ }
22
+
23
+ export interface SearchTracksOptions {
24
+ /** Maximum number of tracks to request from the API. */
25
+ limit?: number;
26
+ }
27
+
28
+ /** Minimal shape from which artwork URLs can be derived. */
29
+ export interface ArtworkSource {
30
+ coverArt?: string | null;
31
+ coverArtSizes?: CoverArtSizes | null;
32
+ }
33
+
34
+ export interface SyraClient {
35
+ /**
36
+ * Search the public catalog for tracks. Results are validated against the
37
+ * track-summary schema and filtered to those that expose a public preview.
38
+ */
39
+ searchTracks(query: string, options?: SearchTracksOptions): Promise<TrackSummary[]>;
40
+ /** Fetch a single track by id, validated against the track-summary schema. */
41
+ getTrack(id: string): Promise<TrackSummary>;
42
+ /** Build the public 30s preview URL for a track at the given start offset. */
43
+ previewUrl(id: string, startSec?: number): string;
44
+ /**
45
+ * Resolve an absolute artwork URL from a track / cover-art reference. Returns
46
+ * `undefined` when no artwork can be derived.
47
+ */
48
+ artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
49
+ }
50
+
51
+ /** Order used to pick the best available artwork variant when none is named. */
52
+ const ARTWORK_FALLBACK_ORDER: ArtworkSize[] = [
53
+ 'original',
54
+ 'xxlarge',
55
+ 'xlarge',
56
+ 'large',
57
+ 'medium',
58
+ 'small',
59
+ ];
60
+
61
+ const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
62
+
63
+ interface SearchResponseShape {
64
+ results?: { tracks?: unknown[] };
65
+ }
66
+
67
+ /**
68
+ * Create a headless client for the public Syra API. Public reads only — there
69
+ * is no authentication in this version.
70
+ */
71
+ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
72
+ const baseURL = (options.baseURL ?? DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
73
+
74
+ function resolveFetch(): typeof fetch {
75
+ if (options.fetch) {
76
+ return options.fetch;
77
+ }
78
+ const globalFetch = (globalThis as { fetch?: typeof fetch }).fetch;
79
+ if (typeof globalFetch === 'function') {
80
+ return globalFetch.bind(globalThis);
81
+ }
82
+ throw new Error(
83
+ '@syra.fm/sdk: no global fetch is available. Pass `fetch` in createSyraClient options, ' +
84
+ 'or run on Node 18+, a browser, or React Native.',
85
+ );
86
+ }
87
+
88
+ async function getJson(path: string): Promise<unknown> {
89
+ const doFetch = resolveFetch();
90
+ const response = await doFetch(`${baseURL}${path}`, {
91
+ headers: { Accept: 'application/json' },
92
+ });
93
+ if (!response.ok) {
94
+ throw new SyraApiError(
95
+ response.status,
96
+ `Syra API request failed: ${response.status} ${response.statusText} (${path})`,
97
+ );
98
+ }
99
+ return response.json();
100
+ }
101
+
102
+ function resolveImageRef(ref: string | null | undefined): string | undefined {
103
+ if (!ref) {
104
+ return undefined;
105
+ }
106
+ if (/^https?:\/\//i.test(ref)) {
107
+ return ref;
108
+ }
109
+ if (ref.startsWith('/api/images/')) {
110
+ return `${baseURL}${ref}`;
111
+ }
112
+ if (OBJECT_ID_PATTERN.test(ref)) {
113
+ return `${baseURL}/api/images/${ref}`;
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ return {
119
+ async searchTracks(query, searchOptions = {}) {
120
+ const params = new URLSearchParams({ q: query, category: 'tracks' });
121
+ if (typeof searchOptions.limit === 'number') {
122
+ params.set('limit', String(searchOptions.limit));
123
+ }
124
+
125
+ const json = (await getJson(`/api/search?${params.toString()}`)) as SearchResponseShape;
126
+ const rawTracks = Array.isArray(json?.results?.tracks) ? json.results.tracks : [];
127
+
128
+ const tracks: TrackSummary[] = [];
129
+ for (const raw of rawTracks) {
130
+ // A single malformed catalog row must not fail the whole search.
131
+ const parsed = trackSummarySchema.safeParse(raw);
132
+ if (parsed.success && parsed.data.previewAvailable === true) {
133
+ tracks.push(parsed.data);
134
+ }
135
+ }
136
+ return tracks;
137
+ },
138
+
139
+ async getTrack(id) {
140
+ const json = await getJson(`/api/tracks/${encodeURIComponent(id)}`);
141
+ return trackSummarySchema.parse(json);
142
+ },
143
+
144
+ previewUrl(id, startSec = 0) {
145
+ const safeStart = Number.isFinite(startSec) ? Math.max(0, Math.trunc(startSec)) : 0;
146
+ return `${baseURL}/api/preview/${encodeURIComponent(id)}.mp3?start=${safeStart}`;
147
+ },
148
+
149
+ artworkUrl(source, size) {
150
+ if (typeof source === 'string') {
151
+ return resolveImageRef(source);
152
+ }
153
+
154
+ if (size && source.coverArtSizes) {
155
+ const resolved = resolveImageRef(source.coverArtSizes[size]?.url);
156
+ if (resolved) {
157
+ return resolved;
158
+ }
159
+ }
160
+
161
+ const fromCoverArt = resolveImageRef(source.coverArt);
162
+ if (fromCoverArt) {
163
+ return fromCoverArt;
164
+ }
165
+
166
+ if (source.coverArtSizes) {
167
+ for (const key of ARTWORK_FALLBACK_ORDER) {
168
+ const resolved = resolveImageRef(source.coverArtSizes[key]?.url);
169
+ if (resolved) {
170
+ return resolved;
171
+ }
172
+ }
173
+ }
174
+
175
+ return undefined;
176
+ },
177
+ };
178
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Error thrown when the Syra API returns a non-2xx response.
3
+ */
4
+ export class SyraApiError extends Error {
5
+ /** HTTP status code returned by the API. */
6
+ readonly status: number;
7
+
8
+ constructor(status: number, message: string) {
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export {
2
+ createSyraClient,
3
+ DEFAULT_SYRA_BASE_URL,
4
+ } from './client';
5
+ export type {
6
+ SyraClient,
7
+ SyraClientOptions,
8
+ SearchTracksOptions,
9
+ ArtworkSource,
10
+ } from './client';
11
+ export {
12
+ trackSummarySchema,
13
+ coverArtSizesSchema,
14
+ coverArtVariantSchema,
15
+ } from './schema';
16
+ export type {
17
+ TrackSummary,
18
+ CoverArtSizes,
19
+ CoverArtVariant,
20
+ ArtworkSize,
21
+ } from './schema';
22
+ export { SyraApiError } from './errors';
package/src/schema.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Minimal, self-contained schemas for the public Syra API response shapes this
5
+ * SDK consumes. Intentionally NOT shared with the Syra backend's internal
6
+ * types — the SDK validates only the fields it returns, and tolerantly strips
7
+ * everything else (Zod object schemas drop unknown keys by default), so the API
8
+ * can evolve without breaking external consumers.
9
+ */
10
+
11
+ /** A single artwork variant. The backend serializes `url` as `/api/images/:id`. */
12
+ export const coverArtVariantSchema = z.object({
13
+ id: z.string().optional(),
14
+ url: z.string(),
15
+ width: z.number().optional(),
16
+ height: z.number().optional(),
17
+ });
18
+ export type CoverArtVariant = z.infer<typeof coverArtVariantSchema>;
19
+
20
+ /** Named artwork variants keyed by size. */
21
+ export const coverArtSizesSchema = z.object({
22
+ small: coverArtVariantSchema.optional(),
23
+ medium: coverArtVariantSchema.optional(),
24
+ large: coverArtVariantSchema.optional(),
25
+ xlarge: coverArtVariantSchema.optional(),
26
+ xxlarge: coverArtVariantSchema.optional(),
27
+ original: coverArtVariantSchema.optional(),
28
+ });
29
+ export type CoverArtSizes = z.infer<typeof coverArtSizesSchema>;
30
+
31
+ /** Artwork size name. */
32
+ export type ArtworkSize = keyof CoverArtSizes;
33
+
34
+ /**
35
+ * The summary view of a track returned by the public catalog endpoints — just
36
+ * enough to render a song row and play its preview.
37
+ */
38
+ export const trackSummarySchema = z.object({
39
+ id: z.string(),
40
+ title: z.string(),
41
+ artistId: z.string().optional(),
42
+ artistName: z.string(),
43
+ albumId: z.string().optional(),
44
+ albumName: z.string().optional(),
45
+ duration: z.number(),
46
+ coverArt: z.string().optional(),
47
+ coverArtSizes: coverArtSizesSchema.optional(),
48
+ previewAvailable: z.boolean().optional(),
49
+ });
50
+ export type TrackSummary = z.infer<typeof trackSummarySchema>;