@syra.fm/sdk 0.1.0 → 0.2.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.
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_SYRA_BASE_URL = void 0;
3
+ exports.DEFAULT_SYRA_WEB_BASE_URL = exports.DEFAULT_SYRA_BASE_URL = void 0;
4
4
  exports.createSyraClient = createSyraClient;
5
5
  const schema_1 = require("./schema");
6
6
  const errors_1 = require("./errors");
7
7
  /** Default base URL of the public Syra API. */
8
8
  exports.DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
9
+ /** Default base URL of the Syra web app, used for deep links. */
10
+ exports.DEFAULT_SYRA_WEB_BASE_URL = 'https://syra.fm';
9
11
  /** Order used to pick the best available artwork variant when none is named. */
10
12
  const ARTWORK_FALLBACK_ORDER = [
11
13
  'original',
@@ -22,6 +24,7 @@ const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
22
24
  */
23
25
  function createSyraClient(options = {}) {
24
26
  const baseURL = (options.baseURL ?? exports.DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
27
+ const webBaseURL = (options.webBaseURL ?? exports.DEFAULT_SYRA_WEB_BASE_URL).replace(/\/+$/, '');
25
28
  function resolveFetch() {
26
29
  if (options.fetch) {
27
30
  return options.fetch;
@@ -108,5 +111,50 @@ function createSyraClient(options = {}) {
108
111
  }
109
112
  return undefined;
110
113
  },
114
+ async searchPodcasts(query, searchOptions = {}) {
115
+ const params = new URLSearchParams({ q: query });
116
+ if (typeof searchOptions.limit === 'number') {
117
+ params.set('limit', String(searchOptions.limit));
118
+ }
119
+ const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
120
+ const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
121
+ const podcasts = [];
122
+ for (const raw of rawPodcasts) {
123
+ // A single malformed catalog row must not fail the whole search.
124
+ const parsed = schema_1.podcastSummarySchema.safeParse(raw);
125
+ if (parsed.success) {
126
+ podcasts.push(parsed.data);
127
+ }
128
+ }
129
+ return podcasts;
130
+ },
131
+ async getPodcast(id) {
132
+ const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
133
+ return schema_1.podcastSummarySchema.parse(json?.data?.podcast);
134
+ },
135
+ podcastUrl(id) {
136
+ return `${webBaseURL}/podcasts/${encodeURIComponent(id)}`;
137
+ },
138
+ podcastArtworkUrl(source, size) {
139
+ if (size && source.imageSizes) {
140
+ const resolved = resolveImageRef(source.imageSizes[size]?.url);
141
+ if (resolved) {
142
+ return resolved;
143
+ }
144
+ }
145
+ const fromImage = resolveImageRef(source.image);
146
+ if (fromImage) {
147
+ return fromImage;
148
+ }
149
+ if (source.imageSizes) {
150
+ for (const key of ARTWORK_FALLBACK_ORDER) {
151
+ const resolved = resolveImageRef(source.imageSizes[key]?.url);
152
+ if (resolved) {
153
+ return resolved;
154
+ }
155
+ }
156
+ }
157
+ return resolveImageRef(source.imageSourceUrl);
158
+ },
111
159
  };
112
160
  }
package/dist/cjs/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
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;
3
+ exports.SyraApiError = exports.coverArtVariantSchema = exports.coverArtSizesSchema = exports.podcastSummarySchema = exports.trackSummarySchema = exports.DEFAULT_SYRA_WEB_BASE_URL = exports.DEFAULT_SYRA_BASE_URL = exports.createSyraClient = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "createSyraClient", { enumerable: true, get: function () { return client_1.createSyraClient; } });
6
6
  Object.defineProperty(exports, "DEFAULT_SYRA_BASE_URL", { enumerable: true, get: function () { return client_1.DEFAULT_SYRA_BASE_URL; } });
7
+ Object.defineProperty(exports, "DEFAULT_SYRA_WEB_BASE_URL", { enumerable: true, get: function () { return client_1.DEFAULT_SYRA_WEB_BASE_URL; } });
7
8
  var schema_1 = require("./schema");
8
9
  Object.defineProperty(exports, "trackSummarySchema", { enumerable: true, get: function () { return schema_1.trackSummarySchema; } });
10
+ Object.defineProperty(exports, "podcastSummarySchema", { enumerable: true, get: function () { return schema_1.podcastSummarySchema; } });
9
11
  Object.defineProperty(exports, "coverArtSizesSchema", { enumerable: true, get: function () { return schema_1.coverArtSizesSchema; } });
10
12
  Object.defineProperty(exports, "coverArtVariantSchema", { enumerable: true, get: function () { return schema_1.coverArtVariantSchema; } });
11
13
  var errors_1 = require("./errors");
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.trackSummarySchema = exports.coverArtSizesSchema = exports.coverArtVariantSchema = void 0;
3
+ exports.podcastSummarySchema = exports.trackSummarySchema = exports.coverArtSizesSchema = exports.coverArtVariantSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  /**
6
6
  * Minimal, self-contained schemas for the public Syra API response shapes this
@@ -41,3 +41,22 @@ exports.trackSummarySchema = zod_1.z.object({
41
41
  coverArtSizes: exports.coverArtSizesSchema.optional(),
42
42
  previewAvailable: zod_1.z.boolean().optional(),
43
43
  });
44
+ /**
45
+ * The summary view of a podcast SHOW returned by the public podcast endpoints
46
+ * (`GET /api/podcasts/search`, `GET /api/podcasts/:id`) — just enough to render
47
+ * a show card and deep-link into the Syra app.
48
+ *
49
+ * Artwork mirrors tracks: `image` is the re-hosted Syra image id (resolved via
50
+ * `/api/images/:id`); `imageSizes` is the multi-resolution variant set (each
51
+ * variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
52
+ * external artwork URL as an absolute fallback when re-hosting has not run yet.
53
+ */
54
+ exports.podcastSummarySchema = zod_1.z.object({
55
+ id: zod_1.z.string(),
56
+ title: zod_1.z.string(),
57
+ author: zod_1.z.string().optional(),
58
+ description: zod_1.z.string().optional(),
59
+ image: zod_1.z.string().optional(),
60
+ imageSizes: exports.coverArtSizesSchema.optional(),
61
+ imageSourceUrl: zod_1.z.string().optional(),
62
+ });
@@ -1,7 +1,9 @@
1
- import { trackSummarySchema, } from './schema.js';
1
+ import { trackSummarySchema, podcastSummarySchema, } from './schema.js';
2
2
  import { SyraApiError } from './errors.js';
3
3
  /** Default base URL of the public Syra API. */
4
4
  export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
5
+ /** Default base URL of the Syra web app, used for deep links. */
6
+ export const DEFAULT_SYRA_WEB_BASE_URL = 'https://syra.fm';
5
7
  /** Order used to pick the best available artwork variant when none is named. */
6
8
  const ARTWORK_FALLBACK_ORDER = [
7
9
  'original',
@@ -18,6 +20,7 @@ const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
18
20
  */
19
21
  export function createSyraClient(options = {}) {
20
22
  const baseURL = (options.baseURL ?? DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
23
+ const webBaseURL = (options.webBaseURL ?? DEFAULT_SYRA_WEB_BASE_URL).replace(/\/+$/, '');
21
24
  function resolveFetch() {
22
25
  if (options.fetch) {
23
26
  return options.fetch;
@@ -104,5 +107,50 @@ export function createSyraClient(options = {}) {
104
107
  }
105
108
  return undefined;
106
109
  },
110
+ async searchPodcasts(query, searchOptions = {}) {
111
+ const params = new URLSearchParams({ q: query });
112
+ if (typeof searchOptions.limit === 'number') {
113
+ params.set('limit', String(searchOptions.limit));
114
+ }
115
+ const json = (await getJson(`/api/podcasts/search?${params.toString()}`));
116
+ const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
117
+ const podcasts = [];
118
+ for (const raw of rawPodcasts) {
119
+ // A single malformed catalog row must not fail the whole search.
120
+ const parsed = podcastSummarySchema.safeParse(raw);
121
+ if (parsed.success) {
122
+ podcasts.push(parsed.data);
123
+ }
124
+ }
125
+ return podcasts;
126
+ },
127
+ async getPodcast(id) {
128
+ const json = (await getJson(`/api/podcasts/${encodeURIComponent(id)}`));
129
+ return podcastSummarySchema.parse(json?.data?.podcast);
130
+ },
131
+ podcastUrl(id) {
132
+ return `${webBaseURL}/podcasts/${encodeURIComponent(id)}`;
133
+ },
134
+ podcastArtworkUrl(source, size) {
135
+ if (size && source.imageSizes) {
136
+ const resolved = resolveImageRef(source.imageSizes[size]?.url);
137
+ if (resolved) {
138
+ return resolved;
139
+ }
140
+ }
141
+ const fromImage = resolveImageRef(source.image);
142
+ if (fromImage) {
143
+ return fromImage;
144
+ }
145
+ if (source.imageSizes) {
146
+ for (const key of ARTWORK_FALLBACK_ORDER) {
147
+ const resolved = resolveImageRef(source.imageSizes[key]?.url);
148
+ if (resolved) {
149
+ return resolved;
150
+ }
151
+ }
152
+ }
153
+ return resolveImageRef(source.imageSourceUrl);
154
+ },
107
155
  };
108
156
  }
package/dist/esm/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { createSyraClient, DEFAULT_SYRA_BASE_URL, } from './client.js';
2
- export { trackSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema.js';
1
+ export { createSyraClient, DEFAULT_SYRA_BASE_URL, DEFAULT_SYRA_WEB_BASE_URL, } from './client.js';
2
+ export { trackSummarySchema, podcastSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema.js';
3
3
  export { SyraApiError } from './errors.js';
@@ -38,3 +38,22 @@ export const trackSummarySchema = z.object({
38
38
  coverArtSizes: coverArtSizesSchema.optional(),
39
39
  previewAvailable: z.boolean().optional(),
40
40
  });
41
+ /**
42
+ * The summary view of a podcast SHOW returned by the public podcast endpoints
43
+ * (`GET /api/podcasts/search`, `GET /api/podcasts/:id`) — just enough to render
44
+ * a show card and deep-link into the Syra app.
45
+ *
46
+ * Artwork mirrors tracks: `image` is the re-hosted Syra image id (resolved via
47
+ * `/api/images/:id`); `imageSizes` is the multi-resolution variant set (each
48
+ * variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
49
+ * external artwork URL as an absolute fallback when re-hosting has not run yet.
50
+ */
51
+ export const podcastSummarySchema = z.object({
52
+ id: z.string(),
53
+ title: z.string(),
54
+ author: z.string().optional(),
55
+ description: z.string().optional(),
56
+ image: z.string().optional(),
57
+ imageSizes: coverArtSizesSchema.optional(),
58
+ imageSourceUrl: z.string().optional(),
59
+ });
@@ -1,9 +1,17 @@
1
- import { type TrackSummary, type CoverArtSizes, type ArtworkSize } from './schema';
1
+ import { type TrackSummary, type PodcastSummary, type CoverArtSizes, type ArtworkSize } from './schema';
2
2
  /** Default base URL of the public Syra API. */
3
3
  export declare const DEFAULT_SYRA_BASE_URL = "https://api.syra.fm";
4
+ /** Default base URL of the Syra web app, used for deep links. */
5
+ export declare const DEFAULT_SYRA_WEB_BASE_URL = "https://syra.fm";
4
6
  export interface SyraClientOptions {
5
7
  /** Base URL of the Syra API. Defaults to {@link DEFAULT_SYRA_BASE_URL}. */
6
8
  baseURL?: string;
9
+ /**
10
+ * Base URL of the Syra WEB app (not the API host), used to build deep links
11
+ * such as {@link SyraClient.podcastUrl}. Defaults to
12
+ * {@link DEFAULT_SYRA_WEB_BASE_URL}.
13
+ */
14
+ webBaseURL?: string;
7
15
  /**
8
16
  * `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
9
17
  * React Native). Inject one (e.g. `node-fetch`) when no global is available.
@@ -15,11 +23,21 @@ export interface SearchTracksOptions {
15
23
  /** Maximum number of tracks to request from the API. */
16
24
  limit?: number;
17
25
  }
18
- /** Minimal shape from which artwork URLs can be derived. */
26
+ export interface SearchPodcastsOptions {
27
+ /** Maximum number of podcast shows to request from the API. */
28
+ limit?: number;
29
+ }
30
+ /** Minimal shape from which track artwork URLs can be derived. */
19
31
  export interface ArtworkSource {
20
32
  coverArt?: string | null;
21
33
  coverArtSizes?: CoverArtSizes | null;
22
34
  }
35
+ /** Minimal shape from which podcast-show artwork URLs can be derived. */
36
+ export interface PodcastArtworkSource {
37
+ image?: string | null;
38
+ imageSizes?: CoverArtSizes | null;
39
+ imageSourceUrl?: string | null;
40
+ }
23
41
  export interface SyraClient {
24
42
  /**
25
43
  * Search the public catalog for tracks. Results are validated against the
@@ -35,6 +53,25 @@ export interface SyraClient {
35
53
  * `undefined` when no artwork can be derived.
36
54
  */
37
55
  artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
56
+ /**
57
+ * Search the public catalog for podcast SHOWS (not episodes). Results are
58
+ * validated against the podcast-summary schema; malformed rows are dropped.
59
+ */
60
+ searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary[]>;
61
+ /**
62
+ * Fetch a single podcast show by id, validated against the podcast-summary
63
+ * schema. The by-id endpoint also returns episodes and resolved persons; this
64
+ * returns just the show summary needed to render a card.
65
+ */
66
+ getPodcast(id: string): Promise<PodcastSummary>;
67
+ /** Build the Syra web app deep link for a podcast show (`/podcasts/:id`). */
68
+ podcastUrl(id: string): string;
69
+ /**
70
+ * Resolve an absolute artwork URL from a podcast show reference. Prefers the
71
+ * re-hosted Syra image, then the requested/fallback variant, then the original
72
+ * external artwork URL. Returns `undefined` when no artwork can be derived.
73
+ */
74
+ podcastArtworkUrl(source: PodcastArtworkSource, size?: ArtworkSize): string | undefined;
38
75
  }
39
76
  /**
40
77
  * Create a headless client for the public Syra API. Public reads only — there
@@ -1,5 +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';
1
+ export { createSyraClient, DEFAULT_SYRA_BASE_URL, DEFAULT_SYRA_WEB_BASE_URL, } from './client';
2
+ export type { SyraClient, SyraClientOptions, SearchTracksOptions, SearchPodcastsOptions, ArtworkSource, PodcastArtworkSource, } from './client';
3
+ export { trackSummarySchema, podcastSummarySchema, coverArtSizesSchema, coverArtVariantSchema, } from './schema';
4
+ export type { TrackSummary, PodcastSummary, CoverArtSizes, CoverArtVariant, ArtworkSize, } from './schema';
5
5
  export { SyraApiError } from './errors';
@@ -484,3 +484,284 @@ export declare const trackSummarySchema: z.ZodObject<{
484
484
  previewAvailable?: boolean | undefined;
485
485
  }>;
486
486
  export type TrackSummary = z.infer<typeof trackSummarySchema>;
487
+ /**
488
+ * The summary view of a podcast SHOW returned by the public podcast endpoints
489
+ * (`GET /api/podcasts/search`, `GET /api/podcasts/:id`) — just enough to render
490
+ * a show card and deep-link into the Syra app.
491
+ *
492
+ * Artwork mirrors tracks: `image` is the re-hosted Syra image id (resolved via
493
+ * `/api/images/:id`); `imageSizes` is the multi-resolution variant set (each
494
+ * variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
495
+ * external artwork URL as an absolute fallback when re-hosting has not run yet.
496
+ */
497
+ export declare const podcastSummarySchema: z.ZodObject<{
498
+ id: z.ZodString;
499
+ title: z.ZodString;
500
+ author: z.ZodOptional<z.ZodString>;
501
+ description: z.ZodOptional<z.ZodString>;
502
+ image: z.ZodOptional<z.ZodString>;
503
+ imageSizes: z.ZodOptional<z.ZodObject<{
504
+ small: z.ZodOptional<z.ZodObject<{
505
+ id: z.ZodOptional<z.ZodString>;
506
+ url: z.ZodString;
507
+ width: z.ZodOptional<z.ZodNumber>;
508
+ height: z.ZodOptional<z.ZodNumber>;
509
+ }, "strip", z.ZodTypeAny, {
510
+ url: string;
511
+ id?: string | undefined;
512
+ width?: number | undefined;
513
+ height?: number | undefined;
514
+ }, {
515
+ url: string;
516
+ id?: string | undefined;
517
+ width?: number | undefined;
518
+ height?: number | undefined;
519
+ }>>;
520
+ medium: z.ZodOptional<z.ZodObject<{
521
+ id: z.ZodOptional<z.ZodString>;
522
+ url: z.ZodString;
523
+ width: z.ZodOptional<z.ZodNumber>;
524
+ height: z.ZodOptional<z.ZodNumber>;
525
+ }, "strip", z.ZodTypeAny, {
526
+ url: string;
527
+ id?: string | undefined;
528
+ width?: number | undefined;
529
+ height?: number | undefined;
530
+ }, {
531
+ url: string;
532
+ id?: string | undefined;
533
+ width?: number | undefined;
534
+ height?: number | undefined;
535
+ }>>;
536
+ large: z.ZodOptional<z.ZodObject<{
537
+ id: z.ZodOptional<z.ZodString>;
538
+ url: z.ZodString;
539
+ width: z.ZodOptional<z.ZodNumber>;
540
+ height: z.ZodOptional<z.ZodNumber>;
541
+ }, "strip", z.ZodTypeAny, {
542
+ url: string;
543
+ id?: string | undefined;
544
+ width?: number | undefined;
545
+ height?: number | undefined;
546
+ }, {
547
+ url: string;
548
+ id?: string | undefined;
549
+ width?: number | undefined;
550
+ height?: number | undefined;
551
+ }>>;
552
+ xlarge: z.ZodOptional<z.ZodObject<{
553
+ id: z.ZodOptional<z.ZodString>;
554
+ url: z.ZodString;
555
+ width: z.ZodOptional<z.ZodNumber>;
556
+ height: z.ZodOptional<z.ZodNumber>;
557
+ }, "strip", z.ZodTypeAny, {
558
+ url: string;
559
+ id?: string | undefined;
560
+ width?: number | undefined;
561
+ height?: number | undefined;
562
+ }, {
563
+ url: string;
564
+ id?: string | undefined;
565
+ width?: number | undefined;
566
+ height?: number | undefined;
567
+ }>>;
568
+ xxlarge: z.ZodOptional<z.ZodObject<{
569
+ id: z.ZodOptional<z.ZodString>;
570
+ url: z.ZodString;
571
+ width: z.ZodOptional<z.ZodNumber>;
572
+ height: z.ZodOptional<z.ZodNumber>;
573
+ }, "strip", z.ZodTypeAny, {
574
+ url: string;
575
+ id?: string | undefined;
576
+ width?: number | undefined;
577
+ height?: number | undefined;
578
+ }, {
579
+ url: string;
580
+ id?: string | undefined;
581
+ width?: number | undefined;
582
+ height?: number | undefined;
583
+ }>>;
584
+ original: z.ZodOptional<z.ZodObject<{
585
+ id: z.ZodOptional<z.ZodString>;
586
+ url: z.ZodString;
587
+ width: z.ZodOptional<z.ZodNumber>;
588
+ height: z.ZodOptional<z.ZodNumber>;
589
+ }, "strip", z.ZodTypeAny, {
590
+ url: string;
591
+ id?: string | undefined;
592
+ width?: number | undefined;
593
+ height?: number | undefined;
594
+ }, {
595
+ url: string;
596
+ id?: string | undefined;
597
+ width?: number | undefined;
598
+ height?: number | undefined;
599
+ }>>;
600
+ }, "strip", z.ZodTypeAny, {
601
+ small?: {
602
+ url: string;
603
+ id?: string | undefined;
604
+ width?: number | undefined;
605
+ height?: number | undefined;
606
+ } | undefined;
607
+ medium?: {
608
+ url: string;
609
+ id?: string | undefined;
610
+ width?: number | undefined;
611
+ height?: number | undefined;
612
+ } | undefined;
613
+ large?: {
614
+ url: string;
615
+ id?: string | undefined;
616
+ width?: number | undefined;
617
+ height?: number | undefined;
618
+ } | undefined;
619
+ xlarge?: {
620
+ url: string;
621
+ id?: string | undefined;
622
+ width?: number | undefined;
623
+ height?: number | undefined;
624
+ } | undefined;
625
+ xxlarge?: {
626
+ url: string;
627
+ id?: string | undefined;
628
+ width?: number | undefined;
629
+ height?: number | undefined;
630
+ } | undefined;
631
+ original?: {
632
+ url: string;
633
+ id?: string | undefined;
634
+ width?: number | undefined;
635
+ height?: number | undefined;
636
+ } | undefined;
637
+ }, {
638
+ small?: {
639
+ url: string;
640
+ id?: string | undefined;
641
+ width?: number | undefined;
642
+ height?: number | undefined;
643
+ } | undefined;
644
+ medium?: {
645
+ url: string;
646
+ id?: string | undefined;
647
+ width?: number | undefined;
648
+ height?: number | undefined;
649
+ } | undefined;
650
+ large?: {
651
+ url: string;
652
+ id?: string | undefined;
653
+ width?: number | undefined;
654
+ height?: number | undefined;
655
+ } | undefined;
656
+ xlarge?: {
657
+ url: string;
658
+ id?: string | undefined;
659
+ width?: number | undefined;
660
+ height?: number | undefined;
661
+ } | undefined;
662
+ xxlarge?: {
663
+ url: string;
664
+ id?: string | undefined;
665
+ width?: number | undefined;
666
+ height?: number | undefined;
667
+ } | undefined;
668
+ original?: {
669
+ url: string;
670
+ id?: string | undefined;
671
+ width?: number | undefined;
672
+ height?: number | undefined;
673
+ } | undefined;
674
+ }>>;
675
+ imageSourceUrl: z.ZodOptional<z.ZodString>;
676
+ }, "strip", z.ZodTypeAny, {
677
+ id: string;
678
+ title: string;
679
+ author?: string | undefined;
680
+ description?: string | undefined;
681
+ image?: string | undefined;
682
+ imageSizes?: {
683
+ small?: {
684
+ url: string;
685
+ id?: string | undefined;
686
+ width?: number | undefined;
687
+ height?: number | undefined;
688
+ } | undefined;
689
+ medium?: {
690
+ url: string;
691
+ id?: string | undefined;
692
+ width?: number | undefined;
693
+ height?: number | undefined;
694
+ } | undefined;
695
+ large?: {
696
+ url: string;
697
+ id?: string | undefined;
698
+ width?: number | undefined;
699
+ height?: number | undefined;
700
+ } | undefined;
701
+ xlarge?: {
702
+ url: string;
703
+ id?: string | undefined;
704
+ width?: number | undefined;
705
+ height?: number | undefined;
706
+ } | undefined;
707
+ xxlarge?: {
708
+ url: string;
709
+ id?: string | undefined;
710
+ width?: number | undefined;
711
+ height?: number | undefined;
712
+ } | undefined;
713
+ original?: {
714
+ url: string;
715
+ id?: string | undefined;
716
+ width?: number | undefined;
717
+ height?: number | undefined;
718
+ } | undefined;
719
+ } | undefined;
720
+ imageSourceUrl?: string | undefined;
721
+ }, {
722
+ id: string;
723
+ title: string;
724
+ author?: string | undefined;
725
+ description?: string | undefined;
726
+ image?: string | undefined;
727
+ imageSizes?: {
728
+ small?: {
729
+ url: string;
730
+ id?: string | undefined;
731
+ width?: number | undefined;
732
+ height?: number | undefined;
733
+ } | undefined;
734
+ medium?: {
735
+ url: string;
736
+ id?: string | undefined;
737
+ width?: number | undefined;
738
+ height?: number | undefined;
739
+ } | undefined;
740
+ large?: {
741
+ url: string;
742
+ id?: string | undefined;
743
+ width?: number | undefined;
744
+ height?: number | undefined;
745
+ } | undefined;
746
+ xlarge?: {
747
+ url: string;
748
+ id?: string | undefined;
749
+ width?: number | undefined;
750
+ height?: number | undefined;
751
+ } | undefined;
752
+ xxlarge?: {
753
+ url: string;
754
+ id?: string | undefined;
755
+ width?: number | undefined;
756
+ height?: number | undefined;
757
+ } | undefined;
758
+ original?: {
759
+ url: string;
760
+ id?: string | undefined;
761
+ width?: number | undefined;
762
+ height?: number | undefined;
763
+ } | undefined;
764
+ } | undefined;
765
+ imageSourceUrl?: string | undefined;
766
+ }>;
767
+ export type PodcastSummary = z.infer<typeof podcastSummarySchema>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syra.fm/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Headless, isomorphic client for the Syra public API — catalog reads and 30s preview clips. No React/RN/DOM dependencies; public reads only.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from 'bun:test';
2
- import { createSyraClient, SyraApiError, DEFAULT_SYRA_BASE_URL } from './index';
2
+ import {
3
+ createSyraClient,
4
+ SyraApiError,
5
+ DEFAULT_SYRA_BASE_URL,
6
+ DEFAULT_SYRA_WEB_BASE_URL,
7
+ } from './index';
3
8
 
4
9
  // ── Fixtures ──────────────────────────────────────────────────────────────────
5
10
 
@@ -21,6 +26,25 @@ function makeTrack(overrides: Record<string, unknown> = {}): Record<string, unkn
21
26
  };
22
27
  }
23
28
 
29
+ function makePodcast(overrides: Record<string, unknown> = {}): Record<string, unknown> {
30
+ return {
31
+ id: '507f1f77bcf86cd799439021',
32
+ title: 'Test Show',
33
+ author: 'Test Publisher',
34
+ description: 'A show about testing.',
35
+ image: '507f1f77bcf86cd799439022',
36
+ explicit: false,
37
+ type: 'episodic',
38
+ source: 'rss',
39
+ refreshIntervalMin: 60,
40
+ episodeCount: 12,
41
+ status: 'active',
42
+ createdAt: '2026-01-01T00:00:00.000Z',
43
+ updatedAt: '2026-01-01T00:00:00.000Z',
44
+ ...overrides,
45
+ };
46
+ }
47
+
24
48
  interface FetchCall {
25
49
  url: string;
26
50
  }
@@ -194,3 +218,147 @@ describe('createSyraClient.artworkUrl', () => {
194
218
  expect(client.artworkUrl('not-an-id')).toBeUndefined();
195
219
  });
196
220
  });
221
+
222
+ // ── searchPodcasts ──────────────────────────────────────────────────────────────
223
+
224
+ describe('createSyraClient.searchPodcasts', () => {
225
+ it('calls /api/podcasts/search with q and limit, returns parsed shows', async () => {
226
+ const { fetch, calls } = fakeFetch(() => ({
227
+ body: {
228
+ data: [
229
+ makePodcast({ id: '507f1f77bcf86cd799439021' }),
230
+ makePodcast({ id: '507f1f77bcf86cd799439023', title: 'Second Show' }),
231
+ ],
232
+ },
233
+ }));
234
+
235
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
236
+ const podcasts = await client.searchPodcasts('news', { limit: 5 });
237
+
238
+ expect(podcasts).toHaveLength(2);
239
+ expect(podcasts[0].id).toBe('507f1f77bcf86cd799439021');
240
+ expect(podcasts[0].author).toBe('Test Publisher');
241
+
242
+ expect(calls).toHaveLength(1);
243
+ const url = new URL(calls[0].url);
244
+ expect(url.pathname).toBe('/api/podcasts/search');
245
+ expect(url.searchParams.get('q')).toBe('news');
246
+ expect(url.searchParams.get('limit')).toBe('5');
247
+ });
248
+
249
+ it('omits the limit param when not provided', async () => {
250
+ const { fetch, calls } = fakeFetch(() => ({ body: { data: [makePodcast()] } }));
251
+ const client = createSyraClient({ fetch });
252
+ await client.searchPodcasts('news');
253
+ const url = new URL(calls[0].url);
254
+ expect(url.searchParams.has('limit')).toBe(false);
255
+ });
256
+
257
+ it('drops malformed rows without throwing', async () => {
258
+ const { fetch } = fakeFetch(() => ({
259
+ body: { data: [{ id: 'broken' }, makePodcast()] },
260
+ }));
261
+ const client = createSyraClient({ fetch });
262
+ const podcasts = await client.searchPodcasts('x');
263
+ expect(podcasts).toHaveLength(1);
264
+ });
265
+
266
+ it('returns an empty array when data is absent', async () => {
267
+ const { fetch } = fakeFetch(() => ({ body: {} }));
268
+ const client = createSyraClient({ fetch });
269
+ expect(await client.searchPodcasts('x')).toEqual([]);
270
+ });
271
+ });
272
+
273
+ // ── getPodcast ──────────────────────────────────────────────────────────────────
274
+
275
+ describe('createSyraClient.getPodcast', () => {
276
+ it('fetches /api/podcasts/:id and validates data.podcast', async () => {
277
+ const { fetch, calls } = fakeFetch(() => ({
278
+ body: { data: { podcast: makePodcast(), episodes: [], persons: [] } },
279
+ }));
280
+ const client = createSyraClient({ baseURL: 'https://api.example.test', fetch });
281
+
282
+ const podcast = await client.getPodcast('507f1f77bcf86cd799439021');
283
+ expect(podcast.title).toBe('Test Show');
284
+ expect(calls[0].url).toBe('https://api.example.test/api/podcasts/507f1f77bcf86cd799439021');
285
+ });
286
+
287
+ it('throws SyraApiError on a non-2xx response', async () => {
288
+ const { fetch } = fakeFetch(() => ({ status: 404, body: { error: 'not found' } }));
289
+ const client = createSyraClient({ fetch });
290
+ await expect(client.getPodcast('507f1f77bcf86cd799439021')).rejects.toBeInstanceOf(SyraApiError);
291
+ });
292
+
293
+ it('throws when data.podcast fails schema validation', async () => {
294
+ const { fetch } = fakeFetch(() => ({ body: { data: { podcast: { id: 'x' } } } }));
295
+ const client = createSyraClient({ fetch });
296
+ await expect(client.getPodcast('x')).rejects.toThrow();
297
+ });
298
+ });
299
+
300
+ // ── podcastUrl ────────────────────────────────────────────────────────────────
301
+
302
+ describe('createSyraClient.podcastUrl', () => {
303
+ it('builds the web deep link from the web base URL, not the API host', () => {
304
+ const client = createSyraClient({
305
+ baseURL: 'https://api.example.test',
306
+ webBaseURL: 'https://web.example.test',
307
+ });
308
+ expect(client.podcastUrl('507f1f77bcf86cd799439021')).toBe(
309
+ 'https://web.example.test/podcasts/507f1f77bcf86cd799439021',
310
+ );
311
+ });
312
+
313
+ it('defaults to the production web base URL', () => {
314
+ const client = createSyraClient();
315
+ expect(client.podcastUrl('abc')).toBe(`${DEFAULT_SYRA_WEB_BASE_URL}/podcasts/abc`);
316
+ });
317
+
318
+ it('does not use the API base URL for the deep link', () => {
319
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
320
+ expect(client.podcastUrl('abc')).toBe(`${DEFAULT_SYRA_WEB_BASE_URL}/podcasts/abc`);
321
+ expect(DEFAULT_SYRA_WEB_BASE_URL).not.toBe(DEFAULT_SYRA_BASE_URL);
322
+ });
323
+ });
324
+
325
+ // ── podcastArtworkUrl ────────────────────────────────────────────────────────────
326
+
327
+ describe('createSyraClient.podcastArtworkUrl', () => {
328
+ const client = createSyraClient({ baseURL: 'https://api.example.test' });
329
+
330
+ it('resolves the re-hosted image id to an absolute images URL', () => {
331
+ expect(client.podcastArtworkUrl({ image: '507f1f77bcf86cd799439022' })).toBe(
332
+ 'https://api.example.test/api/images/507f1f77bcf86cd799439022',
333
+ );
334
+ });
335
+
336
+ it('prefers a named size from imageSizes', () => {
337
+ const url = client.podcastArtworkUrl(
338
+ {
339
+ image: '507f1f77bcf86cd799439022',
340
+ imageSizes: {
341
+ large: {
342
+ id: '507f1f77bcf86cd799439023',
343
+ url: '/api/images/507f1f77bcf86cd799439023',
344
+ width: 640,
345
+ height: 640,
346
+ },
347
+ },
348
+ },
349
+ 'large',
350
+ );
351
+ expect(url).toBe('https://api.example.test/api/images/507f1f77bcf86cd799439023');
352
+ });
353
+
354
+ it('falls back to imageSourceUrl when no Syra image is present', () => {
355
+ expect(
356
+ client.podcastArtworkUrl({ imageSourceUrl: 'https://cdn.example.com/cover.jpg' }),
357
+ ).toBe('https://cdn.example.com/cover.jpg');
358
+ });
359
+
360
+ it('returns undefined when nothing resolvable is present', () => {
361
+ expect(client.podcastArtworkUrl({})).toBeUndefined();
362
+ expect(client.podcastArtworkUrl({ image: 'not-an-id' })).toBeUndefined();
363
+ });
364
+ });
package/src/client.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  trackSummarySchema,
3
+ podcastSummarySchema,
3
4
  type TrackSummary,
5
+ type PodcastSummary,
4
6
  type CoverArtSizes,
5
7
  type ArtworkSize,
6
8
  } from './schema';
