@rapthi/podca-ts 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Thierry Rapillard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # podca-ts
2
+
3
+ [![npm version](https://badge.fury.io/js/podca-ts.svg)](https://badge.fury.io/js/podca-ts)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org/)
6
+
7
+ A modern, fully-typed TypeScript library for parsing and managing podcast feeds from RSS/iTunes feeds. Built with type safety, error handling, and comprehensive test coverage.
8
+
9
+ ## Features
10
+
11
+ - 📡 **Parse RSS Feeds** - Extract podcast metadata from standard RSS feeds
12
+ - 🎵 **iTunes Support** - Full support for iTunes-specific podcast metadata
13
+ - 🔍 **iTunes Search** - Search for podcasts using the iTunes Search API
14
+ - 📦 **Fully Typed** - Complete TypeScript support with strict type checking
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install podca-ts
20
+ ```
21
+ ```bash
22
+ yarn add podca-ts
23
+ ```
24
+ ```bash
25
+ pnpm add podca-ts
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ### Parse a Podcast Feed
31
+
32
+ ```typescript
33
+ import { PodcastLoader } from 'podca-ts';
34
+
35
+ const loader = new PodcastLoader();
36
+
37
+ try {
38
+ const podcast = await loader.getPodcastFromFeed(
39
+ 'https://example.com/podcast/feed.xml'
40
+ );
41
+
42
+ console.log(`Podcast: ${podcast.title}`);
43
+ console.log(`Episodes: ${podcast.episodes?.length}`);
44
+
45
+ podcast.episodes?.forEach((episode) => {
46
+ console.log(`- ${episode.title} (${episode.durationInSeconds}s)`);
47
+ });
48
+ } catch (error) {
49
+ console.error('Failed to load podcast:', error);
50
+ }
51
+ ```
52
+
53
+ ### Search for Podcasts on iTunes
54
+
55
+ ```typescript
56
+ import { ItunesSearch } from 'podca-ts';
57
+
58
+ const searcher = new ItunesSearch();
59
+
60
+ try {
61
+ const results = await searcher.search({
62
+ term: 'javascript',
63
+ media: 'podcast',
64
+ limit: 10,
65
+ country: 'US',
66
+ });
67
+
68
+ console.log(`Found ${results.resultCount} podcasts`);
69
+
70
+ results.results.forEach((podcast) => {
71
+ console.log(`- ${podcast.trackName} by ${podcast.artistName}`);
72
+ });
73
+ } catch (error) {
74
+ console.error('Search failed:', error);
75
+ }
76
+ ```
77
+
78
+ ## API Documentation
79
+
80
+ ### PodcastLoader
81
+
82
+ The main class for parsing podcast feeds.
83
+
84
+ #### Constructor
85
+
86
+ ```typescript
87
+ const loader = new PodcastLoader();
88
+ ```
89
+
90
+ #### Methods
91
+
92
+ ##### `getPodcastFromFeed(feedUrl: string): Promise<Podcast>`
93
+
94
+ Fetches and parses a podcast feed from the given URL.
95
+
96
+ **Parameters:**
97
+ - `feedUrl` (string): The URL of the podcast RSS feed
98
+
99
+ **Returns:** A Promise that resolves to a `Podcast` object
100
+
101
+ **Throws:**
102
+ - `Error` if the feed URL is invalid
103
+ - `Error` if the feed cannot be fetched
104
+ - `Error` if the feed is malformed or missing required fields
105
+ - `Error` if the request times out (30 seconds)
106
+
107
+ **Example:**
108
+
109
+ ```typescript
110
+ const podcast = await loader.getPodcastFromFeed(
111
+ 'https://feeds.example.com/podcast.xml'
112
+ );
113
+ ```
114
+
115
+ ### ItunesSearch
116
+
117
+ Search for podcasts using the iTunes Search API.
118
+
119
+ #### Constructor
120
+
121
+ ```typescript
122
+ const searcher = new ItunesSearch();
123
+ ```
124
+
125
+ #### Methods
126
+
127
+ ##### `search<T extends MediaType>(option: ITunesSearchParams<T>): Promise<ITunesSearchResponse>`
128
+
129
+ Searches for content on iTunes.
130
+
131
+ **Parameters:**
132
+ - `option` (ITunesSearchParams): Search parameters
133
+
134
+ **Returns:** A Promise that resolves to an `ITunesSearchResponse` object
135
+
136
+ **Throws:**
137
+ - `Error` if the API request fails
138
+ - `Error` if the response is not OK
139
+
140
+ **Example:**
141
+
142
+ ```typescript
143
+ const results = await searcher.search({
144
+ term: 'typescript',
145
+ media: 'podcast',
146
+ limit: 25,
147
+ country: 'US',
148
+ lang: 'en_us',
149
+ });
150
+ ```
151
+
152
+ ## Types
153
+
154
+ ### Podcast
155
+
156
+ ```typescript
157
+ interface Podcast {
158
+ title: string;
159
+ description?: string;
160
+ link: string;
161
+ language?: string;
162
+ categories: Category[];
163
+ explicit: boolean;
164
+ imageUrl?: string;
165
+ author?: string;
166
+ copyright?: string;
167
+ fundingUrl?: string;
168
+ type?: string;
169
+ episodes?: Episode[];
170
+ }
171
+ ```
172
+
173
+ ### Episode
174
+
175
+ ```typescript
176
+ interface Episode {
177
+ title?: string;
178
+ guid: string;
179
+ enclosure?: Enclosure;
180
+ linkUrl?: string;
181
+ pubDate?: string;
182
+ description?: string;
183
+ durationInSeconds?: string | number;
184
+ imageUrl?: string;
185
+ explicit?: boolean;
186
+ number?: number;
187
+ season?: number;
188
+ type?: string;
189
+ }
190
+ ```
191
+
192
+ ### Category
193
+
194
+ ```typescript
195
+ interface Category {
196
+ name: string;
197
+ }
198
+ ```
199
+
200
+ ### Enclosure
201
+
202
+ ```typescript
203
+ interface Enclosure {
204
+ url: string;
205
+ type: string;
206
+ length: string;
207
+ }
208
+ ```
209
+
210
+ ### ITunesSearchParams
211
+
212
+ ```typescript
213
+ interface ITunesSearchParams<T extends MediaType> {
214
+ term: string;
215
+ media: T;
216
+ entity?: EntityForMediaType<T>;
217
+ country?: string;
218
+ limit?: number;
219
+ lang?: string;
220
+ version?: number;
221
+ explicit?: 'Yes' | 'No';
222
+ }
223
+ ```
224
+
225
+ ## Error Handling
226
+
227
+ The library provides detailed error messages to help with debugging:
228
+
229
+ ```typescript
230
+ import { PodcastLoader } from 'podca-ts';
231
+
232
+ const loader = new PodcastLoader();
233
+
234
+ try {
235
+ const podcast = await loader.getPodcastFromFeed('invalid-url');
236
+ } catch (error) {
237
+ if (error instanceof Error) {
238
+ console.error(error.message);
239
+ // Output: "Invalid feed URL: invalid-url"
240
+ }
241
+ }
242
+ ```
243
+
244
+ ## Requirements
245
+
246
+ - **Node.js**: >= 18.0.0
247
+ - **TypeScript**: >= 5.0.0 (for development)
248
+
249
+ ## Contributing
250
+
251
+ Contributions are welcome! Please feel free to submit a Pull Request.
252
+
253
+ ## License
254
+
255
+ MIT © [Thierry Rapillard](https://github.com/rapthi)
256
+
257
+ ## Changelog
258
+
259
+ See [CHANGELOG.md](./CHANGELOG.md) for a list of changes in each release.
260
+
261
+ ## Support
262
+
263
+ If you encounter any issues or have questions, please open an issue on [GitHub](https://github.com/rapthi/podca-ts/issues).
264
+
265
+ ## Acknowledgments
266
+
267
+ - Built with [TypeScript](https://www.typescriptlang.org/)
268
+ - Tested with [Vitest](https://vitest.dev/)
269
+ - Parsing powered by [@sesamy/podcast-parser](https://www.npmjs.com/package/@sesamy/podcast-parser)
@@ -0,0 +1,5 @@
1
+ export type * from './itunes-search/itunes-search-options.js';
2
+ export type * from './itunes-search/itunes-search-result.js';
3
+ export * from './itunes-search/itunes-search.js';
4
+ export * from './podcast/podcast.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,0CAA0C,CAAC;AAC9D,mBAAmB,yCAAyC,CAAC;AAC7D,cAAc,kCAAkC,CAAC;AACjD,cAAc,sBAAsB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './itunes-search/itunes-search.js';
2
+ export * from './podcast/podcast.js';
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,0CAA0C,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { ITunesSearchParams, MediaType } from './itunes-search-options.js';
2
+ export declare class iTunesSearch {
3
+ constructor();
4
+ search<T extends MediaType>(option: ITunesSearchParams<T>): void;
5
+ }
6
+ //# sourceMappingURL=iTunesSearch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iTunesSearch.d.ts","sourceRoot":"","sources":["../../src/itunes-search/iTunesSearch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAEhF,qBAAa,YAAY;;IAGvB,MAAM,CAAC,CAAC,SAAS,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;CAG1D"}
@@ -0,0 +1,7 @@
1
+ export class iTunesSearch {
2
+ constructor() { }
3
+ search(option) {
4
+ console.log('Searching iTunes for', option);
5
+ }
6
+ }
7
+ //# sourceMappingURL=iTunesSearch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iTunesSearch.js","sourceRoot":"","sources":["../../src/itunes-search/iTunesSearch.ts"],"names":[],"mappings":"AAEA,MAAM,OAAO,YAAY;IACvB,gBAAe,CAAC;IAEhB,MAAM,CAAsB,MAA6B;QACvD,OAAO,CAAC,GAAG,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAAC;IAC9C,CAAC;CACF"}
@@ -0,0 +1,15 @@
1
+ export type MediaType = 'movie' | 'podcast' | 'music' | 'musicVideo' | 'audiobook' | 'shortFilm' | 'tvShow' | 'software' | 'ebook' | 'all';
2
+ export type Entity = 'movieArtist' | 'movie' | 'podcastAuthor' | 'podcast' | 'musicArtist' | 'musicTrack' | 'album' | 'musicVideo' | 'mix' | 'song' | 'audiobookAuthor' | 'audiobook' | 'shortFilmArtist' | 'shortFilm' | 'tvEpisode' | 'tvSeason' | 'software' | 'iPadSoftware' | 'macSoftware' | 'ebook' | 'allArtist' | 'allTrack';
3
+ type EntityForMediaType<T extends MediaType> = T extends 'movie' ? 'movieArtist' | 'movie' : T extends 'podcast' ? 'podcastAuthor' | 'podcast' : T extends 'music' ? 'musicArtist' | 'musicTrack' | 'album' | 'musicVideo' | 'mix' | 'song' : T extends 'musicVideo' ? 'musicArtist' | 'musicVideo' : T extends 'audiobook' ? 'audiobookAuthor' | 'audiobook' : T extends 'shortFilm' ? 'shortFilmArtist' | 'shortFilm' : T extends 'tvShow' ? 'tvEpisode' | 'tvSeason' : T extends 'software' ? 'software' | 'iPadSoftware' | 'macSoftware' : T extends 'ebook' ? 'ebook' : T extends 'all' ? 'movie' | 'album' | 'allArtist' | 'podcast' | 'musicVideo' | 'mix' | 'audiobook' | 'tvSeason' | 'allTrack' : never;
4
+ export interface ITunesSearchParams<T extends MediaType> {
5
+ media: T;
6
+ entity?: EntityForMediaType<T>;
7
+ term: string;
8
+ country?: string;
9
+ limit?: number;
10
+ lang?: string;
11
+ version?: number;
12
+ explicit?: 'Yes' | 'No';
13
+ }
14
+ export {};
15
+ //# sourceMappingURL=itunes-search-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"itunes-search-options.d.ts","sourceRoot":"","sources":["../../src/itunes-search/itunes-search-options.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GACjB,OAAO,GACP,SAAS,GACT,OAAO,GACP,YAAY,GACZ,WAAW,GACX,WAAW,GACX,QAAQ,GACR,UAAU,GACV,OAAO,GACP,KAAK,CAAC;AAEV,MAAM,MAAM,MAAM,GACd,aAAa,GACb,OAAO,GACP,eAAe,GACf,SAAS,GACT,aAAa,GACb,YAAY,GACZ,OAAO,GACP,YAAY,GACZ,KAAK,GACL,MAAM,GACN,iBAAiB,GACjB,WAAW,GACX,iBAAiB,GACjB,WAAW,GACX,WAAW,GACX,UAAU,GACV,UAAU,GACV,cAAc,GACd,aAAa,GACb,OAAO,GACP,WAAW,GACX,UAAU,CAAC;AAEf,KAAK,kBAAkB,CAAC,CAAC,SAAS,SAAS,IAAI,CAAC,SAAS,OAAO,GAC5D,aAAa,GAAG,OAAO,GACvB,CAAC,SAAS,SAAS,GACjB,eAAe,GAAG,SAAS,GAC3B,CAAC,SAAS,OAAO,GACf,aAAa,GAAG,YAAY,GAAG,OAAO,GAAG,YAAY,GAAG,KAAK,GAAG,MAAM,GACtE,CAAC,SAAS,YAAY,GACpB,aAAa,GAAG,YAAY,GAC5B,CAAC,SAAS,WAAW,GACnB,iBAAiB,GAAG,WAAW,GAC/B,CAAC,SAAS,WAAW,GACnB,iBAAiB,GAAG,WAAW,GAC/B,CAAC,SAAS,QAAQ,GAChB,WAAW,GAAG,UAAU,GACxB,CAAC,SAAS,UAAU,GAClB,UAAU,GAAG,cAAc,GAAG,aAAa,GAC3C,CAAC,SAAS,OAAO,GACf,OAAO,GACP,CAAC,SAAS,KAAK,GAET,OAAO,GACP,OAAO,GACP,WAAW,GACX,SAAS,GACT,YAAY,GACZ,KAAK,GACL,WAAW,GACX,UAAU,GACV,UAAU,GACd,KAAK,CAAC;AAE5B,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,SAAS;IACrD,KAAK,EAAE,CAAC,CAAC;IACT,MAAM,CAAC,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;CACzB"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ {"version":3,"file":"itunes-search-options.js","sourceRoot":"","sources":["../../src/itunes-search/itunes-search-options.ts"],"names":[],"mappings":""}
@@ -0,0 +1,41 @@
1
+ export type WrapperType = 'track' | 'collection' | 'artist';
2
+ export type Explicitness = 'explicit' | 'cleaned' | 'notExplicit';
3
+ export type Kind = 'book' | 'album' | 'coached-audio' | 'feature-movie' | 'interactive-booklet' | 'music-video' | 'pdf' | 'podcast' | 'podcast-episode' | 'software-package' | 'song' | 'tv-episode' | 'artist';
4
+ export interface ITunesSearchResult {
5
+ wrapperType: WrapperType;
6
+ kind: Kind;
7
+ artistId?: number;
8
+ collectionId?: number;
9
+ trackId?: number;
10
+ artistName?: string;
11
+ collectionName?: string;
12
+ trackName?: string;
13
+ collectionCensoredName?: string;
14
+ trackCensoredName?: string;
15
+ artistViewUrl?: string;
16
+ collectionViewUrl?: string;
17
+ trackViewUrl?: string;
18
+ previewUrl?: string;
19
+ artworkUrl30?: string;
20
+ artworkUrl60?: string;
21
+ artworkUrl100?: string;
22
+ collectionPrice?: number;
23
+ trackPrice?: number;
24
+ collectionExplicitness?: Explicitness;
25
+ trackExplicitness?: Explicitness;
26
+ discCount?: number;
27
+ discNumber?: number;
28
+ trackCount?: number;
29
+ trackNumber?: number;
30
+ trackTimeMillis?: number;
31
+ country?: string;
32
+ currency?: string;
33
+ primaryGenreName?: string;
34
+ releaseDate?: string;
35
+ feedUrl?: string;
36
+ }
37
+ export interface ITunesSearchResponse {
38
+ resultCount: number;
39
+ results: ITunesSearchResult[];
40
+ }
41
+ //# sourceMappingURL=itunes-search-result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"itunes-search-result.d.ts","sourceRoot":"","sources":["../../src/itunes-search/itunes-search-result.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,YAAY,GAAG,QAAQ,CAAC;AAE5D,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,SAAS,GAAG,aAAa,CAAC;AAElE,MAAM,MAAM,IAAI,GACZ,MAAM,GACN,OAAO,GACP,eAAe,GACf,eAAe,GACf,qBAAqB,GACrB,aAAa,GACb,KAAK,GACL,SAAS,GACT,iBAAiB,GACjB,kBAAkB,GAClB,MAAM,GACN,YAAY,GACZ,QAAQ,CAAC;AAEb,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;IAEX,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,sBAAsB,CAAC,EAAE,YAAY,CAAC;IACtC,iBAAiB,CAAC,EAAE,YAAY,CAAC;IAEjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,kBAAkB,EAAE,CAAC;CAC/B"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { ITunesSearchParams, MediaType } from './itunes-search-options.js';
2
+ import type { ITunesSearchResponse } from './itunes-search-result.js';
3
+ export declare class ItunesSearch {
4
+ private static readonly ITUNES_SEARCH_URL;
5
+ constructor();
6
+ search<T extends MediaType>(option: ITunesSearchParams<T>): Promise<ITunesSearchResponse>;
7
+ private buildSearchUrl;
8
+ }
9
+ //# sourceMappingURL=itunes-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"itunes-search.d.ts","sourceRoot":"","sources":["../../src/itunes-search/itunes-search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAChF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAEtE,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAqC;;IAIxE,MAAM,CAAC,CAAC,SAAS,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAuB/F,OAAO,CAAC,cAAc;CAWvB"}
@@ -0,0 +1,30 @@
1
+ export class ItunesSearch {
2
+ constructor() { }
3
+ async search(option) {
4
+ const searchUrlWithParams = this.buildSearchUrl(option);
5
+ try {
6
+ const response = await fetch(searchUrlWithParams);
7
+ if (!response.ok) {
8
+ // noinspection ExceptionCaughtLocallyJS
9
+ throw new Error(`Failed to fetch data from iTunes Search API: ${response.status}}`);
10
+ }
11
+ return await response.json();
12
+ }
13
+ catch (error) {
14
+ if (error instanceof Error) {
15
+ throw new Error(`Fetch failed: ${error.message}`);
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ buildSearchUrl(option) {
21
+ const url = new URL(ItunesSearch.ITUNES_SEARCH_URL);
22
+ for (const [key, value] of Object.entries(option)) {
23
+ if (value !== undefined) {
24
+ url.searchParams.append(key, String(value));
25
+ }
26
+ }
27
+ return url.toString();
28
+ }
29
+ }
30
+ ItunesSearch.ITUNES_SEARCH_URL = 'https://itunes.apple.com/search';
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=itunes-search.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"itunes-search.spec.d.ts","sourceRoot":"","sources":["../../src/itunes-search/itunes-search.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ItunesSearch } from './itunes-search';
3
+ describe('itunesSearch', () => {
4
+ let searcher;
5
+ beforeEach(() => {
6
+ searcher = new ItunesSearch();
7
+ vi.stubGlobal('fetch', vi.fn());
8
+ });
9
+ it('should return data with success when the response is ok', async () => {
10
+ const mockData = {
11
+ resultCount: 1,
12
+ results: [
13
+ {
14
+ wrapperType: 'track',
15
+ kind: 'song',
16
+ trackName: 'Test Song',
17
+ artistName: 'Test Artist',
18
+ collectionName: 'Test Album',
19
+ trackPrice: 0.99,
20
+ country: 'USA',
21
+ currency: 'USD',
22
+ releaseDate: '2023-01-01T08:00:00Z',
23
+ primaryGenreName: 'Pop',
24
+ },
25
+ ],
26
+ };
27
+ vi.mocked(fetch).mockResolvedValueOnce({
28
+ ok: true,
29
+ json: async () => mockData,
30
+ });
31
+ const result = await searcher.search({ term: 'test', media: 'music' });
32
+ expect(result).toEqual(mockData);
33
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining('https://itunes.apple.com/search?term=test&media=music'));
34
+ });
35
+ it("should raise an error if the http response is not ok (ex: 404)", async () => {
36
+ vi.mocked(fetch).mockResolvedValueOnce({
37
+ ok: false,
38
+ status: 404,
39
+ });
40
+ await expect(searcher.search({ term: 'invalid', media: 'podcast' })).rejects.toThrow('Failed to fetch data from iTunes Search API: 404');
41
+ });
42
+ it('should raise an error "Fetch failed" in case of network failure', async () => {
43
+ vi.mocked(fetch).mockRejectedValueOnce(new Error('Network Error'));
44
+ await expect(searcher.search({ term: 'test', media: 'podcast' })).rejects.toThrow('Fetch failed: Network Error');
45
+ });
46
+ });
@@ -0,0 +1,55 @@
1
+ export declare class PodcastLoader {
2
+ private readonly FETCH_TIMEOUT_MS;
3
+ /**
4
+ * Fetches and parses a podcast feed from the given URL
5
+ * @param feedUrl - The URL of the podcast feed
6
+ * @returns A Promise resolving to the parsed Podcast
7
+ * @throws Error if the feed is invalid or the fetch fails
8
+ */
9
+ getPodcastFromFeed(feedUrl: string): Promise<Podcast>;
10
+ private validateUrl;
11
+ private extractChannel;
12
+ private validateChannel;
13
+ private mapChannel;
14
+ private mapCategories;
15
+ private mapEpisodes;
16
+ private mapEnclosure;
17
+ }
18
+ export interface Category {
19
+ name: string;
20
+ }
21
+ export interface Episode {
22
+ title: string | undefined;
23
+ enclosure: Enclosure | undefined;
24
+ guid: string;
25
+ linkUrl?: string;
26
+ pubDate?: string;
27
+ description?: string;
28
+ durationInSeconds?: string | number | undefined;
29
+ imageUrl?: string;
30
+ explicit?: boolean;
31
+ number?: number;
32
+ season?: number;
33
+ type?: string | undefined;
34
+ }
35
+ export interface Enclosure {
36
+ length: string;
37
+ type: string;
38
+ url: string;
39
+ }
40
+ export interface Podcast {
41
+ title: string;
42
+ description: string | undefined;
43
+ link: string;
44
+ language: string | undefined;
45
+ categories: Category[];
46
+ explicit: boolean;
47
+ imageUrl?: string;
48
+ author?: string;
49
+ copyright?: string;
50
+ fundingUrl?: string;
51
+ type?: string;
52
+ complete?: boolean;
53
+ episodes?: Episode[];
54
+ }
55
+ //# sourceMappingURL=podcast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"podcast.d.ts","sourceRoot":"","sources":["../../src/podcast/podcast.ts"],"names":[],"mappings":"AAiDA,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C;;;;;OAKG;IACG,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA0C3D,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,UAAU;IAiBlB,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,YAAY;CASrB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB"}
@@ -0,0 +1,120 @@
1
+ import { parseFeedToJson } from '@sesamy/podcast-parser';
2
+ export class PodcastLoader {
3
+ constructor() {
4
+ this.FETCH_TIMEOUT_MS = 30000;
5
+ }
6
+ /**
7
+ * Fetches and parses a podcast feed from the given URL
8
+ * @param feedUrl - The URL of the podcast feed
9
+ * @returns A Promise resolving to the parsed Podcast
10
+ * @throws Error if the feed is invalid or the fetch fails
11
+ */
12
+ async getPodcastFromFeed(feedUrl) {
13
+ this.validateUrl(feedUrl);
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), this.FETCH_TIMEOUT_MS);
16
+ try {
17
+ const response = await fetch(feedUrl, { signal: controller.signal });
18
+ if (!response.ok) {
19
+ throw new Error(`Failed to fetch podcast feed: ${response.status} ${response.statusText}`);
20
+ }
21
+ const xmlString = await response.text();
22
+ if (!xmlString.trim()) {
23
+ throw new Error('Podcast feed is empty');
24
+ }
25
+ const podcastFromXml = await parseFeedToJson(xmlString);
26
+ const channel = this.extractChannel(podcastFromXml);
27
+ this.validateChannel(channel);
28
+ return this.mapChannel(channel);
29
+ }
30
+ catch (error) {
31
+ if (error instanceof Error && error.name === 'AbortError') {
32
+ throw new Error(`Podcast feed request timeout after ${this.FETCH_TIMEOUT_MS}ms`);
33
+ }
34
+ if (error instanceof Error) {
35
+ throw new Error(`Failed to load podcast feed: ${error.message}`);
36
+ }
37
+ throw error;
38
+ }
39
+ finally {
40
+ clearTimeout(timeoutId);
41
+ }
42
+ }
43
+ validateUrl(feedUrl) {
44
+ try {
45
+ new URL(feedUrl);
46
+ }
47
+ catch {
48
+ throw new Error(`Invalid feed URL: ${feedUrl}`);
49
+ }
50
+ }
51
+ extractChannel(podcastFromXml) {
52
+ const channel = podcastFromXml?.rss?.channel;
53
+ if (!channel) {
54
+ throw new Error('Invalid podcast feed: missing channel data');
55
+ }
56
+ return channel;
57
+ }
58
+ validateChannel(channel) {
59
+ if (!channel.title) {
60
+ throw new Error('Invalid podcast feed: missing required field "title"');
61
+ }
62
+ if (!channel.link) {
63
+ throw new Error('Invalid podcast feed: missing required field "link"');
64
+ }
65
+ }
66
+ mapChannel(channel) {
67
+ return {
68
+ title: channel.title,
69
+ description: channel.description,
70
+ link: channel.link,
71
+ language: channel.language,
72
+ categories: this.mapCategories(channel['itunes:category']),
73
+ explicit: channel['itunes:explicit'] === 'true',
74
+ imageUrl: channel['itunes:image']?.['@_href'],
75
+ author: channel['itunes:author'],
76
+ copyright: channel['copyright'],
77
+ fundingUrl: channel['podcast:funding']?.['@_url'],
78
+ type: channel['itunes:type'],
79
+ episodes: this.mapEpisodes(channel.item),
80
+ };
81
+ }
82
+ mapCategories(categories) {
83
+ if (!categories) {
84
+ return [];
85
+ }
86
+ return categories.flatMap((category) => [
87
+ { name: category['@_text'] },
88
+ ...(category['itunes:category']?.map((sub) => ({ name: sub['@_text'] })) || []),
89
+ ]);
90
+ }
91
+ mapEpisodes(items) {
92
+ if (!items) {
93
+ return [];
94
+ }
95
+ return items.map((item) => ({
96
+ title: item.title,
97
+ enclosure: this.mapEnclosure(item.enclosure),
98
+ guid: item.guid['#text'],
99
+ linkUrl: item.link,
100
+ pubDate: item.pubDate,
101
+ description: item.description,
102
+ durationInSeconds: item['itunes:duration'],
103
+ imageUrl: item['itunes:image']?.['@_href'],
104
+ explicit: item['itunes:explicit'] === 'yes',
105
+ number: item['itunes:episode'],
106
+ season: item['itunes:season'],
107
+ type: item['itunes:episodeType'],
108
+ }));
109
+ }
110
+ mapEnclosure(enclosure) {
111
+ if (!enclosure?.[0]) {
112
+ return undefined;
113
+ }
114
+ return {
115
+ url: enclosure[0]['@_url'],
116
+ type: enclosure[0]['@_type'],
117
+ length: enclosure[0]['@_length'],
118
+ };
119
+ }
120
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=podcast.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"podcast.spec.d.ts","sourceRoot":"","sources":["../../src/podcast/podcast.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,364 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { PodcastLoader } from './podcast';
3
+ vi.mock('@sesamy/podcast-parser', () => ({
4
+ parseFeedToJson: vi.fn(),
5
+ }));
6
+ import { parseFeedToJson } from '@sesamy/podcast-parser';
7
+ describe('PodcastLoader', () => {
8
+ let podcastLoader;
9
+ beforeEach(() => {
10
+ podcastLoader = new PodcastLoader();
11
+ vi.stubGlobal('fetch', vi.fn());
12
+ });
13
+ afterEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+ describe('getPodcastFromFeed', () => {
17
+ it('should successfully parse a valid podcast feed', async () => {
18
+ const mockXmlResponse = `<?xml version="1.0" encoding="UTF-8"?>
19
+ <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0">
20
+ <channel>
21
+ <title>Test Podcast</title>
22
+ <description>A test podcast</description>
23
+ <link>https://example.com</link>
24
+ <language>en</language>
25
+ <itunes:category text="Technology">
26
+ <itunes:category text="Software How-To"/>
27
+ </itunes:category>
28
+ <itunes:explicit>false</itunes:explicit>
29
+ <itunes:image href="https://example.com/image.jpg"/>
30
+ <itunes:author>John Doe</itunes:author>
31
+ <copyright>2024 Test Podcast</copyright>
32
+ <podcast:funding url="https://example.com/support">Support Us</podcast:funding>
33
+ <itunes:type>episodic</itunes:type>
34
+ <item>
35
+ <title>Episode 1</title>
36
+ <guid>#text</guid>
37
+ <link>https://example.com/episode1</link>
38
+ <pubDate>Mon, 01 Jan 2024 12:00:00 GMT</pubDate>
39
+ <description>First episode</description>
40
+ <itunes:duration>3600</itunes:duration>
41
+ <itunes:image href="https://example.com/ep1.jpg"/>
42
+ <itunes:explicit>no</itunes:explicit>
43
+ <itunes:episode>1</itunes:episode>
44
+ <itunes:season>1</itunes:season>
45
+ <itunes:episodeType>full</itunes:episodeType>
46
+ <enclosure url="https://example.com/ep1.mp3" type="audio/mpeg" length="123456"/>
47
+ </item>
48
+ </channel>
49
+ </rss>`;
50
+ const mockParsedData = {
51
+ rss: {
52
+ channel: {
53
+ title: 'Test Podcast',
54
+ description: 'A test podcast',
55
+ link: 'https://example.com',
56
+ language: 'en',
57
+ 'itunes:category': [
58
+ {
59
+ '@_text': 'Technology',
60
+ 'itunes:category': [{ '@_text': 'Software How-To' }],
61
+ },
62
+ ],
63
+ 'itunes:explicit': 'false',
64
+ 'itunes:image': { '@_href': 'https://example.com/image.jpg' },
65
+ 'itunes:author': 'John Doe',
66
+ copyright: '2024 Test Podcast',
67
+ 'podcast:funding': { '@_url': 'https://example.com/support' },
68
+ 'itunes:type': 'episodic',
69
+ item: [
70
+ {
71
+ title: 'Episode 1',
72
+ guid: { '#text': 'episode-1-guid' },
73
+ link: 'https://example.com/episode1',
74
+ pubDate: 'Mon, 01 Jan 2024 12:00:00 GMT',
75
+ description: 'First episode',
76
+ 'itunes:duration': '3600',
77
+ 'itunes:image': { '@_href': 'https://example.com/ep1.jpg' },
78
+ 'itunes:explicit': 'no',
79
+ 'itunes:episode': 1,
80
+ 'itunes:season': 1,
81
+ 'itunes:episodeType': 'full',
82
+ enclosure: [
83
+ {
84
+ '@_url': 'https://example.com/ep1.mp3',
85
+ '@_type': 'audio/mpeg',
86
+ '@_length': '123456',
87
+ },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ },
93
+ };
94
+ vi.mocked(fetch).mockResolvedValueOnce({
95
+ ok: true,
96
+ text: async () => mockXmlResponse,
97
+ });
98
+ vi.mocked(parseFeedToJson).mockResolvedValueOnce(mockParsedData);
99
+ const result = await podcastLoader.getPodcastFromFeed('https://example.com/feed.xml');
100
+ expect(result.title).toBe('Test Podcast');
101
+ expect(result.description).toBe('A test podcast');
102
+ expect(result.link).toBe('https://example.com');
103
+ expect(result.language).toBe('en');
104
+ expect(result.explicit).toBe(false);
105
+ expect(result.imageUrl).toBe('https://example.com/image.jpg');
106
+ expect(result.author).toBe('John Doe');
107
+ expect(result.copyright).toBe('2024 Test Podcast');
108
+ expect(result.fundingUrl).toBe('https://example.com/support');
109
+ expect(result.type).toBe('episodic');
110
+ expect(result.categories).toHaveLength(2);
111
+ expect(result.episodes).toHaveLength(1);
112
+ });
113
+ it('should handle explicit content correctly', async () => {
114
+ const mockParsedData = {
115
+ rss: {
116
+ channel: {
117
+ title: 'Explicit Podcast',
118
+ description: 'Contains explicit content',
119
+ link: 'https://example.com',
120
+ language: 'en',
121
+ 'itunes:explicit': 'true',
122
+ item: [],
123
+ },
124
+ },
125
+ };
126
+ vi.mocked(fetch).mockResolvedValueOnce({
127
+ ok: true,
128
+ text: async () => '<xml></xml>',
129
+ });
130
+ vi.mocked(parseFeedToJson).mockResolvedValueOnce(mockParsedData);
131
+ const result = await podcastLoader.getPodcastFromFeed('https://example.com/feed.xml');
132
+ expect(result.explicit).toBe(true);
133
+ });
134
+ it('should handle missing optional fields gracefully', async () => {
135
+ const mockParsedData = {
136
+ rss: {
137
+ channel: {
138
+ title: 'Minimal Podcast',
139
+ link: 'https://example.com',
140
+ item: [],
141
+ },
142
+ },
143
+ };
144
+ vi.mocked(fetch).mockResolvedValueOnce({
145
+ ok: true,
146
+ text: async () => '<xml></xml>',
147
+ });
148
+ vi.mocked(parseFeedToJson).mockResolvedValueOnce(mockParsedData);
149
+ const result = await podcastLoader.getPodcastFromFeed('https://example.com/feed.xml');
150
+ expect(result.title).toBe('Minimal Podcast');
151
+ expect(result.description).toBeUndefined();
152
+ expect(result.imageUrl).toBeUndefined();
153
+ expect(result.author).toBeUndefined();
154
+ expect(result.copyright).toBeUndefined();
155
+ expect(result.fundingUrl).toBeUndefined();
156
+ expect(result.type).toBeUndefined();
157
+ expect(result.categories).toEqual([]);
158
+ expect(result.episodes).toEqual([]);
159
+ });
160
+ it('should throw an error when fetch fails', async () => {
161
+ vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
162
+ await expect(podcastLoader.getPodcastFromFeed('https://invalid.com/feed.xml')).rejects.toThrow('Network error');
163
+ });
164
+ it('should throw an error when response is not ok', async () => {
165
+ vi.mocked(fetch).mockResolvedValueOnce({
166
+ ok: false,
167
+ status: 404,
168
+ });
169
+ await expect(podcastLoader.getPodcastFromFeed('https://example.com/feed.xml')).rejects.toThrow();
170
+ });
171
+ });
172
+ describe('mapCategories', () => {
173
+ it('should map single category without subcategories', () => {
174
+ const categories = [{ '@_text': 'Technology' }];
175
+ const result = podcastLoader['mapCategories'](categories);
176
+ expect(result).toEqual([{ name: 'Technology' }]);
177
+ });
178
+ it('should map categories with subcategories', () => {
179
+ const categories = [
180
+ {
181
+ '@_text': 'Technology',
182
+ 'itunes:category': [
183
+ { '@_text': 'Software How-To' },
184
+ { '@_text': 'Gadgets' },
185
+ ],
186
+ },
187
+ ];
188
+ const result = podcastLoader['mapCategories'](categories);
189
+ expect(result).toEqual([
190
+ { name: 'Technology' },
191
+ { name: 'Software How-To' },
192
+ { name: 'Gadgets' },
193
+ ]);
194
+ });
195
+ it('should handle multiple parent categories with subcategories', () => {
196
+ const categories = [
197
+ {
198
+ '@_text': 'Technology',
199
+ 'itunes:category': [{ '@_text': 'Software How-To' }],
200
+ },
201
+ {
202
+ '@_text': 'Business',
203
+ 'itunes:category': [{ '@_text': 'Careers' }],
204
+ },
205
+ ];
206
+ const result = podcastLoader['mapCategories'](categories);
207
+ expect(result).toEqual([
208
+ { name: 'Technology' },
209
+ { name: 'Software How-To' },
210
+ { name: 'Business' },
211
+ { name: 'Careers' },
212
+ ]);
213
+ });
214
+ it('should return empty array when categories is undefined', () => {
215
+ const result = podcastLoader['mapCategories'](undefined);
216
+ expect(result).toEqual([]);
217
+ });
218
+ it('should handle categories without subcategories', () => {
219
+ const categories = [
220
+ { '@_text': 'Technology' },
221
+ { '@_text': 'Business' },
222
+ ];
223
+ const result = podcastLoader['mapCategories'](categories);
224
+ expect(result).toEqual([
225
+ { name: 'Technology' },
226
+ { name: 'Business' },
227
+ ]);
228
+ });
229
+ });
230
+ describe('mapEpisodes', () => {
231
+ it('should map episodes with all fields', () => {
232
+ const items = [
233
+ {
234
+ title: 'Episode 1',
235
+ guid: { '#text': 'ep1-guid' },
236
+ link: 'https://example.com/ep1',
237
+ pubDate: 'Mon, 01 Jan 2024 12:00:00 GMT',
238
+ description: 'First episode',
239
+ 'itunes:duration': '3600',
240
+ 'itunes:image': { '@_href': 'https://example.com/ep1.jpg' },
241
+ 'itunes:explicit': 'yes',
242
+ 'itunes:episode': 1,
243
+ 'itunes:season': 1,
244
+ 'itunes:episodeType': 'full',
245
+ enclosure: [
246
+ {
247
+ '@_url': 'https://example.com/ep1.mp3',
248
+ '@_type': 'audio/mpeg',
249
+ '@_length': '123456',
250
+ },
251
+ ],
252
+ },
253
+ ];
254
+ const result = podcastLoader['mapEpisodes'](items);
255
+ expect(result).toHaveLength(1);
256
+ expect(result[0]).toEqual({
257
+ title: 'Episode 1',
258
+ guid: 'ep1-guid',
259
+ linkUrl: 'https://example.com/ep1',
260
+ pubDate: 'Mon, 01 Jan 2024 12:00:00 GMT',
261
+ description: 'First episode',
262
+ durationInSeconds: '3600',
263
+ imageUrl: 'https://example.com/ep1.jpg',
264
+ explicit: true,
265
+ number: 1,
266
+ season: 1,
267
+ type: 'full',
268
+ enclosure: {
269
+ url: 'https://example.com/ep1.mp3',
270
+ type: 'audio/mpeg',
271
+ length: '123456',
272
+ },
273
+ });
274
+ });
275
+ it('should handle episodes with minimal fields', () => {
276
+ const items = [
277
+ {
278
+ title: 'Episode 1',
279
+ guid: { '#text': 'ep1-guid' },
280
+ },
281
+ ];
282
+ const result = podcastLoader['mapEpisodes'](items);
283
+ expect(result).toHaveLength(1);
284
+ expect(result[0]).toEqual({
285
+ title: 'Episode 1',
286
+ guid: 'ep1-guid',
287
+ enclosure: undefined,
288
+ linkUrl: undefined,
289
+ pubDate: undefined,
290
+ description: undefined,
291
+ durationInSeconds: undefined,
292
+ imageUrl: undefined,
293
+ explicit: false,
294
+ number: undefined,
295
+ season: undefined,
296
+ type: undefined,
297
+ });
298
+ });
299
+ it('should handle explicit field as "no"', () => {
300
+ const items = [
301
+ {
302
+ title: 'Clean Episode',
303
+ guid: { '#text': 'ep-guid' },
304
+ 'itunes:explicit': 'no',
305
+ },
306
+ ];
307
+ const result = podcastLoader['mapEpisodes'](items);
308
+ expect(result[0].explicit).toBe(false);
309
+ });
310
+ it('should handle multiple episodes', () => {
311
+ const items = [
312
+ {
313
+ title: 'Episode 1',
314
+ guid: { '#text': 'ep1-guid' },
315
+ },
316
+ {
317
+ title: 'Episode 2',
318
+ guid: { '#text': 'ep2-guid' },
319
+ },
320
+ ];
321
+ const result = podcastLoader['mapEpisodes'](items);
322
+ expect(result).toHaveLength(2);
323
+ expect(result[0].title).toBe('Episode 1');
324
+ expect(result[1].title).toBe('Episode 2');
325
+ });
326
+ });
327
+ describe('mapEnclosure', () => {
328
+ it('should map enclosure with all fields', () => {
329
+ const enclosure = [
330
+ {
331
+ '@_url': 'https://example.com/ep1.mp3',
332
+ '@_type': 'audio/mpeg',
333
+ '@_length': '123456',
334
+ },
335
+ ];
336
+ const result = podcastLoader['mapEnclosure'](enclosure);
337
+ expect(result).toEqual({
338
+ url: 'https://example.com/ep1.mp3',
339
+ type: 'audio/mpeg',
340
+ length: '123456',
341
+ });
342
+ });
343
+ it('should return undefined when enclosure is undefined', () => {
344
+ const result = podcastLoader['mapEnclosure'](undefined);
345
+ expect(result).toBeUndefined();
346
+ });
347
+ it('should return undefined when enclosure array is empty', () => {
348
+ const result = podcastLoader['mapEnclosure']([]);
349
+ expect(result).toBeUndefined();
350
+ });
351
+ it('should handle enclosure with different media types', () => {
352
+ const enclosures = [
353
+ {
354
+ '@_url': 'https://example.com/ep1.m4a',
355
+ '@_type': 'audio/mp4',
356
+ '@_length': '654321',
357
+ },
358
+ ];
359
+ const result = podcastLoader['mapEnclosure'](enclosures);
360
+ expect(result?.type).toBe('audio/mp4');
361
+ expect(result?.url).toBe('https://example.com/ep1.m4a');
362
+ });
363
+ });
364
+ });
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@rapthi/podca-ts",
3
+ "version": "1.0.0",
4
+ "description": "A TypeScript library for parsing and managing podcast feeds from RSS/iTunes feeds",
5
+ "author": "Thierry Rapillard <rapthi@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rapthi/podca-ts.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/rapthi/podca-ts/issues"
13
+ },
14
+ "homepage": "https://github.com/rapthi/podca-ts",
15
+ "keywords": [
16
+ "podcast",
17
+ "typescript",
18
+ "parser",
19
+ "itunes",
20
+ "rss",
21
+ "feed",
22
+ "xml"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "require": "./dist/index.cjs"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE",
38
+ "CHANGELOG.md"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest --watch",
44
+ "test:coverage": "vitest --coverage",
45
+ "lint": "eslint src --ext .ts",
46
+ "lint:fix": "eslint src --ext .ts --fix",
47
+ "format": "prettier --write 'src/**/*.{ts,json}'",
48
+ "format:check": "prettier --check 'src/**/*.{ts,json}'",
49
+ "type-check": "tsc --noEmit",
50
+ "prepublishOnly": "npm run build && npm run test && npm run lint",
51
+ "ci": "npm run type-check && npm run format:check && npm run lint && npm run test:coverage && npm run build"
52
+ },
53
+ "dependencies": {
54
+ "@sesamy/podcast-parser": "^1.11.0"
55
+ },
56
+ "devDependencies": {
57
+ "@eslint/js": "^9.39.2",
58
+ "@types/node": "^25.0.3",
59
+ "@typescript-eslint/eslint-plugin": "^8.51.0",
60
+ "@typescript-eslint/parser": "^8.51.0",
61
+ "@vitest/coverage-v8": "^4.0.16",
62
+ "eslint": "^9.39.2",
63
+ "prettier": "^3.7.4",
64
+ "typescript": "^5.9.3",
65
+ "typescript-eslint": "^8.51.0",
66
+ "vitest": "^4.0.16"
67
+ },
68
+ "engines": {
69
+ "node": ">=18.0.0"
70
+ }
71
+ }