@ziplayer/plugin 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ import { BasePlugin,Track, SearchResult, StreamInfo } from "ziplayer";
2
+
3
+ /**
4
+ * This minimal Spotify plugin:
5
+ * - Parses Spotify URLs/URIs (track/playlist/album)
6
+ * - Uses Spotify's public oEmbed endpoint to fetch *display metadata* (no auth, no SDK)
7
+ * - Does NOT provide audio streams (player is expected to redirect/fallback upstream)
8
+ * - Does NOT expand playlists/albums (no SDK; oEmbed doesn't enumerate items)
9
+ */
10
+ export class SpotifyPlugin extends BasePlugin {
11
+ name = "spotify";
12
+ version = "1.1.0";
13
+
14
+ canHandle(query: string): boolean {
15
+ const q = query.toLowerCase().trim();
16
+ if (q.startsWith("spotify:")) return true;
17
+ try {
18
+ const u = new URL(q);
19
+ return u.hostname.includes("open.spotify.com");
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ validate(url: string): boolean {
26
+ if (url.startsWith("spotify:")) return true;
27
+ try {
28
+ const u = new URL(url);
29
+ return u.hostname.includes("open.spotify.com");
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
36
+ if (!this.validate(query)) {
37
+ return { tracks: [] };
38
+ }
39
+
40
+ const kind = this.identifyKind(query);
41
+
42
+ if (kind === "track") {
43
+ const t = await this.buildTrackFromUrlOrUri(query, requestedBy);
44
+ return { tracks: t ? [t] : [] };
45
+ }
46
+
47
+ if (kind === "playlist") {
48
+ const t = await this.buildHeaderItem(query, requestedBy, "playlist");
49
+ return { tracks: t ? [t] : [] };
50
+ }
51
+
52
+ if (kind === "album") {
53
+ const t = await this.buildHeaderItem(query, requestedBy, "album");
54
+ return { tracks: t ? [t] : [] };
55
+ }
56
+
57
+ return { tracks: [] };
58
+ }
59
+
60
+ async extractPlaylist(
61
+ _input: string,
62
+ _requestedBy: string
63
+ ): Promise<Track[]> {
64
+ return [];
65
+ }
66
+
67
+ async extractAlbum(_input: string, _requestedBy: string): Promise<Track[]> {
68
+ return [];
69
+ }
70
+
71
+ async getStream(_track: Track): Promise<StreamInfo> {
72
+ throw new Error("Spotify streaming is not supported by this plugin");
73
+ }
74
+
75
+ private identifyKind(
76
+ input: string
77
+ ): "track" | "playlist" | "album" | "unknown" {
78
+ if (input.startsWith("spotify:")) {
79
+ if (input.includes(":track:")) return "track";
80
+ if (input.includes(":playlist:")) return "playlist";
81
+ if (input.includes(":album:")) return "album";
82
+ return "unknown";
83
+ }
84
+ try {
85
+ const u = new URL(input);
86
+ const parts = u.pathname.split("/").filter(Boolean);
87
+ const kind = parts[0];
88
+ if (kind === "track") return "track";
89
+ if (kind === "playlist") return "playlist";
90
+ if (kind === "album") return "album";
91
+ return "unknown";
92
+ } catch {
93
+ return "unknown";
94
+ }
95
+ }
96
+
97
+ private extractId(input: string): string | null {
98
+ if (!input) return null;
99
+ if (input.startsWith("spotify:")) {
100
+ const parts = input.split(":");
101
+ return parts[2] || null;
102
+ }
103
+ try {
104
+ const u = new URL(input);
105
+ const parts = u.pathname.split("/").filter(Boolean);
106
+ return parts[1] || null; // /track/<id>
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ private async buildTrackFromUrlOrUri(
113
+ input: string,
114
+ requestedBy: string
115
+ ): Promise<Track | null> {
116
+ const id = this.extractId(input);
117
+ if (!id) return null;
118
+
119
+ const url = this.toShareUrl(input, "track", id);
120
+ const meta = await this.fetchOEmbed(url).catch(() => undefined);
121
+ const title = meta?.title || `Spotify Track ${id}`;
122
+ const thumbnail = meta?.thumbnail_url;
123
+
124
+ const track: Track = {
125
+ id,
126
+ title,
127
+ url,
128
+ duration: 0,
129
+ thumbnail,
130
+ requestedBy,
131
+ source: this.name,
132
+ metadata: {
133
+ author: meta?.author_name,
134
+ provider: meta?.provider_name,
135
+ spotify_id: id,
136
+ },
137
+ };
138
+ return track;
139
+ }
140
+
141
+ private async buildHeaderItem(
142
+ input: string,
143
+ requestedBy: string,
144
+ kind: "playlist" | "album"
145
+ ): Promise<Track | null> {
146
+ const id = this.extractId(input);
147
+ if (!id) return null;
148
+ const url = this.toShareUrl(input, kind, id);
149
+ const meta = await this.fetchOEmbed(url).catch(() => undefined);
150
+
151
+ const title = meta?.title || `Spotify ${kind} ${id}`;
152
+ const thumbnail = meta?.thumbnail_url;
153
+
154
+ return {
155
+ id,
156
+ title,
157
+ url,
158
+ duration: 0,
159
+ thumbnail,
160
+ requestedBy,
161
+ source: this.name,
162
+ metadata: {
163
+ author: meta?.author_name,
164
+ provider: meta?.provider_name,
165
+ spotify_id: id,
166
+ kind,
167
+ },
168
+ };
169
+ }
170
+
171
+ private toShareUrl(input: string, expectedKind: string, id: string): string {
172
+ if (input.startsWith("spotify:")) {
173
+ return `https://open.spotify.com/${expectedKind}/${id}`;
174
+ }
175
+ try {
176
+ const u = new URL(input);
177
+ const parts = u.pathname.split("/").filter(Boolean);
178
+ const kind = parts[0] || expectedKind;
179
+ const realId = parts[1] || id;
180
+ return `https://open.spotify.com/${kind}/${realId}`;
181
+ } catch {
182
+ return `https://open.spotify.com/${expectedKind}/${id}`;
183
+ }
184
+ }
185
+
186
+ private async fetchOEmbed(pageUrl: string): Promise<{
187
+ title?: string;
188
+ thumbnail_url?: string;
189
+ provider_name?: string;
190
+ author_name?: string;
191
+ }> {
192
+ const endpoint = `https://open.spotify.com/oembed?url=${encodeURIComponent(
193
+ pageUrl
194
+ )}`;
195
+ const res = await fetch(endpoint);
196
+ if (!res.ok) throw new Error(`oEmbed HTTP ${res.status}`);
197
+ return res.json() as Promise<{
198
+ title?: string;
199
+ thumbnail_url?: string;
200
+ provider_name?: string;
201
+ author_name?: string;
202
+ }>;
203
+ }
204
+ }
@@ -0,0 +1,320 @@
1
+ import { BasePlugin, Track, SearchResult, StreamInfo } from "ziplayer";
2
+
3
+ import { Innertube, Log } from "youtubei.js";
4
+
5
+ export class YouTubePlugin extends BasePlugin {
6
+ name = "youtube";
7
+ version = "1.0.0";
8
+
9
+ private client!: Innertube;
10
+ private searchClient!: Innertube;
11
+ private ready: Promise<void>;
12
+
13
+ constructor() {
14
+ super();
15
+ this.ready = this.init();
16
+ }
17
+
18
+ private async init(): Promise<void> {
19
+ this.client = await Innertube.create({
20
+ client_type: "ANDROID",
21
+ retrieve_player: false,
22
+ } as any);
23
+
24
+ // Use a separate web client for search to avoid mobile parser issues
25
+ this.searchClient = await Innertube.create({
26
+ client_type: "WEB",
27
+ retrieve_player: false,
28
+ } as any);
29
+ Log.setLevel(0);
30
+ }
31
+
32
+ canHandle(query: string): boolean {
33
+ const q = query.toLowerCase();
34
+ const isUrl = q.startsWith("http://") || q.startsWith("https://");
35
+ return q.includes("youtube.com") || q.includes("youtu.be") || (!isUrl && q.includes("youtube"));
36
+ }
37
+
38
+ validate(url: string): boolean {
39
+ const u = url.toLowerCase();
40
+ return u.includes("youtube.com") || u.includes("youtu.be");
41
+ }
42
+
43
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
44
+ await this.ready;
45
+
46
+ if (this.validate(query)) {
47
+ const listId = this.extractListId(query);
48
+ if (listId) {
49
+ try {
50
+ const playlist: any = await (this.client as any).getPlaylist(listId);
51
+ const videos: any[] = playlist?.videos || playlist?.items || [];
52
+
53
+ const tracks: Track[] = videos.map((v: any) => {
54
+ const id = v.id || v.video_id || v.videoId;
55
+ const title = v.title?.text ?? v.title;
56
+ const duration = toSeconds(v.duration?.text ?? v.duration);
57
+ const thumb = v.thumbnails?.[0]?.url || v.thumbnail?.url;
58
+ const author = v.author?.name ?? v.channel?.name;
59
+ const views = v.view_count ?? v.views;
60
+
61
+ return {
62
+ id: String(id),
63
+ title,
64
+ url: `https://www.youtube.com/watch?v=${id}`,
65
+ duration: Number(duration) || 0,
66
+ thumbnail: thumb,
67
+ requestedBy,
68
+ source: this.name,
69
+ metadata: { author, views, playlist: listId },
70
+ } as Track;
71
+ });
72
+
73
+ return {
74
+ tracks,
75
+ playlist: {
76
+ name: playlist?.title || playlist?.metadata?.title || `Playlist ${listId}`,
77
+ url: query,
78
+ thumbnail: playlist?.thumbnails?.[0]?.url || playlist?.thumbnail?.url,
79
+ },
80
+ };
81
+ } catch {
82
+ const withoutList = query.replace(/[?&]list=[^&]+/, "").replace(/[?&]$/, "");
83
+ return await this.search(withoutList, requestedBy);
84
+ }
85
+ }
86
+
87
+ const videoId = this.extractVideoId(query);
88
+ if (!videoId) throw new Error("Invalid YouTube URL");
89
+
90
+ const info = await this.client.getBasicInfo(videoId);
91
+ const basic = (info as any).basic_info ?? {};
92
+
93
+ const track: Track = {
94
+ id: videoId,
95
+ title: basic.title ?? (info as any).title ?? "Unknown title",
96
+ url: `https://www.youtube.com/watch?v=${videoId}`,
97
+ duration: toSeconds(basic.duration ?? (info as any).duration) ?? 0,
98
+ thumbnail:
99
+ basic.thumbnail?.[0]?.url || basic.thumbnail?.[basic.thumbnail?.length - 1]?.url || (info as any).thumbnails?.[0]?.url,
100
+ requestedBy,
101
+ source: this.name,
102
+ metadata: {
103
+ author: basic.author ?? (info as any).author?.name,
104
+ views: (info as any).basic_info?.view_count ?? (info as any).view_count,
105
+ },
106
+ };
107
+
108
+ return { tracks: [track] };
109
+ }
110
+
111
+ // Text search → return up to 10 video tracks
112
+ const res: any = await this.searchClient.search(query, {
113
+ type: "video" as any,
114
+ });
115
+ const items: any[] = res?.items || res?.videos || res?.results || [];
116
+
117
+ const tracks: Track[] = items.slice(0, 10).map((v: any) => {
118
+ const id = v.id || v.video_id || v.videoId || v.identifier;
119
+ const title = v.title?.text ?? v.title ?? v.headline ?? "Unknown title";
120
+ const duration = toSeconds(v.duration?.text ?? v.duration?.seconds ?? v.duration ?? v.length_text);
121
+ const thumbnail = v.thumbnails?.[0]?.url || v.thumbnail?.url || v.thumbnail?.thumbnails?.[0]?.url;
122
+ const author = v.author?.name ?? v.channel?.name ?? v.owner?.name;
123
+ const views = v.view_count ?? v.views ?? v.short_view_count ?? v.stats?.view_count;
124
+
125
+ const track: Track = {
126
+ id: String(id),
127
+ title,
128
+ url: `https://www.youtube.com/watch?v=${id}`,
129
+ duration: Number(duration) || 0,
130
+ thumbnail,
131
+ requestedBy,
132
+ source: this.name,
133
+ metadata: { author, views },
134
+ };
135
+ return track;
136
+ });
137
+
138
+ return { tracks };
139
+ }
140
+
141
+ async extractPlaylist(url: string, requestedBy: string): Promise<Track[]> {
142
+ await this.ready;
143
+
144
+ const listId = this.extractListId(url);
145
+ if (!listId) return [];
146
+
147
+ try {
148
+ const playlist: any = await (this.client as any).getPlaylist(listId);
149
+ const videos: any[] = playlist?.videos || playlist?.items || [];
150
+
151
+ return videos.map((v: any) => {
152
+ const id = v.id || v.video_id || v.videoId;
153
+ const title = v.title?.text ?? v.title;
154
+ const duration = toSeconds(v.duration?.text ?? v.duration);
155
+ const thumb = v.thumbnails?.[0]?.url || v.thumbnail?.url;
156
+ const author = v.author?.name ?? v.channel?.name;
157
+ const views = v.view_count ?? v.views;
158
+
159
+ const track: Track = {
160
+ id: String(id),
161
+ title,
162
+ url: `https://www.youtube.com/watch?v=${id}`,
163
+ duration: Number(duration) || 0,
164
+ thumbnail: thumb,
165
+ requestedBy,
166
+ source: this.name,
167
+ metadata: { author, views, playlist: listId },
168
+ };
169
+ return track;
170
+ });
171
+ } catch {
172
+ // If playlist fetch fails, return empty to keep optional contract intact
173
+ return [];
174
+ }
175
+ }
176
+
177
+ async getStream(track: Track): Promise<StreamInfo> {
178
+ await this.ready;
179
+
180
+ const id = this.extractVideoId(track.url) || track.id;
181
+
182
+ if (!id) throw new Error("Invalid track id");
183
+
184
+ try {
185
+ const stream: any = await (this.client as any).download(id, {
186
+ type: "audio",
187
+ quality: "best",
188
+ });
189
+ return {
190
+ stream,
191
+ type: "arbitrary",
192
+ metadata: track.metadata,
193
+ };
194
+ } catch (e: any) {
195
+ try {
196
+ const info: any = await (this.client as any).getBasicInfo(id);
197
+
198
+ // Prefer m4a audio-only formats first
199
+ let format: any = info?.chooseFormat?.({
200
+ type: "audio",
201
+ quality: "best",
202
+ });
203
+ if (!format && info?.formats?.length) {
204
+ const audioOnly = info.formats.filter((f: any) => f.mime_type?.includes("audio"));
205
+ audioOnly.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
206
+ format = audioOnly[0];
207
+ }
208
+
209
+ if (!format) throw new Error("No audio format available");
210
+
211
+ let url: string | undefined = undefined;
212
+ if (typeof format.decipher === "function") {
213
+ url = format.decipher((this.client as any).session.player);
214
+ }
215
+ if (!url) url = format.url;
216
+
217
+ if (!url) throw new Error("No valid URL to decipher");
218
+ const res = await fetch(url);
219
+
220
+ if (!res.ok || !res.body) {
221
+ throw new Error(`HTTP ${res.status}`);
222
+ }
223
+
224
+ return {
225
+ stream: res.body as any,
226
+ type: "arbitrary",
227
+ metadata: {
228
+ ...track.metadata,
229
+ itag: format.itag,
230
+ mime: format.mime_type,
231
+ },
232
+ };
233
+ } catch (inner: any) {
234
+ throw new Error(`Failed to get YouTube stream: ${inner?.message || inner}`);
235
+ }
236
+ }
237
+ }
238
+
239
+ async getRelatedTracks(trackURL: string, opts: { limit?: number; offset?: number; history?: Track[] } = {}): Promise<Track[]> {
240
+ await this.ready;
241
+ const videoId = this.extractVideoId(trackURL);
242
+ const info: any = await await (this.searchClient as any).getInfo(videoId);
243
+ const related: any[] = info?.watch_next_feed || [];
244
+ const offset = opts.offset ?? 0;
245
+ const limit = opts.limit ?? 5;
246
+
247
+ const relatedfilter = related.filter((tr: any) => !(opts?.history ?? []).some((t) => t.url === tr.url));
248
+
249
+ return relatedfilter.slice(offset, offset + limit).map((v: any) => {
250
+ const id = v.id || v.video_id || v.videoId || v.content_id;
251
+ const videometa = v?.metadata;
252
+ return {
253
+ id: String(id),
254
+ title: videometa.title.text ?? "Unknown title",
255
+ url: `https://www.youtube.com/watch?v=${id}`,
256
+ duration: Number(v.length_seconds || toSeconds(v.duration)) || 0,
257
+ thumbnail: v.thumbnails?.[0]?.url || v.thumbnail?.url || v.content_image?.image?.[0]?.url,
258
+ requestedBy: "auto",
259
+ source: this.name,
260
+ metadata: { author: v.author, views: v.view_count },
261
+ } as Track;
262
+ });
263
+ }
264
+
265
+ async getFallback(track: Track): Promise<StreamInfo> {
266
+ try {
267
+ const result = await this.search(track.title, track.requestedBy);
268
+ const first = result.tracks[0];
269
+ if (!first) throw new Error("No fallback track found");
270
+ return await this.getStream(first);
271
+ } catch (e: any) {
272
+ throw new Error(`YouTube fallback search failed: ${e?.message || e}`);
273
+ }
274
+ }
275
+
276
+ private extractVideoId(input: string): string | null {
277
+ try {
278
+ const u = new URL(input);
279
+ if (u.hostname.includes("youtu.be")) {
280
+ return u.pathname.split("/").filter(Boolean)[0] || null;
281
+ }
282
+ if (u.hostname.includes("youtube.com")) {
283
+ // watch?v=, shorts/, embed/
284
+ if (u.searchParams.get("v")) return u.searchParams.get("v");
285
+ const path = u.pathname;
286
+ if (path.startsWith("/shorts/")) return path.replace("/shorts/", "");
287
+ if (path.startsWith("/embed/")) return path.replace("/embed/", "");
288
+ }
289
+ return null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ private extractListId(input: string): string | null {
296
+ try {
297
+ const u = new URL(input);
298
+ return u.searchParams.get("list");
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+ }
304
+ function toSeconds(d: any): number | undefined {
305
+ if (typeof d === "number") return d;
306
+ if (typeof d === "string") {
307
+ // mm:ss or hh:mm:ss
308
+ const parts = d.split(":").map(Number);
309
+ if (parts.some((n) => Number.isNaN(n))) return undefined;
310
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
311
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
312
+ const asNum = Number(d);
313
+ return Number.isFinite(asNum) ? asNum : undefined;
314
+ }
315
+ if (d && typeof d === "object") {
316
+ if (typeof (d as any).seconds === "number") return (d as any).seconds;
317
+ if (typeof (d as any).milliseconds === "number") return Math.floor((d as any).milliseconds / 1000);
318
+ }
319
+ return undefined;
320
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { YouTubePlugin } from "./YouTubePlugin";
2
+ export { SoundCloudPlugin } from "./SoundCloudPlugin";
3
+ export { SpotifyPlugin } from "./SpotifyPlugin";
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "moduleResolution": "node",
16
+ "allowSyntheticDefaultImports": true,
17
+ "experimentalDecorators": true,
18
+ "emitDecoratorMetadata": true,
19
+ "resolveJsonModule": true
20
+ },
21
+ "include": ["src/*"],
22
+ "exclude": ["node_modules", "dist", "examples"]
23
+ }