src
pages
configurator
core
input
orchestrator
processor
scripts
···
47
47
"npm:query-string@^9.1.2",
48
48
"npm:sass@^1.87.0",
49
49
"npm:spellcaster@6",
50
50
+
"npm:subsonic-api@^3.1.2",
50
51
"npm:throttle-debounce@^5.0.2",
51
52
"npm:uint8arrays@^5.1.0",
52
53
"npm:uri-js@^4.4.1",
···
24
24
"native-file-system-adapter": "^3.0.1",
25
25
"query-string": "^9.1.2",
26
26
"spellcaster": "^6.0.0",
27
27
+
"subsonic-api": "^3.1.2",
27
28
"throttle-debounce": "^5.0.2",
28
29
"uint8arrays": "^5.1.0",
29
30
"uri-js": "^4.4.1",
···
21720
21721
"url": "https://github.com/sponsors/Borewit"
21721
21722
}
21722
21723
},
21724
21724
+
"node_modules/subsonic-api": {
21725
21725
+
"version": "3.1.2",
21726
21726
+
"resolved": "https://registry.npmjs.org/subsonic-api/-/subsonic-api-3.1.2.tgz",
21727
21727
+
"integrity": "sha512-EPuqd+z/6v/AbZhd25/5AN+QWsdFQ9K1SHd3N9PIN7Jheo9+L2bsmrbpjJ7D/AgnrmiSmlwhdfnkiaC83hVsfQ==",
21728
21728
+
"license": "MIT",
21729
21729
+
"dependencies": {
21730
21730
+
"typescript": "^5.7.3"
21731
21731
+
},
21732
21732
+
"engines": {
21733
21733
+
"node": ">=18"
21734
21734
+
}
21735
21735
+
},
21723
21736
"node_modules/then-read-stream": {
21724
21737
"version": "1.5.1",
21725
21738
"resolved": "https://registry.npmjs.org/then-read-stream/-/then-read-stream-1.5.1.tgz",
···
21899
21912
"version": "5.8.3",
21900
21913
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
21901
21914
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
21902
21902
-
"dev": true,
21903
21915
"license": "Apache-2.0",
21904
21904
-
"peer": true,
21905
21916
"bin": {
21906
21917
"tsc": "bin/tsc",
21907
21918
"tsserver": "bin/tsserver"
···
19
19
"native-file-system-adapter": "^3.0.1",
20
20
"query-string": "^9.1.2",
21
21
"spellcaster": "^6.0.0",
22
22
+
"subsonic-api": "^3.1.2",
22
23
"throttle-debounce": "^5.0.2",
23
24
"uint8arrays": "^5.1.0",
24
25
"uri-js": "^4.4.1",
···
12
12
<strong>My device</strong>
13
13
</a>
14
14
<br />
15
15
+
<a href="../../input/opensubsonic/" class="with-icon">
16
16
+
<i class="iconoir-open-in-window"></i>
17
17
+
<strong>Opensubsonic server</strong>
18
18
+
</a>
19
19
+
<br />
15
20
<a href="../../input/s3/" class="with-icon">
16
21
<i class="iconoir-open-in-window"></i>
17
22
<strong>S3-compatible service</strong>
···
41
46
// Applet connections
42
47
const input = {
43
48
nativeFs: applet("../../input/native-fs"),
49
49
+
opensubsonic: applet("../../input/opensubsonic"),
44
50
s3: applet("../../input/s3"),
45
51
};
46
52
···
48
54
// ACTIONS
49
55
////////////////////////////////////////////
50
56
const contextualize = async (tracks: Track[]) => {
57
57
+
const opensubsonic = await input.opensubsonic;
51
58
const s3 = await input.s3;
52
52
-
await s3.sendAction("contextualize", tracks, { timeoutDuration: 60000 * 5 });
59
59
+
60
60
+
const groups = await groupTracksPerScheme(tracks);
61
61
+
62
62
+
await Promise.all([
63
63
+
opensubsonic.sendAction(
64
64
+
"contextualize",
65
65
+
groups[opensubsonic.manifest.input_properties.scheme],
66
66
+
{ timeoutDuration: 60000 * 5 },
67
67
+
),
68
68
+
s3.sendAction("contextualize", groups[s3.manifest.input_properties.scheme], {
69
69
+
timeoutDuration: 60000 * 5,
70
70
+
}),
71
71
+
]);
53
72
};
54
73
55
74
const list = async (cachedTracks: Track[] = []) => {
56
56
-
const [nativeFs, s3] = [await input.nativeFs, await input.s3];
75
75
+
const [nativeFs, opensubsonic, s3] = [
76
76
+
await input.nativeFs,
77
77
+
await input.opensubsonic,
78
78
+
await input.s3,
79
79
+
];
57
80
58
58
-
const groups = cachedTracks.reduce(
59
59
-
(acc: Record<string, Track[]>, track: Track) => {
60
60
-
const scheme = track.uri.split(":", 1)[0];
61
61
-
return { ...acc, [scheme]: [...(acc[scheme] || []), track] };
62
62
-
},
63
63
-
{
64
64
-
[nativeFs.manifest.input_properties.scheme]: [],
65
65
-
[s3.manifest.input_properties.scheme]: [],
66
66
-
},
67
67
-
);
81
81
+
const groups = await groupTracksPerScheme(cachedTracks);
68
82
69
83
const promises = Object.entries(groups).map(
70
84
async ([scheme, cachedTracksGroup]: [string, Track[]]) => {
71
85
switch (scheme) {
72
86
case nativeFs.manifest.input_properties.scheme:
73
87
return await nativeFs.sendAction("list", cachedTracksGroup, {
88
88
+
timeoutDuration: 60000 * 60 * 24,
89
89
+
});
90
90
+
91
91
+
case opensubsonic.manifest.input_properties.scheme:
92
92
+
return await opensubsonic.sendAction("list", cachedTracksGroup, {
74
93
timeoutDuration: 60000 * 60 * 24,
75
94
});
76
95
···
92
111
};
93
112
94
113
const resolve = async (args: { method: string; uri: string }) => {
95
95
-
const [nativeFs, s3] = [await input.nativeFs, await input.s3];
96
114
const scheme = args.uri.split(":", 1)[0];
115
115
+
const [nativeFs, opensubsonic, s3] = [
116
116
+
await input.nativeFs,
117
117
+
await input.opensubsonic,
118
118
+
await input.s3,
119
119
+
];
97
120
98
121
switch (scheme) {
99
122
case nativeFs.manifest.input_properties.scheme:
100
123
return await nativeFs.sendAction("resolve", args);
124
124
+
125
125
+
case opensubsonic.manifest.input_properties.scheme:
126
126
+
return await opensubsonic.sendAction("resolve", args);
101
127
102
128
case s3.manifest.input_properties.scheme:
103
129
return await s3.sendAction("resolve", args);
···
110
136
context.setActionHandler("contextualize", contextualize);
111
137
context.setActionHandler("list", list);
112
138
context.setActionHandler("resolve", resolve);
139
139
+
140
140
+
////////////////////////////////////////////
141
141
+
// 🛠️
142
142
+
////////////////////////////////////////////
143
143
+
async function groupTracksPerScheme(tracks: Track[]) {
144
144
+
return tracks.reduce(
145
145
+
(acc: Record<string, Track[]>, track: Track) => {
146
146
+
const scheme = track.uri.split(":", 1)[0];
147
147
+
return { ...acc, [scheme]: [...(acc[scheme] || []), track] };
148
148
+
},
149
149
+
{
150
150
+
[(await input.nativeFs).manifest.input_properties.scheme]: [],
151
151
+
[(await input.opensubsonic).manifest.input_properties.scheme]: [],
152
152
+
[(await input.s3).manifest.input_properties.scheme]: [],
153
153
+
},
154
154
+
);
155
155
+
}
113
156
</script>
···
145
145
default:
146
146
const conn = await connection(method);
147
147
try {
148
148
-
await conn.sendAction("mount");
148
148
+
await conn.sendAction("mount", undefined, { timeoutDuration: 60000 });
149
149
setActive(method);
150
150
} catch (err) {
151
151
const msg: string =
···
159
159
async function unmountStorageMethod(method: Method) {
160
160
const conn = await connection(method);
161
161
conn.removeEventListener("data", dateEventHandler);
162
162
-
await conn.sendAction("unmount");
162
162
+
await conn.sendAction("unmount", undefined, { timeoutDuration: 60000 });
163
163
}
164
164
165
165
////////////////////////////////////////////
···
167
167
////////////////////////////////////////////
168
168
const tracks = async (...args: unknown[]) => {
169
169
const conn = await connection(active());
170
170
-
await conn.sendAction("tracks", ...args);
170
170
+
await conn.sendAction("tracks", ...args, { timeoutDuration: 60000 * 5 });
171
171
};
172
172
173
173
context.setActionHandler("tracks", tracks);
···
355
355
}
356
356
357
357
localStorage.setItem(CUSTOM_KEY, url);
358
358
-
await apl.sendAction("mount");
358
358
+
await apl.sendAction("mount", undefined, { timeoutDuration: 60000 });
359
359
360
360
setActive("custom");
361
361
setModalIsOpen(false);
···
88
88
>abstractions</a
89
89
> for non-browser systems.
90
90
</p>
91
91
+
<p>
92
92
+
<strong
93
93
+
>TODO: Figure out how to present this to users who just want to use the damn thing.</strong
94
94
+
>
95
95
+
</p>
91
96
</header>
92
97
<main>
93
98
<div class="columns">
···
1
1
+
<main class="container">
2
2
+
<h1>OpenSubsonic input</h1>
3
3
+
4
4
+
<h4>Mounted servers</h4>
5
5
+
6
6
+
<div id="servers">
7
7
+
<p>
8
8
+
<span class="with-icon">
9
9
+
<i class="iconoir-bonfire"></i>
10
10
+
<small>Just a moment, loading mounted servers.</small>
11
11
+
</span>
12
12
+
</p>
13
13
+
</div>
14
14
+
15
15
+
<h4>Add a new OpenSubsonic-compatible server</h4>
16
16
+
17
17
+
<form id="form"></form>
18
18
+
</main>
19
19
+
20
20
+
<style is:global>
21
21
+
iframe {
22
22
+
display: none;
23
23
+
}
24
24
+
</style>
25
25
+
26
26
+
<script>
27
27
+
import { SubsonicAPI, type Child } from "subsonic-api";
28
28
+
import { computed, effect, type Signal, signal } from "spellcaster";
29
29
+
import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js";
30
30
+
import * as IDB from "idb-keyval";
31
31
+
import * as URI from "uri-js";
32
32
+
import QS from "query-string";
33
33
+
34
34
+
import type { Track } from "@applets/core/types.d.ts";
35
35
+
import { register } from "@scripts/applet/common";
36
36
+
import manifest from "./_manifest.json";
37
37
+
38
38
+
// https://opensubsonic.netlify.app/docs/api-reference/
39
39
+
type Server = {
40
40
+
apiKey?: string;
41
41
+
host: string;
42
42
+
password?: string;
43
43
+
tls: boolean;
44
44
+
username?: string;
45
45
+
};
46
46
+
47
47
+
////////////////////////////////////////////
48
48
+
// SETUP
49
49
+
////////////////////////////////////////////
50
50
+
const IDB_PREFIX = "@applets/input/opensubsonic";
51
51
+
const IDB_SERVERS = `${IDB_PREFIX}/servers`;
52
52
+
const SCHEME = manifest.input_properties.scheme;
53
53
+
54
54
+
// Register applet
55
55
+
const context = register();
56
56
+
57
57
+
////////////////////////////////////////////
58
58
+
// ACTIONS
59
59
+
////////////////////////////////////////////
60
60
+
const consult = async (fileUriOrScheme: string) => {
61
61
+
// TODO: Check if server is available + CORS works?
62
62
+
return { supported: true };
63
63
+
};
64
64
+
65
65
+
const contextualize = async (tracks: Track[]) => {
66
66
+
const s = serversFromTracks(tracks);
67
67
+
setServers({ ...servers(), ...s });
68
68
+
};
69
69
+
70
70
+
const list = async (cachedTracks: Track[] = []) => {
71
71
+
const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => {
72
72
+
const uri = URI.parse(t.uri);
73
73
+
if (!uri.path) return acc;
74
74
+
return { ...acc, [URI.unescapeComponent(uri.path)]: t };
75
75
+
}, {});
76
76
+
77
77
+
async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> {
78
78
+
const result = await client.search3({
79
79
+
query: "",
80
80
+
artistCount: 0,
81
81
+
albumCount: 0,
82
82
+
songCount: 1000,
83
83
+
songOffset: offset,
84
84
+
});
85
85
+
86
86
+
const songs = result.searchResult3.song || [];
87
87
+
88
88
+
if (songs.length === 1000) {
89
89
+
const moreSongs = await search(client, offset + 1000);
90
90
+
return [...songs, ...moreSongs];
91
91
+
}
92
92
+
93
93
+
return songs;
94
94
+
}
95
95
+
96
96
+
const promises = Object.values(servers()).map(async (server) => {
97
97
+
const client = createClient(server);
98
98
+
const list = await search(client, 0);
99
99
+
100
100
+
return list
101
101
+
.filter((song) => !song.isVideo)
102
102
+
.map((song) => {
103
103
+
if (song.path && cache[song.path]) return cache[song.path];
104
104
+
105
105
+
const track: Track = {
106
106
+
id: crypto.randomUUID(),
107
107
+
kind: autoTypeToTrackKind(song.type),
108
108
+
uri: buildURI(server, { songId: song.id, path: song.path }),
109
109
+
110
110
+
stats: {
111
111
+
bitrate: song.bitRate,
112
112
+
duration: song.duration,
113
113
+
},
114
114
+
tags: {
115
115
+
album: song.album,
116
116
+
artist: song.artist,
117
117
+
disc: { no: song.discNumber || 1 },
118
118
+
genre: song.genre,
119
119
+
title: song.title,
120
120
+
track: { no: song.track || 1 },
121
121
+
year: song.year,
122
122
+
},
123
123
+
};
124
124
+
125
125
+
return track;
126
126
+
});
127
127
+
});
128
128
+
129
129
+
return (await Promise.all(promises)).flat(1);
130
130
+
};
131
131
+
132
132
+
const resolve = async ({ uri }: { method: string; uri: string }) => {
133
133
+
const server = parseURI(uri);
134
134
+
if (!server) return undefined;
135
135
+
136
136
+
const client = createClient(server);
137
137
+
const parsedURI = URI.parse(uri);
138
138
+
const qs = QS.parse(parsedURI.query || "");
139
139
+
140
140
+
const songId = typeof qs.songId === "string" ? qs.songId : undefined;
141
141
+
if (!songId) return undefined;
142
142
+
143
143
+
// TODO:
144
144
+
// const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days
145
145
+
// const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
146
146
+
147
147
+
const url = await client
148
148
+
.download({
149
149
+
id: songId,
150
150
+
format: "raw",
151
151
+
})
152
152
+
.then((a) => a.blob())
153
153
+
.then((blob) => URL.createObjectURL(blob));
154
154
+
155
155
+
// NOTE:
156
156
+
// First idea was to get the URL for the download and use that instead.
157
157
+
// Problem is, more often than not, servers don't allow for CORS Range requests,
158
158
+
// so it's basically useless.
159
159
+
160
160
+
return { expiresAt: Infinity, url };
161
161
+
};
162
162
+
163
163
+
const mount = async () => {};
164
164
+
165
165
+
const unmount = async () => {};
166
166
+
167
167
+
context.setActionHandler("consult", consult);
168
168
+
context.setActionHandler("contextualize", contextualize);
169
169
+
context.setActionHandler("list", list);
170
170
+
context.setActionHandler("resolve", resolve);
171
171
+
context.setActionHandler("mount", mount);
172
172
+
context.setActionHandler("unmount", unmount);
173
173
+
174
174
+
////////////////////////////////////////////
175
175
+
// UI
176
176
+
////////////////////////////////////////////
177
177
+
const [servers, setServers] = signal<Record<string, Server>>(await loadServers());
178
178
+
const [form, setForm] = signal<{
179
179
+
api_key?: string;
180
180
+
host?: string;
181
181
+
password?: string;
182
182
+
username?: string;
183
183
+
}>({});
184
184
+
185
185
+
const serversMap = computed(() => {
186
186
+
return new Map(Object.entries(servers()));
187
187
+
});
188
188
+
189
189
+
effect(() => {
190
190
+
saveServers(servers());
191
191
+
});
192
192
+
193
193
+
////////////////////////////////////////////
194
194
+
// UI ~ SERVERS
195
195
+
////////////////////////////////////////////
196
196
+
const Server = (server: Signal<Server>) => {
197
197
+
const onclick = () => {
198
198
+
const b = server();
199
199
+
const id = serverId(b);
200
200
+
201
201
+
const col = { ...servers() };
202
202
+
delete col[id];
203
203
+
204
204
+
setServers(col);
205
205
+
};
206
206
+
207
207
+
return tags.li({ onclick, style: "cursor: pointer" }, text(server().host));
208
208
+
};
209
209
+
210
210
+
const ServerList = computed(() => {
211
211
+
if (serversMap().size === 0) {
212
212
+
return tags.p({ id: "servers" }, [tags.small({}, text("Nothing added so far."))]);
213
213
+
}
214
214
+
215
215
+
return tags.ul({ id: "servers" }, repeat(serversMap, Server));
216
216
+
});
217
217
+
218
218
+
effect(() => {
219
219
+
document.querySelector("#servers")?.replaceWith(ServerList());
220
220
+
});
221
221
+
222
222
+
////////////////////////////////////////////
223
223
+
// UI ~ FORM
224
224
+
////////////////////////////////////////////
225
225
+
function addServer(event: Event) {
226
226
+
event.preventDefault();
227
227
+
228
228
+
const f = form();
229
229
+
230
230
+
const server: Server = {
231
231
+
apiKey: f.api_key,
232
232
+
host: f.host?.replace(/^https?:\/\//, "").replace(/\/+$/, "") || "localhost:4533",
233
233
+
username: f.username,
234
234
+
tls: f.host?.startsWith("http://") || f.host?.startsWith("localhost") ? false : true,
235
235
+
password: f.password,
236
236
+
};
237
237
+
238
238
+
setServers({
239
239
+
...servers(),
240
240
+
[serverId(server)]: server,
241
241
+
});
242
242
+
}
243
243
+
244
244
+
function Form() {
245
245
+
return tags.form({ onsubmit: addServer }, [
246
246
+
tags.fieldset({ className: "grid" }, [
247
247
+
Input("host", "Server host", "my.opensubsonic.server:4747", { required: true }),
248
248
+
]),
249
249
+
tags.fieldset({ className: "grid" }, [
250
250
+
Input("username", "Server name", "username", { required: true }),
251
251
+
Input("password", "Password", "password", { required: true, type: "password" }),
252
252
+
]),
253
253
+
tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]),
254
254
+
]);
255
255
+
}
256
256
+
257
257
+
function Input(name: string, label: string, placeholder: string, opts: Props = {}) {
258
258
+
return tags.label({}, [
259
259
+
tags.span({}, [
260
260
+
tags.span({}, text(label)),
261
261
+
tags.small({}, text("required" in opts ? "" : " (optional)")),
262
262
+
]),
263
263
+
tags.input({
264
264
+
...opts,
265
265
+
name,
266
266
+
placeholder,
267
267
+
oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value),
268
268
+
}),
269
269
+
]);
270
270
+
}
271
271
+
272
272
+
function formInput(name: string, value: string) {
273
273
+
setForm({ ...form(), [name]: value });
274
274
+
}
275
275
+
276
276
+
// 🚀
277
277
+
document.querySelector("#form")?.replaceWith(Form());
278
278
+
279
279
+
////////////////////////////////////////////
280
280
+
// 🛠️
281
281
+
////////////////////////////////////////////
282
282
+
function autoTypeToTrackKind(type: Child["type"]): Track["kind"] {
283
283
+
switch (type?.toLowerCase()) {
284
284
+
case "audiobook":
285
285
+
return "audiobook";
286
286
+
287
287
+
case "music":
288
288
+
return "music";
289
289
+
290
290
+
case "podcast":
291
291
+
return "podcast";
292
292
+
293
293
+
default:
294
294
+
return "miscellaneous";
295
295
+
}
296
296
+
}
297
297
+
298
298
+
function buildURI(server: Server, args: { songId: string; path?: string }) {
299
299
+
return URI.serialize({
300
300
+
scheme: SCHEME,
301
301
+
userinfo: server.apiKey
302
302
+
? URI.escapeComponent(server.apiKey)
303
303
+
: `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`,
304
304
+
host: server.host.replace(/^https?:\/\//, ""),
305
305
+
path: args.path,
306
306
+
query: QS.stringify({
307
307
+
songId: args.songId,
308
308
+
tls: server.tls ? "t" : "f",
309
309
+
}),
310
310
+
});
311
311
+
}
312
312
+
313
313
+
function createClient(server: Server) {
314
314
+
return new SubsonicAPI({
315
315
+
url: `http${server.tls ? "s" : ""}://${server.host}`,
316
316
+
auth: server.apiKey
317
317
+
? { apiKey: URI.unescapeComponent(server.apiKey) }
318
318
+
: {
319
319
+
username: URI.unescapeComponent(server.username || ""),
320
320
+
password: URI.unescapeComponent(server.password || ""),
321
321
+
},
322
322
+
});
323
323
+
}
324
324
+
325
325
+
async function loadServers() {
326
326
+
const i = await IDB.get(IDB_SERVERS);
327
327
+
return i ? i : {};
328
328
+
}
329
329
+
330
330
+
function parseURI(uriString: string): Server | undefined {
331
331
+
const uri = URI.parse(uriString);
332
332
+
if (uri.scheme !== SCHEME) return undefined;
333
333
+
if (!uri.host) return undefined;
334
334
+
335
335
+
let apiKey: string | undefined = undefined;
336
336
+
let username: string | undefined = undefined;
337
337
+
let password: string | undefined = undefined;
338
338
+
339
339
+
if (uri.userinfo?.includes(":")) {
340
340
+
// Username + Password
341
341
+
const [u, p] = uri.userinfo.split(":");
342
342
+
username = u;
343
343
+
password = p;
344
344
+
if (!username || !password) return undefined;
345
345
+
} else {
346
346
+
// API key
347
347
+
apiKey = uri.userinfo;
348
348
+
if (!apiKey) return undefined;
349
349
+
}
350
350
+
351
351
+
const qs = QS.parse(uri.query || "");
352
352
+
353
353
+
return {
354
354
+
apiKey,
355
355
+
host: uri.port ? `${uri.host}:${uri.port}` : uri.host,
356
356
+
password,
357
357
+
tls: qs.tls === "f" ? false : true,
358
358
+
username,
359
359
+
};
360
360
+
}
361
361
+
362
362
+
async function saveServers(items: Record<string, Server>) {
363
363
+
await IDB.set(IDB_SERVERS, items);
364
364
+
}
365
365
+
366
366
+
function serversFromTracks(tracks: Track[]) {
367
367
+
return tracks.reduce((acc: Record<string, Server>, track: Track) => {
368
368
+
const server = parseURI(track.uri);
369
369
+
if (!server) return acc;
370
370
+
371
371
+
const id = serverId(server);
372
372
+
if (acc[id]) return acc;
373
373
+
374
374
+
return { ...acc, [id]: server };
375
375
+
}, {});
376
376
+
}
377
377
+
378
378
+
function serverId(server: Server) {
379
379
+
if (server.apiKey) return `${server.apiKey}@${server.host}`;
380
380
+
return `${server.username}:${server.password}@${server.host}`;
381
381
+
}
382
382
+
</script>
···
1
1
+
{
2
2
+
"name": "diffuse/input/opensubsonic",
3
3
+
"title": "Diffuse Input | OpenSubsonic API",
4
4
+
"entrypoint": "index.html",
5
5
+
"input_properties": {
6
6
+
"scheme": "opensubsonic"
7
7
+
},
8
8
+
"actions": {
9
9
+
"consult": {
10
10
+
"title": "Consult",
11
11
+
"params_schema": {
12
12
+
"type": "string",
13
13
+
"description": "The uri to check the availability of."
14
14
+
}
15
15
+
},
16
16
+
"contextualize": {
17
17
+
"title": "Contextualize",
18
18
+
"params_schema": {
19
19
+
"type": "array",
20
20
+
"description": "Array of tracks",
21
21
+
"items": { "type": "object" }
22
22
+
}
23
23
+
},
24
24
+
"list": {
25
25
+
"title": "List",
26
26
+
"description": "List tracks.",
27
27
+
"params_schema": {
28
28
+
"type": "array",
29
29
+
"description": "A list of (cached) tracks with an uri matching the scheme",
30
30
+
"items": {
31
31
+
"type": "object"
32
32
+
}
33
33
+
}
34
34
+
},
35
35
+
"resolve": {
36
36
+
"title": "Resolve",
37
37
+
"description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes or an audio stream. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.",
38
38
+
"params_schema": {
39
39
+
"type": "object",
40
40
+
"properties": {
41
41
+
"method": {
42
42
+
"type": "string",
43
43
+
"description": "The HTTP method that is going to be used on the resolved URI."
44
44
+
},
45
45
+
"uri": { "type": "string", "description": "The URI to resolve." }
46
46
+
},
47
47
+
"required": ["method", "uri"]
48
48
+
}
49
49
+
},
50
50
+
"mount": {
51
51
+
"title": "Mount",
52
52
+
"description": "Prepare for usage."
53
53
+
},
54
54
+
"unmount": {
55
55
+
"title": "Unmount",
56
56
+
"description": "Callback after usage.",
57
57
+
"params_schema": {
58
58
+
"type": "string",
59
59
+
"description": "The handle id to unmount"
60
60
+
}
61
61
+
}
62
62
+
}
63
63
+
}
···
1
1
+
---
2
2
+
import Layout from "@layouts/applet-pico-ui.astro";
3
3
+
import Applet from "./_applet.astro";
4
4
+
import { title } from "./_manifest.json";
5
5
+
---
6
6
+
7
7
+
<Layout title={title}>
8
8
+
<Applet />
9
9
+
</Layout>
···
306
306
return URI.serialize({
307
307
scheme: SCHEME,
308
308
userinfo: `${bucket.accessKey}:${bucket.secretKey}`,
309
309
-
host: bucket.host,
309
309
+
host: bucket.host.replace(/^https?:\/\//, ""),
310
310
path: path,
311
311
query: QS.stringify({
312
312
bucketName: bucket.bucketName,
···
319
319
function createClient(bucket: Bucket) {
320
320
return new S3Client({
321
321
bucket: bucket.bucketName,
322
322
-
endPoint: bucket.host.includes("://") ? bucket.host : `https://${bucket.host}`,
322
322
+
endPoint: `http${bucket.host.startsWith("localhost") ? "" : "s"}://${bucket.host}`,
323
323
region: bucket.region,
324
324
pathStyle: false,
325
325
accessKey: bucket.accessKey,
···
52
52
});
53
53
54
54
// Process
55
55
-
let changed = false;
55
55
+
let changed = true; // TODO
56
56
57
57
const tracksWithMetadata = await tracks.reduce(
58
58
async (promise: Promise<Track[]>, track: Track) => {
···
67
67
const stream = resp.body;
68
68
69
69
if (!stream) return {};
70
70
-
meta = await parseWebStream(stream, { mimeType: mimeType || mimeFallback });
70
70
+
meta = await parseWebStream(
71
71
+
stream,
72
72
+
{ mimeType: mimeType || mimeFallback },
73
73
+
{ skipCovers: !includeArtwork },
74
74
+
);
71
75
} else if (urls) {
72
76
const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false });
73
77
httpClient.resolvedUrl = urls.get;
···
76
80
77
81
meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork });
78
82
} else if (stream) {
79
79
-
meta = await parseWebStream(stream, { mimeType });
83
83
+
meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork });
80
84
} else {
81
85
throw new Error("Missing args, need either some urls or a stream.");
82
86
}
···
3
3
import * as Uint8 from "uint8arrays";
4
4
import { applets } from "@web-applets/sdk";
5
5
import { type ElementConfigurator, h } from "spellcaster/hyperscript.js";
6
6
-
import { effect, isSignal, type Signal, signal, throttled } from "spellcaster/spellcaster.js";
6
6
+
import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js";
7
7
import { xxh32 } from "xxh32";
8
8
import QS from "query-string";
9
9
···
49
49
frame = existingFrame;
50
50
} else {
51
51
frame = document.createElement("iframe");
52
52
+
frame.loading = "eager";
52
53
frame.src = src;
53
54
if (opts.frameId) frame.id = opts.frameId;
54
55
···
170
171
});
171
172
172
173
// Promise that fullfills whenever it figures out its the main instance or not.
173
173
-
const promise = new Promise<void>((resolve) => {
174
174
-
const timeoutId = setTimeout(() => {
175
175
-
channel.removeEventListener("message", handler);
176
176
-
resolve(undefined);
177
177
-
}, 1000);
178
178
-
179
179
-
const handler = (event: MessageEvent) => {
180
180
-
if (event.data === "pong" || event.data === "ping") {
181
181
-
clearTimeout(timeoutId);
174
174
+
function makeMainPromise() {
175
175
+
return new Promise<{ isMain: boolean }>((resolve) => {
176
176
+
const timeoutId = setTimeout(() => {
182
177
channel.removeEventListener("message", handler);
183
183
-
resolve(undefined);
184
184
-
}
185
185
-
};
178
178
+
resolve({ isMain: true });
179
179
+
}, 1000);
186
180
187
187
-
channel.addEventListener("message", handler);
188
188
-
});
181
181
+
const handler = (event: MessageEvent) => {
182
182
+
if (event.data === "pong" || event.data === "ping") {
183
183
+
clearTimeout(timeoutId);
184
184
+
channel.removeEventListener("message", handler);
185
185
+
resolve({ isMain: false });
186
186
+
}
187
187
+
};
188
188
+
189
189
+
channel.addEventListener("message", handler);
190
190
+
});
191
191
+
}
192
192
+
193
193
+
const promise = makeMainPromise();
189
194
190
195
// Send out ping
191
196
channel.postMessage({
···
212
217
scope,
213
218
214
219
settled() {
215
215
-
return promise;
220
220
+
return promise.then(() => {});
216
221
},
217
222
218
223
get instanceId() {
···
237
242
},
238
243
239
244
setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
240
240
-
const handler = (...args: any) => {
245
245
+
const handler = async (...args: any) => {
246
246
+
if (isMainInstance) {
247
247
+
return actionHandler(...args);
248
248
+
}
249
249
+
250
250
+
// Check if a main instance is still available,
251
251
+
// if not, then this is the new main.
252
252
+
const { isMain } = await makeMainPromise();
253
253
+
isMainInstance = isMain;
254
254
+
241
255
if (isMainInstance) {
242
256
return actionHandler(...args);
243
257
}
···
249
263
arguments: args,
250
264
};
251
265
252
252
-
return new Promise((resolve) => {
266
266
+
console.log("📣", actionMessage);
267
267
+
268
268
+
return await new Promise((resolve) => {
253
269
const actionCallback = (event: MessageEvent) => {
254
270
if (
255
271
event.data?.type === "actioncomplete" &&