@ziplayer/plugin 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,204 +1,190 @@
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
- }
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 === "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 === "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(_input: string, _requestedBy: string): Promise<Track[]> {
61
+ return [];
62
+ }
63
+
64
+ async extractAlbum(_input: string, _requestedBy: string): Promise<Track[]> {
65
+ return [];
66
+ }
67
+
68
+ async getStream(_track: Track): Promise<StreamInfo> {
69
+ throw new Error("Spotify streaming is not supported by this plugin");
70
+ }
71
+
72
+ private identifyKind(input: string): "track" | "playlist" | "album" | "unknown" {
73
+ if (input.startsWith("spotify:")) {
74
+ if (input.includes(":track:")) return "track";
75
+ if (input.includes(":playlist:")) return "playlist";
76
+ if (input.includes(":album:")) return "album";
77
+ return "unknown";
78
+ }
79
+ try {
80
+ const u = new URL(input);
81
+ const parts = u.pathname.split("/").filter(Boolean);
82
+ const kind = parts[0];
83
+ if (kind === "track") return "track";
84
+ if (kind === "playlist") return "playlist";
85
+ if (kind === "album") return "album";
86
+ return "unknown";
87
+ } catch {
88
+ return "unknown";
89
+ }
90
+ }
91
+
92
+ private extractId(input: string): string | null {
93
+ if (!input) return null;
94
+ if (input.startsWith("spotify:")) {
95
+ const parts = input.split(":");
96
+ return parts[2] || null;
97
+ }
98
+ try {
99
+ const u = new URL(input);
100
+ const parts = u.pathname.split("/").filter(Boolean);
101
+ return parts[1] || null; // /track/<id>
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ private async buildTrackFromUrlOrUri(input: string, requestedBy: string): Promise<Track | null> {
108
+ const id = this.extractId(input);
109
+ if (!id) return null;
110
+
111
+ const url = this.toShareUrl(input, "track", id);
112
+ const meta = await this.fetchOEmbed(url).catch(() => undefined);
113
+ const title = meta?.title || `Spotify Track ${id}`;
114
+ const thumbnail = meta?.thumbnail_url;
115
+
116
+ const track: Track = {
117
+ id,
118
+ title,
119
+ url,
120
+ duration: 0,
121
+ thumbnail,
122
+ requestedBy,
123
+ source: this.name,
124
+ metadata: {
125
+ author: meta?.author_name,
126
+ provider: meta?.provider_name,
127
+ spotify_id: id,
128
+ },
129
+ };
130
+ return track;
131
+ }
132
+
133
+ private async buildHeaderItem(input: string, requestedBy: string, kind: "playlist" | "album"): Promise<Track | null> {
134
+ const id = this.extractId(input);
135
+ if (!id) return null;
136
+ const url = this.toShareUrl(input, kind, id);
137
+ const meta = await this.fetchOEmbed(url).catch(() => undefined);
138
+
139
+ const title = meta?.title || `Spotify ${kind} ${id}`;
140
+ const thumbnail = meta?.thumbnail_url;
141
+
142
+ return {
143
+ id,
144
+ title,
145
+ url,
146
+ duration: 0,
147
+ thumbnail,
148
+ requestedBy,
149
+ source: this.name,
150
+ metadata: {
151
+ author: meta?.author_name,
152
+ provider: meta?.provider_name,
153
+ spotify_id: id,
154
+ kind,
155
+ },
156
+ };
157
+ }
158
+
159
+ private toShareUrl(input: string, expectedKind: string, id: string): string {
160
+ if (input.startsWith("spotify:")) {
161
+ return `https://open.spotify.com/${expectedKind}/${id}`;
162
+ }
163
+ try {
164
+ const u = new URL(input);
165
+ const parts = u.pathname.split("/").filter(Boolean);
166
+ const kind = parts[0] || expectedKind;
167
+ const realId = parts[1] || id;
168
+ return `https://open.spotify.com/${kind}/${realId}`;
169
+ } catch {
170
+ return `https://open.spotify.com/${expectedKind}/${id}`;
171
+ }
172
+ }
173
+
174
+ private async fetchOEmbed(pageUrl: string): Promise<{
175
+ title?: string;
176
+ thumbnail_url?: string;
177
+ provider_name?: string;
178
+ author_name?: string;
179
+ }> {
180
+ const endpoint = `https://open.spotify.com/oembed?url=${encodeURIComponent(pageUrl)}`;
181
+ const res = await fetch(endpoint);
182
+ if (!res.ok) throw new Error(`oEmbed HTTP ${res.status}`);
183
+ return res.json() as Promise<{
184
+ title?: string;
185
+ thumbnail_url?: string;
186
+ provider_name?: string;
187
+ author_name?: string;
188
+ }>;
189
+ }
190
+ }
@@ -0,0 +1,231 @@
1
+ import { BasePlugin, Track, SearchResult, StreamInfo } from "ziplayer";
2
+ import { Readable } from "stream";
3
+ import { getTTSUrls } from "@zibot/zitts";
4
+ import axios from "axios";
5
+
6
+ export interface TTSPluginOptions {
7
+ defaultLang?: string; // e.g., "vi" | "en"
8
+ slow?: boolean;
9
+ /**
10
+ * Optional custom TTS hook. If provided, it will be used to
11
+ * create the audio stream for the given text instead of the
12
+ * built-in Google TTS wrapper.
13
+ *
14
+ * Return one of:
15
+ * - Node Readable (preferred)
16
+ * - HTTP(S) URL string or URL object
17
+ * - Buffer / Uint8Array / ArrayBuffer
18
+ \t * - Or an object with { stream, type } | { url, type }
19
+ */
20
+ createStream?: (
21
+ text: string,
22
+ ctx?: { lang: string; slow: boolean; track?: Track },
23
+ ) =>
24
+ | Promise<Readable | string | URL | Buffer | Uint8Array | ArrayBuffer>
25
+ | Readable
26
+ | string
27
+ | URL
28
+ | Buffer
29
+ | Uint8Array
30
+ | ArrayBuffer;
31
+ }
32
+
33
+ interface TTSConfig {
34
+ text: string;
35
+ lang: string;
36
+ slow: boolean;
37
+ }
38
+
39
+ export class TTSPlugin extends BasePlugin {
40
+ name = "tts";
41
+ version = "1.0.0";
42
+ private opts: { defaultLang: string; slow: boolean; createStream?: TTSPluginOptions["createStream"] };
43
+
44
+ constructor(opts?: TTSPluginOptions) {
45
+ super();
46
+ this.opts = {
47
+ defaultLang: opts?.defaultLang || "vi",
48
+ slow: !!opts?.slow,
49
+ createStream: opts?.createStream,
50
+ };
51
+ }
52
+
53
+ canHandle(query: string): boolean {
54
+ if (!query) return false;
55
+ const q = query.trim().toLowerCase();
56
+ return q.startsWith("tts:") || q.startsWith("say ");
57
+ }
58
+
59
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
60
+ if (!this.canHandle(query)) {
61
+ return { tracks: [] };
62
+ }
63
+ const { text, lang, slow } = this.parseQuery(query);
64
+ const config: TTSConfig = { text, lang, slow };
65
+ const url = this.encodeConfig(config);
66
+ const title = `TTS (${lang}${slow ? ", slow" : ""}): ${text.slice(0, 64)}${text.length > 64 ? "…" : ""}`;
67
+ const estimatedSeconds = Math.max(1, Math.min(60, Math.ceil(text.length / 12)));
68
+
69
+ const track: Track = {
70
+ id: `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
71
+ title,
72
+ url,
73
+ duration: estimatedSeconds,
74
+ requestedBy,
75
+ source: this.name,
76
+ metadata: { tts: config },
77
+ };
78
+
79
+ return { tracks: [track] };
80
+ }
81
+
82
+ async getStream(track: Track): Promise<StreamInfo> {
83
+ const cfg = this.extractConfig(track);
84
+
85
+ if (this.opts.createStream && typeof this.opts.createStream === "function") {
86
+ const out = await this.opts.createStream(cfg.text, { lang: cfg.lang, slow: cfg.slow, track });
87
+ let type: StreamInfo["type"] | undefined;
88
+ let metadata: Record<string, any> | undefined;
89
+ let stream: Readable | null = null;
90
+
91
+ const normType = (t?: any): StreamInfo["type"] | undefined => {
92
+ if (!t || typeof t !== "string") return undefined;
93
+ const v = t.toLowerCase();
94
+ if (v.includes("webm") && v.includes("opus")) return "webm/opus";
95
+ if (v.includes("ogg") && v.includes("opus")) return "ogg/opus";
96
+ return undefined;
97
+ };
98
+
99
+ if (out && typeof out === "object") {
100
+ // If it's already a Readable/Buffer/Uint8Array/ArrayBuffer/URL, let toReadable handle it
101
+ if (
102
+ out instanceof Readable ||
103
+ out instanceof Buffer ||
104
+ out instanceof Uint8Array ||
105
+ out instanceof ArrayBuffer ||
106
+ out instanceof URL
107
+ ) {
108
+ stream = await this.toReadable(out as any);
109
+ } else if ((out as any).stream) {
110
+ const o = out as any;
111
+ stream = o.stream as Readable;
112
+ type = normType(o.type);
113
+ metadata = o.metadata;
114
+ } else if ((out as any).url) {
115
+ const o = out as any;
116
+ const urlStr = o.url.toString();
117
+ try {
118
+ type =
119
+ normType(o.type) || (urlStr.endsWith(".webm") ? "webm/opus" : urlStr.endsWith(".ogg") ? "ogg/opus" : undefined);
120
+ const res = await axios.get(urlStr, { responseType: "stream" });
121
+ stream = res.data as unknown as Readable;
122
+ metadata = o.metadata;
123
+ } catch (e) {
124
+ throw new Error(`Failed to fetch custom TTS URL: ${e}`);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (!stream) {
130
+ stream = await this.toReadable(out as any);
131
+ }
132
+ return { stream, type: type || "arbitrary", metadata: { provider: "custom", ...(metadata || {}) } };
133
+ }
134
+
135
+ const urls = getTTSUrls(cfg.text, { lang: cfg.lang, slow: cfg.slow });
136
+ if (!urls || urls.length === 0) {
137
+ throw new Error("TTS returned no audio URLs");
138
+ }
139
+
140
+ const parts = await Promise.all(
141
+ urls.map((u) => axios.get<ArrayBuffer>(u, { responseType: "arraybuffer" }).then((r) => Buffer.from(r.data))),
142
+ );
143
+
144
+ const merged = Buffer.concat(parts);
145
+ const stream = Readable.from([merged]);
146
+ return { stream, type: "arbitrary", metadata: { size: merged.length } };
147
+ }
148
+
149
+ private async toReadable(out: Readable | string | URL | Buffer | Uint8Array | ArrayBuffer): Promise<Readable> {
150
+ if (out instanceof Readable) return out;
151
+ if (typeof out === "string" || out instanceof URL) {
152
+ const url = out instanceof URL ? out.toString() : out;
153
+ if (/^https?:\/\//i.test(url)) {
154
+ const res = await axios.get(url, { responseType: "stream" });
155
+ return res.data as unknown as Readable;
156
+ }
157
+ return Readable.from([Buffer.from(url)]);
158
+ }
159
+ if (out instanceof Buffer) return Readable.from([out]);
160
+ if (out instanceof Uint8Array) return Readable.from([Buffer.from(out)]);
161
+ if (out instanceof ArrayBuffer) return Readable.from([Buffer.from(out)]);
162
+ throw new Error("Unsupported return type from createStream");
163
+ }
164
+
165
+ private parseQuery(query: string): TTSConfig {
166
+ const isLangCode = (s: string) => /^[a-z]{2,3}(?:-[A-Z]{2})?$/.test(s);
167
+
168
+ const raw = query.trim();
169
+ let text = raw;
170
+ let lang = this.opts.defaultLang;
171
+ let slow = this.opts.slow;
172
+
173
+ const lower = raw.toLowerCase();
174
+ if (lower.startsWith("say ")) {
175
+ text = raw.slice(4).trim();
176
+ } else if (lower.startsWith("tts:")) {
177
+ const body = raw.slice(4).trim();
178
+ // Supported:
179
+ // - "tts: <text>" (text may contain colons)
180
+ // - "tts:<lang>:<text>"
181
+ // - "tts:<lang>:<slow>:<text>" where slow in {0,1,true,false}
182
+ const firstSep = body.indexOf(":");
183
+ if (firstSep === -1) {
184
+ text = body;
185
+ } else {
186
+ const maybeLang = body.slice(0, firstSep).trim();
187
+ const rest = body.slice(firstSep + 1).trim();
188
+ if (isLangCode(maybeLang)) {
189
+ lang = maybeLang;
190
+ const secondSep = rest.indexOf(":");
191
+ if (secondSep !== -1) {
192
+ const maybeSlow = rest.slice(0, secondSep).trim().toLowerCase();
193
+ const remaining = rest.slice(secondSep + 1).trim();
194
+ if (["0", "1", "true", "false"].includes(maybeSlow)) {
195
+ slow = maybeSlow === "1" || maybeSlow === "true";
196
+ text = remaining;
197
+ } else {
198
+ text = rest;
199
+ }
200
+ } else {
201
+ text = rest;
202
+ }
203
+ } else {
204
+ text = body;
205
+ }
206
+ }
207
+ }
208
+
209
+ text = (text || "").trim();
210
+ if (!text) throw new Error("No text provided for TTS");
211
+ return { text, lang, slow };
212
+ }
213
+
214
+ private encodeConfig(cfg: TTSConfig): string {
215
+ const payload = encodeURIComponent(JSON.stringify(cfg));
216
+ return `tts://${payload}`;
217
+ }
218
+
219
+ private extractConfig(track: Track): TTSConfig {
220
+ const meta = (track.metadata as any)?.tts as TTSConfig | undefined;
221
+ if (meta && meta.text) return meta;
222
+ try {
223
+ const url = track.url || "";
224
+ const encoded = url.startsWith("tts://") ? url.slice("tts://".length) : url;
225
+ const cfg = JSON.parse(decodeURIComponent(encoded));
226
+ return { text: cfg.text, lang: cfg.lang || this.opts.defaultLang, slow: !!cfg.slow };
227
+ } catch {
228
+ return { text: track.title || "", lang: this.opts.defaultLang, slow: this.opts.slow };
229
+ }
230
+ }
231
+ }