@@ -9,9 +11,18 @@ import { SyraApiError } from './errors';
9
11
  /** Default base URL of the public Syra API. */
10
12
  export const DEFAULT_SYRA_BASE_URL = 'https://api.syra.fm';
11
13
 
14
+ /** Default base URL of the Syra web app, used for deep links. */
15
+ export const DEFAULT_SYRA_WEB_BASE_URL = 'https://syra.fm';
16
+
12
17
  export interface SyraClientOptions {
13
18
  /** Base URL of the Syra API. Defaults to {@link DEFAULT_SYRA_BASE_URL}. */
14
19
  baseURL?: string;
20
+ /**
21
+ * Base URL of the Syra WEB app (not the API host), used to build deep links
22
+ * such as {@link SyraClient.podcastUrl}. Defaults to
23
+ * {@link DEFAULT_SYRA_WEB_BASE_URL}.
24
+ */
25
+ webBaseURL?: string;
15
26
  /**
16
27
  * `fetch` implementation. Defaults to the global `fetch` (Node 18+, browsers,
17
28
  * React Native). Inject one (e.g. `node-fetch`) when no global is available.
@@ -25,12 +36,24 @@ export interface SearchTracksOptions {
25
36
  limit?: number;
26
37
  }
27
38
 
28
- /** Minimal shape from which artwork URLs can be derived. */
39
+ export interface SearchPodcastsOptions {
40
+ /** Maximum number of podcast shows to request from the API. */
41
+ limit?: number;
42
+ }
43
+
44
+ /** Minimal shape from which track artwork URLs can be derived. */
29
45
  export interface ArtworkSource {
30
46
  coverArt?: string | null;
31
47
  coverArtSizes?: CoverArtSizes | null;
32
48
  }
33
49
 
50
+ /** Minimal shape from which podcast-show artwork URLs can be derived. */
51
+ export interface PodcastArtworkSource {
52
+ image?: string | null;
53
+ imageSizes?: CoverArtSizes | null;
54
+ imageSourceUrl?: string | null;
55
+ }
56
+
34
57
  export interface SyraClient {
35
58
  /**
36
59
  * Search the public catalog for tracks. Results are validated against the
@@ -46,6 +69,25 @@ export interface SyraClient {
46
69
  * `undefined` when no artwork can be derived.
47
70
  */
48
71
  artworkUrl(source: string | ArtworkSource, size?: ArtworkSize): string | undefined;
72
+ /**
73
+ * Search the public catalog for podcast SHOWS (not episodes). Results are
74
+ * validated against the podcast-summary schema; malformed rows are dropped.
75
+ */
76
+ searchPodcasts(query: string, options?: SearchPodcastsOptions): Promise<PodcastSummary[]>;
77
+ /**
78
+ * Fetch a single podcast show by id, validated against the podcast-summary
79
+ * schema. The by-id endpoint also returns episodes and resolved persons; this
80
+ * returns just the show summary needed to render a card.
81
+ */
82
+ getPodcast(id: string): Promise<PodcastSummary>;
83
+ /** Build the Syra web app deep link for a podcast show (`/podcasts/:id`). */
84
+ podcastUrl(id: string): string;
85
+ /**
86
+ * Resolve an absolute artwork URL from a podcast show reference. Prefers the
87
+ * re-hosted Syra image, then the requested/fallback variant, then the original
88
+ * external artwork URL. Returns `undefined` when no artwork can be derived.
89
+ */
90
+ podcastArtworkUrl(source: PodcastArtworkSource, size?: ArtworkSize): string | undefined;
49
91
  }
50
92
 
51
93
  /** Order used to pick the best available artwork variant when none is named. */
@@ -64,12 +106,21 @@ interface SearchResponseShape {
64
106
  results?: { tracks?: unknown[] };
65
107
  }
66
108
 
109
+ interface PodcastSearchResponseShape {
110
+ data?: unknown[];
111
+ }
112
+
113
+ interface PodcastDetailResponseShape {
114
+ data?: { podcast?: unknown };
115
+ }
116
+
67
117
  /**
68
118
  * Create a headless client for the public Syra API. Public reads only — there
69
119
  * is no authentication in this version.
70
120
  */
71
121
  export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
72
122
  const baseURL = (options.baseURL ?? DEFAULT_SYRA_BASE_URL).replace(/\/+$/, '');
123
+ const webBaseURL = (options.webBaseURL ?? DEFAULT_SYRA_WEB_BASE_URL).replace(/\/+$/, '');
73
124
 
74
125
  function resolveFetch(): typeof fetch {
75
126
  if (options.fetch) {
@@ -174,5 +225,63 @@ export function createSyraClient(options: SyraClientOptions = {}): SyraClient {
174
225
 
175
226
  return undefined;
176
227
  },
228
+
229
+ async searchPodcasts(query, searchOptions = {}) {
230
+ const params = new URLSearchParams({ q: query });
231
+ if (typeof searchOptions.limit === 'number') {
232
+ params.set('limit', String(searchOptions.limit));
233
+ }
234
+
235
+ const json = (await getJson(
236
+ `/api/podcasts/search?${params.toString()}`,
237
+ )) as PodcastSearchResponseShape;
238
+ const rawPodcasts = Array.isArray(json?.data) ? json.data : [];
239
+
240
+ const podcasts: PodcastSummary[] = [];
241
+ for (const raw of rawPodcasts) {
242
+ // A single malformed catalog row must not fail the whole search.
243
+ const parsed = podcastSummarySchema.safeParse(raw);
244
+ if (parsed.success) {
245
+ podcasts.push(parsed.data);
246
+ }
247
+ }
248
+ return podcasts;
249
+ },
250
+
251
+ async getPodcast(id) {
252
+ const json = (await getJson(
253
+ `/api/podcasts/${encodeURIComponent(id)}`,
254
+ )) as PodcastDetailResponseShape;
255
+ return podcastSummarySchema.parse(json?.data?.podcast);
256
+ },
257
+
258
+ podcastUrl(id) {
259
+ return `${webBaseURL}/podcasts/${encodeURIComponent(id)}`;
260
+ },
261
+
262
+ podcastArtworkUrl(source, size) {
263
+ if (size && source.imageSizes) {
264
+ const resolved = resolveImageRef(source.imageSizes[size]?.url);
265
+ if (resolved) {
266
+ return resolved;
267
+ }
268
+ }
269
+
270
+ const fromImage = resolveImageRef(source.image);
271
+ if (fromImage) {
272
+ return fromImage;
273
+ }
274
+
275
+ if (source.imageSizes) {
276
+ for (const key of ARTWORK_FALLBACK_ORDER) {
277
+ const resolved = resolveImageRef(source.imageSizes[key]?.url);
278
+ if (resolved) {
279
+ return resolved;
280
+ }
281
+ }
282
+ }
283
+
284
+ return resolveImageRef(source.imageSourceUrl);
285
+ },
177
286
  };
178
287
  }
package/src/index.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  export {
2
2
  createSyraClient,
3
3
  DEFAULT_SYRA_BASE_URL,
4
+ DEFAULT_SYRA_WEB_BASE_URL,
4
5
  } from './client';
5
6
  export type {
6
7
  SyraClient,
7
8
  SyraClientOptions,
8
9
  SearchTracksOptions,
10
+ SearchPodcastsOptions,
9
11
  ArtworkSource,
12
+ PodcastArtworkSource,
10
13
  } from './client';
11
14
  export {
12
15
  trackSummarySchema,
16
+ podcastSummarySchema,
13
17
  coverArtSizesSchema,
14
18
  coverArtVariantSchema,
15
19
  } from './schema';
16
20
  export type {
17
21
  TrackSummary,
22
+ PodcastSummary,
18
23
  CoverArtSizes,
19
24
  CoverArtVariant,
20
25
  ArtworkSize,
package/src/schema.ts CHANGED
@@ -48,3 +48,24 @@ export const trackSummarySchema = z.object({
48
48
  previewAvailable: z.boolean().optional(),
49
49
  });
50
50
  export type TrackSummary = z.infer<typeof trackSummarySchema>;
51
+
52
+ /**
53
+ * The summary view of a podcast SHOW returned by the public podcast endpoints
54
+ * (`GET /api/podcasts/search`, `GET /api/podcasts/:id`) — just enough to render
55
+ * a show card and deep-link into the Syra app.
56
+ *
57
+ * Artwork mirrors tracks: `image` is the re-hosted Syra image id (resolved via
58
+ * `/api/images/:id`); `imageSizes` is the multi-resolution variant set (each
59
+ * variant `url` is `/api/images/:id`); `imageSourceUrl` keeps the original
60
+ * external artwork URL as an absolute fallback when re-hosting has not run yet.
61
+ */
62
+ export const podcastSummarySchema = z.object({
63
+ id: z.string(),
64
+ title: z.string(),
65
+ author: z.string().optional(),
66
+ description: z.string().optional(),
67
+ image: z.string().optional(),
68
+ imageSizes: coverArtSizesSchema.optional(),
69
+ imageSourceUrl: z.string().optional(),
70
+ });
71
+ export type PodcastSummary = z.infer<typeof podcastSummarySchema>;