@song-spotlight/api 0.1.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 +29 -0
- package/dist/core-CLy9m0H1.js +480 -0
- package/dist/handlers.d.ts +55 -0
- package/dist/handlers.js +3 -0
- package/dist/structs.d.ts +13 -0
- package/dist/structs.js +13 -0
- package/dist/types-Cp4kDlRH.d.ts +9 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 nexpid
|
|
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,29 @@
|
|
|
1
|
+
# @song-spotlight/api
|
|
2
|
+
|
|
3
|
+
[Song Spotlight](https://github.com/nexpid-labs/SongSpotlight) API types and song validation module
|
|
4
|
+
|
|
5
|
+
## How to use
|
|
6
|
+
|
|
7
|
+
TODO!!!
|
|
8
|
+
|
|
9
|
+
## Development
|
|
10
|
+
|
|
11
|
+
To install dependencies:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To build:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bun run build
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
To publish:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bun run publish
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This project was created using `bun init` in bun v1.2.20. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
//#region package.json
|
|
2
|
+
var version = "0.1.0";
|
|
3
|
+
|
|
4
|
+
//#endregion
|
|
5
|
+
//#region src/handlers/common.ts
|
|
6
|
+
function clean(link) {
|
|
7
|
+
const url = new URL(link);
|
|
8
|
+
url.protocol = "https";
|
|
9
|
+
url.username = url.password = url.port = url.search = url.hash = "";
|
|
10
|
+
return url.toString().replace(/\/?$/, "/");
|
|
11
|
+
}
|
|
12
|
+
async function request(options) {
|
|
13
|
+
if (options.body) {
|
|
14
|
+
const body = JSON.stringify(options.body);
|
|
15
|
+
options.body = body;
|
|
16
|
+
options.headers ??= {};
|
|
17
|
+
options.headers["content-type"] ??= "application/json";
|
|
18
|
+
options.headers["content-length"] ??= String(body.length);
|
|
19
|
+
}
|
|
20
|
+
const url = new URL(options.url);
|
|
21
|
+
for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value);
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
method: options.method,
|
|
24
|
+
cache: "force-cache",
|
|
25
|
+
redirect: "follow",
|
|
26
|
+
headers: {
|
|
27
|
+
"accept": "*/*",
|
|
28
|
+
"user-agent": `song-spotlight/v${version}`,
|
|
29
|
+
"cache-control": "public, max-age=3600",
|
|
30
|
+
...options.headers
|
|
31
|
+
},
|
|
32
|
+
body: options.body
|
|
33
|
+
});
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let json;
|
|
36
|
+
try {
|
|
37
|
+
json = JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
json = null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
ok: res.ok,
|
|
43
|
+
redirected: res.redirected,
|
|
44
|
+
url: res.url,
|
|
45
|
+
status: res.status,
|
|
46
|
+
headers: res.headers,
|
|
47
|
+
text,
|
|
48
|
+
json
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseNextData(html) {
|
|
52
|
+
const data = html.match(/id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/)?.[1];
|
|
53
|
+
if (!data) return void 0;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(data);
|
|
56
|
+
} catch {
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const PLAYLIST_LIMIT = 15;
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/handlers/finders.ts
|
|
64
|
+
const $ = {
|
|
65
|
+
services: [],
|
|
66
|
+
parsers: []
|
|
67
|
+
};
|
|
68
|
+
function sid(song) {
|
|
69
|
+
return [
|
|
70
|
+
song.service,
|
|
71
|
+
song.type,
|
|
72
|
+
song.id
|
|
73
|
+
].join(":");
|
|
74
|
+
}
|
|
75
|
+
const parseCache = /* @__PURE__ */ new Map();
|
|
76
|
+
const linkCache = /* @__PURE__ */ new Map();
|
|
77
|
+
async function parseLink(link) {
|
|
78
|
+
const cleaned = clean(link);
|
|
79
|
+
if (parseCache.has(cleaned)) return parseCache.get(cleaned);
|
|
80
|
+
const { hostname, pathname } = new URL(cleaned);
|
|
81
|
+
const path = pathname.slice(1).split(/\/+/);
|
|
82
|
+
let song = null;
|
|
83
|
+
for (const parser of $.parsers) if (parser.hosts.includes(hostname)) {
|
|
84
|
+
song = await parser.parse(cleaned, hostname, path);
|
|
85
|
+
if (song) break;
|
|
86
|
+
}
|
|
87
|
+
parseCache.set(cleaned, song);
|
|
88
|
+
if (song) linkCache.set(sid(song), cleaned);
|
|
89
|
+
return song;
|
|
90
|
+
}
|
|
91
|
+
const renderCache = /* @__PURE__ */ new Map();
|
|
92
|
+
async function renderSong(song) {
|
|
93
|
+
const id = sid(song);
|
|
94
|
+
if (renderCache.has(id)) return renderCache.get(id);
|
|
95
|
+
let info = null;
|
|
96
|
+
const service = $.services.find((x) => x.name === song.service);
|
|
97
|
+
if (service?.types.includes(song.type)) info = await service.render(song.type, song.id);
|
|
98
|
+
renderCache.set(id, info);
|
|
99
|
+
return info;
|
|
100
|
+
}
|
|
101
|
+
async function toLink(song) {
|
|
102
|
+
const id = sid(song);
|
|
103
|
+
if (linkCache.has(id)) return linkCache.get(id);
|
|
104
|
+
let link = null;
|
|
105
|
+
const service = $.services.find((x) => x.name === song.service);
|
|
106
|
+
if (service?.types.includes(song.type)) link = await service.from(song.type, song.id);
|
|
107
|
+
const cleaned = link ? clean(link) : link;
|
|
108
|
+
linkCache.set(id, cleaned);
|
|
109
|
+
if (cleaned) parseCache.set(cleaned, {
|
|
110
|
+
service: song.service,
|
|
111
|
+
type: song.type,
|
|
112
|
+
id: song.id
|
|
113
|
+
});
|
|
114
|
+
return link;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/handlers/defs/parsers/songdotlink.ts
|
|
119
|
+
const songdotlink = {
|
|
120
|
+
hosts: [
|
|
121
|
+
"song.link",
|
|
122
|
+
"album.link",
|
|
123
|
+
"artist.link",
|
|
124
|
+
"pods.link",
|
|
125
|
+
"playlist.link",
|
|
126
|
+
"mylink.page",
|
|
127
|
+
"odesli.co"
|
|
128
|
+
],
|
|
129
|
+
async parse(link, _host, path) {
|
|
130
|
+
const [first, second, third] = path;
|
|
131
|
+
if (!first || third) return null;
|
|
132
|
+
if (second && Number.isNaN(+second)) return null;
|
|
133
|
+
else if (!second && (!first.match(/^[A-z0-9-_]+$/) || first.match(/^[-_]/) || first.match(/[-_]$/))) return null;
|
|
134
|
+
const html = (await request({ url: link })).text;
|
|
135
|
+
const sections = parseNextData(html)?.props?.pageProps?.pageData?.sections;
|
|
136
|
+
if (!sections) return null;
|
|
137
|
+
const links = sections.flatMap((x) => x.links ?? []).filter((x) => x.url && x.platform);
|
|
138
|
+
const valid = links.find((x) => x.platform === "spotify") ?? links.find((x) => x.platform === "soundcloud") ?? links.find((x) => x.platform === "appleMusic");
|
|
139
|
+
if (!valid) return null;
|
|
140
|
+
return await parseLink(valid.url);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/handlers/defs/cache.ts
|
|
146
|
+
const handlerCache = /* @__PURE__ */ new Map();
|
|
147
|
+
function makeCache(name, retrieve) {
|
|
148
|
+
return { retrieve(...args) {
|
|
149
|
+
if (handlerCache.has(name)) return handlerCache.get(name);
|
|
150
|
+
const res = retrieve(...args);
|
|
151
|
+
if (res instanceof Promise) return res.then((ret) => {
|
|
152
|
+
handlerCache.set(name, ret);
|
|
153
|
+
return ret;
|
|
154
|
+
});
|
|
155
|
+
else {
|
|
156
|
+
handlerCache.set(name, res);
|
|
157
|
+
return res;
|
|
158
|
+
}
|
|
159
|
+
} };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/handlers/defs/services/applemusic.ts
|
|
164
|
+
const geo = "fr", defaultName = "songspotlight";
|
|
165
|
+
const applemusicToken = makeCache("applemusicToken", async (html) => {
|
|
166
|
+
html ??= (await request({ url: `https://music.apple.com/${geo}/new` })).text;
|
|
167
|
+
const asset = html.match(/src="(\/assets\/index-\w+\.js)"/i)?.[1];
|
|
168
|
+
if (!asset) return;
|
|
169
|
+
const js = (await request({ url: `https://music.apple.com${asset}` })).text;
|
|
170
|
+
const code = js.match(/\w+="(ey.*?)"/i)?.[1];
|
|
171
|
+
return code;
|
|
172
|
+
});
|
|
173
|
+
const applemusic = {
|
|
174
|
+
name: "applemusic",
|
|
175
|
+
hosts: ["music.apple.com", "geo.music.apple.com"],
|
|
176
|
+
types: [
|
|
177
|
+
"artist",
|
|
178
|
+
"album",
|
|
179
|
+
"playlist",
|
|
180
|
+
"song"
|
|
181
|
+
],
|
|
182
|
+
async parse(_link, _host, path) {
|
|
183
|
+
const [country, type, name, id, fourth] = path;
|
|
184
|
+
if (!country || !type || !this.types.includes(type) || !name || !id || fourth) return null;
|
|
185
|
+
const res = await request({ url: `https://music.apple.com/${geo}/${type}/${defaultName}/${id}` });
|
|
186
|
+
if (res.status !== 200) return null;
|
|
187
|
+
await applemusicToken.retrieve(res.text);
|
|
188
|
+
return {
|
|
189
|
+
service: this.name,
|
|
190
|
+
type,
|
|
191
|
+
id
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
async render(type, id) {
|
|
195
|
+
const token = await applemusicToken.retrieve();
|
|
196
|
+
if (!token) return null;
|
|
197
|
+
const res = await request({
|
|
198
|
+
url: `https://amp-api.music.apple.com/v1/catalog/${geo}/${type}s/${id}?include=songs`,
|
|
199
|
+
headers: {
|
|
200
|
+
authorization: `Bearer ${token}`,
|
|
201
|
+
origin: "https://music.apple.com"
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
if (res.status !== 200) return null;
|
|
205
|
+
const { attributes, relationships } = res.json.data[0];
|
|
206
|
+
const base = {
|
|
207
|
+
label: attributes.name,
|
|
208
|
+
sublabel: attributes.artistName ?? "Top Songs",
|
|
209
|
+
explicit: attributes.contentRating === "explicit"
|
|
210
|
+
};
|
|
211
|
+
const thumbnailUrl = attributes.artwork?.url?.replace("{w}", "128")?.replace("{h}", "128");
|
|
212
|
+
if (type === "song") {
|
|
213
|
+
const duration = attributes.durationInMillis, previewUrl = attributes.previews?.[0]?.url;
|
|
214
|
+
return {
|
|
215
|
+
...base,
|
|
216
|
+
form: "single",
|
|
217
|
+
thumbnailUrl,
|
|
218
|
+
single: {
|
|
219
|
+
audio: previewUrl && duration ? {
|
|
220
|
+
previewUrl,
|
|
221
|
+
duration
|
|
222
|
+
} : void 0,
|
|
223
|
+
link: attributes.url
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const songs = (relationships.songs ?? relationships.tracks)?.data;
|
|
228
|
+
if (!songs) return null;
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
form: "list",
|
|
232
|
+
thumbnailUrl,
|
|
233
|
+
list: songs.slice(0, PLAYLIST_LIMIT).map(({ attributes: song }) => {
|
|
234
|
+
const duration = song.durationInMillis, previewUrl = song.previews?.[0]?.url;
|
|
235
|
+
return {
|
|
236
|
+
label: song.name,
|
|
237
|
+
sublabel: song.artistName,
|
|
238
|
+
explicit: song.contentRating === "explicit",
|
|
239
|
+
audio: previewUrl && duration ? {
|
|
240
|
+
previewUrl,
|
|
241
|
+
duration
|
|
242
|
+
} : void 0,
|
|
243
|
+
link: song.url
|
|
244
|
+
};
|
|
245
|
+
})
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
from(type, id) {
|
|
249
|
+
return `https://music.apple.com/us/${type}/${defaultName}/${id}`;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/handlers/defs/services/soundcloud.ts
|
|
255
|
+
const client_id = "nIjtjiYnjkOhMyh5xrbqEW12DxeJVnic";
|
|
256
|
+
async function parseWidget(type, id, tracks) {
|
|
257
|
+
return (await request({
|
|
258
|
+
url: `https://api-widget.soundcloud.com/${type}s/${id}${tracks ? "/tracks?limit=20" : ""}`,
|
|
259
|
+
query: {
|
|
260
|
+
client_id,
|
|
261
|
+
app_version: "1752674865",
|
|
262
|
+
format: "json",
|
|
263
|
+
representation: "full"
|
|
264
|
+
}
|
|
265
|
+
})).json;
|
|
266
|
+
}
|
|
267
|
+
async function parsePreview(transcodings) {
|
|
268
|
+
const preview = transcodings.sort((a, b) => {
|
|
269
|
+
const isA = a.format.protocol === "progressive";
|
|
270
|
+
const isB = b.format.protocol === "progressive";
|
|
271
|
+
return isA && !isB ? -1 : isB && !isA ? 1 : 0;
|
|
272
|
+
})?.[0];
|
|
273
|
+
if (preview?.url && preview?.duration) {
|
|
274
|
+
const link = (await request({
|
|
275
|
+
url: preview.url,
|
|
276
|
+
query: { client_id }
|
|
277
|
+
})).json;
|
|
278
|
+
if (!link?.url) return;
|
|
279
|
+
return {
|
|
280
|
+
duration: preview.duration,
|
|
281
|
+
previewUrl: link.url
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const soundcloud = {
|
|
286
|
+
name: "soundcloud",
|
|
287
|
+
hosts: [
|
|
288
|
+
"soundcloud.com",
|
|
289
|
+
"m.soundcloud.com",
|
|
290
|
+
"on.soundcloud.com"
|
|
291
|
+
],
|
|
292
|
+
types: [
|
|
293
|
+
"user",
|
|
294
|
+
"track",
|
|
295
|
+
"playlist"
|
|
296
|
+
],
|
|
297
|
+
async parse(link, host, path) {
|
|
298
|
+
if (host === "on.soundcloud.com") {
|
|
299
|
+
if (!path[0] || path[1]) return null;
|
|
300
|
+
const { url, status } = await request({ url: link.slice(0, -1) });
|
|
301
|
+
return status === 200 ? await parseLink(url) : null;
|
|
302
|
+
} else {
|
|
303
|
+
const [user, second, track, fourth] = path;
|
|
304
|
+
let valid = false;
|
|
305
|
+
if (user && !second) valid = true;
|
|
306
|
+
else if (user && second && second !== "sets" && !track) valid = true;
|
|
307
|
+
else if (user && second === "sets" && track && !fourth) valid = true;
|
|
308
|
+
if (!valid) return null;
|
|
309
|
+
const data = (await request({
|
|
310
|
+
url: "https://soundcloud.com/oembed",
|
|
311
|
+
query: {
|
|
312
|
+
format: "json",
|
|
313
|
+
url: link.slice(0, -1)
|
|
314
|
+
}
|
|
315
|
+
})).json;
|
|
316
|
+
if (!data?.html) return null;
|
|
317
|
+
const rawUrl = data.html.match(/w\.soundcloud\.com.*?url=(.*?)[&"]/)?.[1];
|
|
318
|
+
if (!rawUrl) return null;
|
|
319
|
+
const splits = decodeURIComponent(rawUrl).split(/\/+/);
|
|
320
|
+
const kind = splits[2], id = splits[3];
|
|
321
|
+
if (!kind || !id) return null;
|
|
322
|
+
return {
|
|
323
|
+
service: this.name,
|
|
324
|
+
type: kind.slice(0, -1),
|
|
325
|
+
id
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
async render(type, id) {
|
|
330
|
+
const data = await parseWidget(type, id, false);
|
|
331
|
+
if (!data.id) return null;
|
|
332
|
+
const base = {
|
|
333
|
+
label: data.title ?? data.username,
|
|
334
|
+
sublabel: data.user?.username ?? "Top tracks",
|
|
335
|
+
explicit: Boolean(data.publisher_metadata?.explicit)
|
|
336
|
+
};
|
|
337
|
+
const thumbnailUrl = data.artwork_url ?? data.avatar_url;
|
|
338
|
+
if (type === "track") {
|
|
339
|
+
const audio = await parsePreview(data.media?.transcodings ?? []);
|
|
340
|
+
return {
|
|
341
|
+
...base,
|
|
342
|
+
form: "single",
|
|
343
|
+
thumbnailUrl,
|
|
344
|
+
single: {
|
|
345
|
+
audio,
|
|
346
|
+
link: data.permalink_url
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
let tracks = [];
|
|
351
|
+
if (type === "user") {
|
|
352
|
+
const got = await parseWidget(type, id, true);
|
|
353
|
+
if (!got.collection) return null;
|
|
354
|
+
tracks = got.collection;
|
|
355
|
+
} else {
|
|
356
|
+
if (!data.tracks) return null;
|
|
357
|
+
tracks = data.tracks;
|
|
358
|
+
}
|
|
359
|
+
const list = [];
|
|
360
|
+
for (const track of tracks) {
|
|
361
|
+
if (!track.title || list.length >= PLAYLIST_LIMIT) continue;
|
|
362
|
+
const audio = await parsePreview(track.media?.transcodings ?? []);
|
|
363
|
+
list.push({
|
|
364
|
+
label: track.title,
|
|
365
|
+
sublabel: track.user?.username ?? "IDKK",
|
|
366
|
+
explicit: Boolean(track.publisher_metadata.explicit),
|
|
367
|
+
audio,
|
|
368
|
+
link: track.permalink_url
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
...base,
|
|
373
|
+
form: "list",
|
|
374
|
+
thumbnailUrl,
|
|
375
|
+
list
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
async from(type, id) {
|
|
379
|
+
const data = await parseWidget(type, id, false);
|
|
380
|
+
if (!data.id) return null;
|
|
381
|
+
return data.permalink_url;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/handlers/defs/services/spotify.ts
|
|
387
|
+
async function parseEmbed(type, id) {
|
|
388
|
+
return parseNextData((await request({ url: `https://open.spotify.com/embed/${type}/${id}` })).text);
|
|
389
|
+
}
|
|
390
|
+
function from(type, id) {
|
|
391
|
+
return `https://open.spotify.com/${type}/${id}`;
|
|
392
|
+
}
|
|
393
|
+
function fromUri(uri) {
|
|
394
|
+
const [sanityCheck, type, id] = uri.split(":");
|
|
395
|
+
if (sanityCheck === "spotify" && type && id) return from(type, id);
|
|
396
|
+
else return null;
|
|
397
|
+
}
|
|
398
|
+
const spotify = {
|
|
399
|
+
name: "spotify",
|
|
400
|
+
hosts: ["open.spotify.com"],
|
|
401
|
+
types: [
|
|
402
|
+
"track",
|
|
403
|
+
"album",
|
|
404
|
+
"playlist",
|
|
405
|
+
"artist"
|
|
406
|
+
],
|
|
407
|
+
async parse(_link, _host, path) {
|
|
408
|
+
const [type, id, third] = path;
|
|
409
|
+
if (!type || !this.types.includes(type) || !id || third) return null;
|
|
410
|
+
const title = (await parseEmbed(type, id))?.props?.pageProps?.title;
|
|
411
|
+
if (title) return null;
|
|
412
|
+
return {
|
|
413
|
+
service: this.name,
|
|
414
|
+
type,
|
|
415
|
+
id
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
async render(type, id) {
|
|
419
|
+
const data = (await parseEmbed(type, id))?.props?.pageProps?.state?.data?.entity;
|
|
420
|
+
if (!data) return null;
|
|
421
|
+
const base = {
|
|
422
|
+
label: data.title,
|
|
423
|
+
sublabel: data.subtitle ?? data.artists?.map((x) => x.name).join(", "),
|
|
424
|
+
explicit: Boolean(data.isExplicit)
|
|
425
|
+
};
|
|
426
|
+
const thumbnailUrl = data.visualIdentity.image.sort((a, b) => a.maxWidth - b.maxWidth)[0]?.url;
|
|
427
|
+
if (type === "track") {
|
|
428
|
+
const link = fromUri(data.uri);
|
|
429
|
+
return {
|
|
430
|
+
...base,
|
|
431
|
+
form: "single",
|
|
432
|
+
thumbnailUrl,
|
|
433
|
+
single: {
|
|
434
|
+
audio: data.audioPreview && data.duration ? {
|
|
435
|
+
duration: data.duration,
|
|
436
|
+
previewUrl: data.audioPreview.url
|
|
437
|
+
} : void 0,
|
|
438
|
+
link
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
} else {
|
|
442
|
+
const list = [];
|
|
443
|
+
for (const track of data.trackList ?? []) {
|
|
444
|
+
if (list.length >= PLAYLIST_LIMIT) continue;
|
|
445
|
+
const link = fromUri(track.uri);
|
|
446
|
+
list.push({
|
|
447
|
+
label: track.title,
|
|
448
|
+
sublabel: track.subtitle ?? track.artists?.map((x) => x.name).join(", "),
|
|
449
|
+
explicit: Boolean(track.isExplicit),
|
|
450
|
+
audio: track.audioPreview && track.duration ? {
|
|
451
|
+
duration: track.duration,
|
|
452
|
+
previewUrl: track.audioPreview.url
|
|
453
|
+
} : void 0,
|
|
454
|
+
link
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
...base,
|
|
459
|
+
form: "list",
|
|
460
|
+
thumbnailUrl,
|
|
461
|
+
list
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
from
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/handlers/core.ts
|
|
470
|
+
const services = [
|
|
471
|
+
spotify,
|
|
472
|
+
soundcloud,
|
|
473
|
+
applemusic
|
|
474
|
+
];
|
|
475
|
+
$.services = services;
|
|
476
|
+
const parsers = [songdotlink, ...services];
|
|
477
|
+
$.parsers = parsers;
|
|
478
|
+
|
|
479
|
+
//#endregion
|
|
480
|
+
export { $, parseLink, parsers, renderSong, services, toLink };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Song } from "./types-Cp4kDlRH.js";
|
|
2
|
+
import { MaybePromise } from "bun";
|
|
3
|
+
|
|
4
|
+
//#region src/handlers/types.d.ts
|
|
5
|
+
interface RenderInfoBase {
|
|
6
|
+
label: string;
|
|
7
|
+
sublabel: string;
|
|
8
|
+
explicit: boolean;
|
|
9
|
+
}
|
|
10
|
+
interface RenderInfoEntry {
|
|
11
|
+
audio?: {
|
|
12
|
+
duration: number;
|
|
13
|
+
previewUrl: string;
|
|
14
|
+
} | undefined;
|
|
15
|
+
link: string;
|
|
16
|
+
}
|
|
17
|
+
type RenderInfoEntryBased = RenderInfoEntry & RenderInfoBase;
|
|
18
|
+
interface RenderSongSingle extends RenderInfoBase {
|
|
19
|
+
form: "single";
|
|
20
|
+
thumbnailUrl?: string;
|
|
21
|
+
single: RenderInfoEntry;
|
|
22
|
+
}
|
|
23
|
+
interface RenderSongList extends RenderInfoBase {
|
|
24
|
+
form: "list";
|
|
25
|
+
thumbnailUrl?: string;
|
|
26
|
+
list: RenderInfoEntryBased[];
|
|
27
|
+
}
|
|
28
|
+
type RenderSongInfo = RenderSongSingle | RenderSongList;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/handlers/helpers.d.ts
|
|
31
|
+
interface SongParser {
|
|
32
|
+
hosts: string[];
|
|
33
|
+
parse(link: string, host: string, path: string[]): MaybePromise<Song | null>;
|
|
34
|
+
}
|
|
35
|
+
interface SongService extends SongParser {
|
|
36
|
+
name: string;
|
|
37
|
+
types: string[];
|
|
38
|
+
render(type: string, id: string): MaybePromise<RenderSongInfo | null>;
|
|
39
|
+
from(type: string, id: string): MaybePromise<string | null>;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/handlers/core.d.ts
|
|
43
|
+
declare const services: SongService[];
|
|
44
|
+
declare const parsers: SongParser[];
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/handlers/finders.d.ts
|
|
47
|
+
declare const $: {
|
|
48
|
+
services: SongService[];
|
|
49
|
+
parsers: SongParser[];
|
|
50
|
+
};
|
|
51
|
+
declare function parseLink(link: string): Promise<Song | null>;
|
|
52
|
+
declare function renderSong(song: Song): Promise<RenderSongInfo | null>;
|
|
53
|
+
declare function toLink(song: Song): Promise<string | null>;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { $, RenderInfoBase, RenderInfoEntry, RenderInfoEntryBased, RenderSongInfo, SongParser, SongService, parseLink, parsers, renderSong, services, toLink };
|
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Song, UserData } from "./types-Cp4kDlRH.js";
|
|
2
|
+
import z, { ZodLiteral, ZodObject, ZodString, ZodUnion, core } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/structs/zod.d.ts
|
|
5
|
+
type SongDef = ZodObject<{
|
|
6
|
+
service: ZodLiteral<string>;
|
|
7
|
+
type: ZodUnion<ZodLiteral<string>[]>;
|
|
8
|
+
id: ZodString;
|
|
9
|
+
}, core.$strip>;
|
|
10
|
+
declare const SongSchema: z.ZodDiscriminatedUnion<[SongDef], "service">;
|
|
11
|
+
declare const UserDataSchema: z.ZodArray<z.ZodDiscriminatedUnion<[SongDef], "service">>;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { Song, SongSchema, UserData, UserDataSchema };
|
package/dist/structs.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { services } from "./core-CLy9m0H1.js";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/structs/zod.ts
|
|
5
|
+
const SongSchema = z.discriminatedUnion("service", services.map((service) => z.object({
|
|
6
|
+
service: z.literal(service.name),
|
|
7
|
+
type: z.union(service.types.map((type) => z.literal(type))),
|
|
8
|
+
id: z.string()
|
|
9
|
+
})));
|
|
10
|
+
const UserDataSchema = z.array(SongSchema).max(6);
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
export { SongSchema, UserDataSchema };
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@song-spotlight/api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Song Spotlight API types and song validation module",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "rm -rf dist && rolldown -c rolldown.config.mts",
|
|
8
|
+
"prepublishOnly": "bun run build"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./handlers": {
|
|
12
|
+
"default": "./dist/handlers.js",
|
|
13
|
+
"types": "./dist/handlers.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./structs": {
|
|
16
|
+
"default": "./dist/structs.js",
|
|
17
|
+
"types": "./dist/structs.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"homepage": "https://github.com/nexpid-labs/SongSpotlight/tree/main/packages/api",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/nexpid-labs/SongSpotlight.git",
|
|
31
|
+
"directory": "packages/api"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/nexpid-labs/SongSpotlight/issues"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"zod": "^4.1.5"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"rolldown": "^1.0.0-beta.34",
|
|
42
|
+
"rolldown-plugin-dts": "^0.15.10"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"typescript": "^5"
|
|
46
|
+
}
|
|
47
|
+
}
|