@ziplayer/plugin 0.0.2 → 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,320 +1,343 @@
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
- }
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 || "").trim().toLowerCase();
34
+ const isUrl = q.startsWith("http://") || q.startsWith("https://");
35
+ if (isUrl) {
36
+ try {
37
+ const parsed = new URL(query);
38
+ const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"];
39
+ return allowedHosts.includes(parsed.hostname.toLowerCase());
40
+ } catch (e) {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // Avoid intercepting explicit patterns for other extractors
46
+ if (q.startsWith("tts:") || q.startsWith("say ")) return false;
47
+ if (q.startsWith("spotify:") || q.includes("open.spotify.com")) return false;
48
+ if (q.includes("soundcloud")) return false;
49
+
50
+ // Treat remaining non-URL free text as YouTube-searchable
51
+ return true;
52
+ }
53
+
54
+ validate(url: string): boolean {
55
+ try {
56
+ const parsed = new URL(url);
57
+ const allowedHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "youtu.be", "www.youtu.be"];
58
+ return allowedHosts.includes(parsed.hostname.toLowerCase());
59
+ } catch (e) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
65
+ await this.ready;
66
+
67
+ if (this.validate(query)) {
68
+ const listId = this.extractListId(query);
69
+ if (listId) {
70
+ try {
71
+ const playlist: any = await (this.client as any).getPlaylist(listId);
72
+ const videos: any[] = playlist?.videos || playlist?.items || [];
73
+
74
+ const tracks: Track[] = videos.map((v: any) => {
75
+ const id = v.id || v.video_id || v.videoId;
76
+ const title = v.title?.text ?? v.title;
77
+ const duration = toSeconds(v.duration?.text ?? v.duration);
78
+ const thumb = v.thumbnails?.[0]?.url || v.thumbnail?.url;
79
+ const author = v.author?.name ?? v.channel?.name;
80
+ const views = v.view_count ?? v.views;
81
+
82
+ return {
83
+ id: String(id),
84
+ title,
85
+ url: `https://www.youtube.com/watch?v=${id}`,
86
+ duration: Number(duration) || 0,
87
+ thumbnail: thumb,
88
+ requestedBy,
89
+ source: this.name,
90
+ metadata: { author, views, playlist: listId },
91
+ } as Track;
92
+ });
93
+
94
+ return {
95
+ tracks,
96
+ playlist: {
97
+ name: playlist?.title || playlist?.metadata?.title || `Playlist ${listId}`,
98
+ url: query,
99
+ thumbnail: playlist?.thumbnails?.[0]?.url || playlist?.thumbnail?.url,
100
+ },
101
+ };
102
+ } catch {
103
+ const withoutList = query.replace(/[?&]list=[^&]+/, "").replace(/[?&]$/, "");
104
+ return await this.search(withoutList, requestedBy);
105
+ }
106
+ }
107
+
108
+ const videoId = this.extractVideoId(query);
109
+ if (!videoId) throw new Error("Invalid YouTube URL");
110
+
111
+ const info = await this.client.getBasicInfo(videoId);
112
+ const basic = (info as any).basic_info ?? {};
113
+
114
+ const track: Track = {
115
+ id: videoId,
116
+ title: basic.title ?? (info as any).title ?? "Unknown title",
117
+ url: `https://www.youtube.com/watch?v=${videoId}`,
118
+ duration: toSeconds(basic.duration ?? (info as any).duration) ?? 0,
119
+ thumbnail:
120
+ basic.thumbnail?.[0]?.url || basic.thumbnail?.[basic.thumbnail?.length - 1]?.url || (info as any).thumbnails?.[0]?.url,
121
+ requestedBy,
122
+ source: this.name,
123
+ metadata: {
124
+ author: basic.author ?? (info as any).author?.name,
125
+ views: (info as any).basic_info?.view_count ?? (info as any).view_count,
126
+ },
127
+ };
128
+
129
+ return { tracks: [track] };
130
+ }
131
+
132
+ // Text search → return up to 10 video tracks
133
+ const res: any = await this.searchClient.search(query, {
134
+ type: "video" as any,
135
+ });
136
+ const items: any[] = res?.items || res?.videos || res?.results || [];
137
+
138
+ const tracks: Track[] = items.slice(0, 10).map((v: any) => {
139
+ const id = v.id || v.video_id || v.videoId || v.identifier;
140
+ const title = v.title?.text ?? v.title ?? v.headline ?? "Unknown title";
141
+ const duration = toSeconds(v.duration?.text ?? v.duration?.seconds ?? v.duration ?? v.length_text);
142
+ const thumbnail = v.thumbnails?.[0]?.url || v.thumbnail?.url || v.thumbnail?.thumbnails?.[0]?.url;
143
+ const author = v.author?.name ?? v.channel?.name ?? v.owner?.name;
144
+ const views = v.view_count ?? v.views ?? v.short_view_count ?? v.stats?.view_count;
145
+
146
+ const track: Track = {
147
+ id: String(id),
148
+ title,
149
+ url: `https://www.youtube.com/watch?v=${id}`,
150
+ duration: Number(duration) || 0,
151
+ thumbnail,
152
+ requestedBy,
153
+ source: this.name,
154
+ metadata: { author, views },
155
+ };
156
+ return track;
157
+ });
158
+
159
+ return { tracks };
160
+ }
161
+
162
+ async extractPlaylist(url: string, requestedBy: string): Promise<Track[]> {
163
+ await this.ready;
164
+
165
+ const listId = this.extractListId(url);
166
+ if (!listId) return [];
167
+
168
+ try {
169
+ const playlist: any = await (this.client as any).getPlaylist(listId);
170
+ const videos: any[] = playlist?.videos || playlist?.items || [];
171
+
172
+ return videos.map((v: any) => {
173
+ const id = v.id || v.video_id || v.videoId;
174
+ const title = v.title?.text ?? v.title;
175
+ const duration = toSeconds(v.duration?.text ?? v.duration);
176
+ const thumb = v.thumbnails?.[0]?.url || v.thumbnail?.url;
177
+ const author = v.author?.name ?? v.channel?.name;
178
+ const views = v.view_count ?? v.views;
179
+
180
+ const track: Track = {
181
+ id: String(id),
182
+ title,
183
+ url: `https://www.youtube.com/watch?v=${id}`,
184
+ duration: Number(duration) || 0,
185
+ thumbnail: thumb,
186
+ requestedBy,
187
+ source: this.name,
188
+ metadata: { author, views, playlist: listId },
189
+ };
190
+ return track;
191
+ });
192
+ } catch {
193
+ // If playlist fetch fails, return empty to keep optional contract intact
194
+ return [];
195
+ }
196
+ }
197
+
198
+ async getStream(track: Track): Promise<StreamInfo> {
199
+ await this.ready;
200
+
201
+ const id = this.extractVideoId(track.url) || track.id;
202
+
203
+ if (!id) throw new Error("Invalid track id");
204
+
205
+ try {
206
+ const stream: any = await (this.client as any).download(id, {
207
+ type: "audio",
208
+ quality: "best",
209
+ });
210
+ return {
211
+ stream,
212
+ type: "arbitrary",
213
+ metadata: track.metadata,
214
+ };
215
+ } catch (e: any) {
216
+ try {
217
+ const info: any = await (this.client as any).getBasicInfo(id);
218
+
219
+ // Prefer m4a audio-only formats first
220
+ let format: any = info?.chooseFormat?.({
221
+ type: "audio",
222
+ quality: "best",
223
+ });
224
+ if (!format && info?.formats?.length) {
225
+ const audioOnly = info.formats.filter((f: any) => f.mime_type?.includes("audio"));
226
+ audioOnly.sort((a: any, b: any) => (b.bitrate || 0) - (a.bitrate || 0));
227
+ format = audioOnly[0];
228
+ }
229
+
230
+ if (!format) throw new Error("No audio format available");
231
+
232
+ let url: string | undefined = undefined;
233
+ if (typeof format.decipher === "function") {
234
+ url = format.decipher((this.client as any).session.player);
235
+ }
236
+ if (!url) url = format.url;
237
+
238
+ if (!url) throw new Error("No valid URL to decipher");
239
+ const res = await fetch(url);
240
+
241
+ if (!res.ok || !res.body) {
242
+ throw new Error(`HTTP ${res.status}`);
243
+ }
244
+
245
+ return {
246
+ stream: res.body as any,
247
+ type: "arbitrary",
248
+ metadata: {
249
+ ...track.metadata,
250
+ itag: format.itag,
251
+ mime: format.mime_type,
252
+ },
253
+ };
254
+ } catch (inner: any) {
255
+ throw new Error(`Failed to get YouTube stream: ${inner?.message || inner}`);
256
+ }
257
+ }
258
+ }
259
+
260
+ async getRelatedTracks(trackURL: string, opts: { limit?: number; offset?: number; history?: Track[] } = {}): Promise<Track[]> {
261
+ await this.ready;
262
+ const videoId = this.extractVideoId(trackURL);
263
+ const info: any = await await (this.searchClient as any).getInfo(videoId);
264
+ const related: any[] = info?.watch_next_feed || [];
265
+ const offset = opts.offset ?? 0;
266
+ const limit = opts.limit ?? 5;
267
+
268
+ const relatedfilter = related.filter((tr: any) => !(opts?.history ?? []).some((t) => t.url === tr.url));
269
+
270
+ return relatedfilter.slice(offset, offset + limit).map((v: any) => {
271
+ const id = v.id || v.video_id || v.videoId || v.content_id;
272
+ const videometa = v?.metadata;
273
+ return {
274
+ id: String(id),
275
+ title: videometa.title.text ?? "Unknown title",
276
+ url: `https://www.youtube.com/watch?v=${id}`,
277
+ duration: Number(v.length_seconds || toSeconds(v.duration)) || 0,
278
+ thumbnail: v.thumbnails?.[0]?.url || v.thumbnail?.url || v.content_image?.image?.[0]?.url,
279
+ requestedBy: "auto",
280
+ source: this.name,
281
+ metadata: { author: v.author, views: v.view_count },
282
+ } as Track;
283
+ });
284
+ }
285
+
286
+ async getFallback(track: Track): Promise<StreamInfo> {
287
+ try {
288
+ const result = await this.search(track.title, track.requestedBy);
289
+ const first = result.tracks[0];
290
+ if (!first) throw new Error("No fallback track found");
291
+ return await this.getStream(first);
292
+ } catch (e: any) {
293
+ throw new Error(`YouTube fallback search failed: ${e?.message || e}`);
294
+ }
295
+ }
296
+
297
+ private extractVideoId(input: string): string | null {
298
+ try {
299
+ const u = new URL(input);
300
+ const allowedShortHosts = ["youtu.be"];
301
+ const allowedLongHosts = ["youtube.com", "www.youtube.com", "music.youtube.com", "m.youtube.com"];
302
+ if (allowedShortHosts.includes(u.hostname)) {
303
+ return u.pathname.split("/").filter(Boolean)[0] || null;
304
+ }
305
+ if (allowedLongHosts.includes(u.hostname)) {
306
+ // watch?v=, shorts/, embed/
307
+ if (u.searchParams.get("v")) return u.searchParams.get("v");
308
+ const path = u.pathname;
309
+ if (path.startsWith("/shorts/")) return path.replace("/shorts/", "");
310
+ if (path.startsWith("/embed/")) return path.replace("/embed/", "");
311
+ }
312
+ return null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ private extractListId(input: string): string | null {
319
+ try {
320
+ const u = new URL(input);
321
+ return u.searchParams.get("list");
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ }
327
+ function toSeconds(d: any): number | undefined {
328
+ if (typeof d === "number") return d;
329
+ if (typeof d === "string") {
330
+ // mm:ss or hh:mm:ss
331
+ const parts = d.split(":").map(Number);
332
+ if (parts.some((n) => Number.isNaN(n))) return undefined;
333
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
334
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
335
+ const asNum = Number(d);
336
+ return Number.isFinite(asNum) ? asNum : undefined;
337
+ }
338
+ if (d && typeof d === "object") {
339
+ if (typeof (d as any).seconds === "number") return (d as any).seconds;
340
+ if (typeof (d as any).milliseconds === "number") return Math.floor((d as any).milliseconds / 1000);
341
+ }
342
+ return undefined;
343
+ }