@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 +21 -0
- package/README.md +269 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/itunes-search/iTunesSearch.d.ts +6 -0
- package/dist/itunes-search/iTunesSearch.d.ts.map +1 -0
- package/dist/itunes-search/iTunesSearch.js +7 -0
- package/dist/itunes-search/iTunesSearch.js.map +1 -0
- package/dist/itunes-search/itunes-search-options.d.ts +15 -0
- package/dist/itunes-search/itunes-search-options.d.ts.map +1 -0
- package/dist/itunes-search/itunes-search-options.js +1 -0
- package/dist/itunes-search/itunes-search-options.js.map +1 -0
- package/dist/itunes-search/itunes-search-result.d.ts +41 -0
- package/dist/itunes-search/itunes-search-result.d.ts.map +1 -0
- package/dist/itunes-search/itunes-search-result.js +1 -0
- package/dist/itunes-search/itunes-search.d.ts +9 -0
- package/dist/itunes-search/itunes-search.d.ts.map +1 -0
- package/dist/itunes-search/itunes-search.js +30 -0
- package/dist/itunes-search/itunes-search.spec.d.ts +2 -0
- package/dist/itunes-search/itunes-search.spec.d.ts.map +1 -0
- package/dist/itunes-search/itunes-search.spec.js +46 -0
- package/dist/podcast/podcast.d.ts +55 -0
- package/dist/podcast/podcast.d.ts.map +1 -0
- package/dist/podcast/podcast.js +120 -0
- package/dist/podcast/podcast.spec.d.ts +2 -0
- package/dist/podcast/podcast.spec.d.ts.map +1 -0
- package/dist/podcast/podcast.spec.js +364 -0
- package/package.json +71 -0
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
|
+
[](https://badge.fury.io/js/podca-ts)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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)
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,0CAA0C,CAAC"}
|
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|