tangled
alpha
login
or
join now
tokono.ma
/
diffuse
5
fork
atom
A music player that connects to your cloud/distributed storage.
5
fork
atom
overview
issues
4
pulls
pipelines
feat: local file upload for ephemeral tracks
Steven Vandevelde
1 day ago
29c6ddfa
c29c68a8
+963
-44
15 changed files
expand all
collapse all
unified
split
src
components
configurator
input
element.js
types.d.ts
worker.js
input
ephemeral-cache
constants.js
element.js
worker.js
orchestrator
output
element.js
process-tracks
element.js
sources
element.js
transformer
output
refiner
default
element.js
facets
connect
common.css
local
index.inline.js
data
input-bundle
index.inline.js
tests
components
configurator
input
test.ts
input
ephemeral-cache
test.ts
+1
src/components/configurator/input/element.js
···
35
35
this.resolve = proxy.resolve;
36
36
37
37
this.cache = proxy.cache;
38
38
+
this.cacheBlob = proxy.cacheBlob;
38
39
this.listCached = proxy.listCached;
39
40
this.removeFromCache = proxy.removeFromCache;
40
41
}
+1
src/components/configurator/input/types.d.ts
···
2
2
3
3
export type Actions = InputActions & {
4
4
cache(uris: string[]): Promise<void>
5
5
+
cacheBlob(blob: Blob): Promise<string>
5
6
listCached(): Promise<string[]>
6
7
removeFromCache(uris: string[]): Promise<void>
7
8
};
+20
-26
src/components/configurator/input/worker.js
···
1
1
import * as IDB from "idb-keyval";
2
2
import * as URI from "fast-uri";
3
3
+
import * as Cid from "~/common/cid.js";
3
4
4
5
import { groupTracksPerScheme, groupUrisPerScheme } from "~/common/utils.js";
5
6
import { ostiary, rpc, workerProxy } from "~/common/worker.js";
···
16
17
////////////////////////////////////////////
17
18
18
19
const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/";
19
19
-
20
20
-
/** @type {Map<string, string>} */
21
21
-
const blobUrls = new Map();
22
20
23
21
////////////////////////////////////////////
24
22
// INPUT ACTIONS
···
58
56
* @type {ActionsWithTunnel<Actions>['detach']}
59
57
*/
60
58
export async function detach({ data, ports }) {
61
61
-
const cachedTracks = data.tracks;
62
62
-
const groups = groupTracks(cachedTracks, ports);
59
59
+
const currentTracks = data.tracks;
60
60
+
const groups = groupTracks(currentTracks, ports);
63
61
64
62
const promises = Object.entries(groups).map(
65
63
async ([scheme, tracksGroup]) => {
···
154
152
export async function resolve({ data, ports }) {
155
153
const uri = data.uri;
156
154
157
157
-
const cachedBlob =
158
158
-
/** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri));
159
159
-
if (cachedBlob) {
160
160
-
let blobUrl = blobUrls.get(uri);
161
161
-
162
162
-
if (!blobUrl) {
163
163
-
blobUrl = URL.createObjectURL(cachedBlob);
164
164
-
blobUrls.set(uri, blobUrl);
165
165
-
}
166
166
-
167
167
-
return { expiresAt: Infinity, url: blobUrl };
168
168
-
}
169
169
-
170
155
const scheme = uri.split(":", 1)[0];
171
156
const input = grabInput(scheme, ports);
172
157
if (!input) return undefined;
···
213
198
export async function removeFromCache({ data }) {
214
199
const uris = data;
215
200
216
216
-
await Promise.all(uris.map(async (uri) => {
217
217
-
const blobUrl = blobUrls.get(uri);
218
218
-
if (blobUrl) {
219
219
-
URL.revokeObjectURL(blobUrl);
220
220
-
blobUrls.delete(uri);
221
221
-
}
222
222
-
await IDB.del(CACHE_KEY_PREFIX + uri);
223
223
-
}));
201
201
+
await Promise.all(uris.map((uri) => IDB.del(CACHE_KEY_PREFIX + uri)));
202
202
+
}
203
203
+
204
204
+
/**
205
205
+
* @type {ActionsWithTunnel<Actions>['cacheBlob']}
206
206
+
*/
207
207
+
export async function cacheBlob({ data }) {
208
208
+
const blob = data;
209
209
+
const buffer = await blob.arrayBuffer();
210
210
+
const bytes = new Uint8Array(buffer);
211
211
+
const cid = await Cid.create(0x55, bytes);
212
212
+
const uri = `ephemeral+cache://${cid}`;
213
213
+
if (await IDB.get(CACHE_KEY_PREFIX + uri) === undefined) {
214
214
+
await IDB.set(CACHE_KEY_PREFIX + uri, blob);
215
215
+
}
216
216
+
return uri;
224
217
}
225
218
226
219
////////////////////////////////////////////
···
237
230
resolve,
238
231
239
232
cache,
233
233
+
cacheBlob,
240
234
listCached,
241
235
removeFromCache,
242
236
});
+2
src/components/input/ephemeral-cache/constants.js
···
1
1
+
export const SCHEME = "ephemeral+cache";
2
2
+
export const CACHE_KEY_PREFIX = "diffuse/components/configurator/input/cache/";
+58
src/components/input/ephemeral-cache/element.js
···
1
1
+
import { DiffuseElement } from "~/common/element.js";
2
2
+
import { SCHEME } from "./constants.js";
3
3
+
4
4
+
/**
5
5
+
* @import { InputActions, InputSchemeProvider } from "~/components/input/types.d.ts"
6
6
+
* @import { ProxiedActions } from "~/common/worker.d.ts"
7
7
+
* @import { Track } from "~/definitions/types.d.ts"
8
8
+
*/
9
9
+
10
10
+
////////////////////////////////////////////
11
11
+
// ELEMENT
12
12
+
////////////////////////////////////////////
13
13
+
14
14
+
/**
15
15
+
* @implements {ProxiedActions<InputActions>}
16
16
+
* @implements {InputSchemeProvider}
17
17
+
*/
18
18
+
class EphemeralCacheInput extends DiffuseElement {
19
19
+
static NAME = "diffuse/input/ephemeral-cache";
20
20
+
static WORKER_URL = "components/input/ephemeral-cache/worker.js";
21
21
+
22
22
+
SCHEME = SCHEME;
23
23
+
24
24
+
constructor() {
25
25
+
super();
26
26
+
27
27
+
/** @type {ProxiedActions<InputActions>} */
28
28
+
const proxy = this.workerProxy();
29
29
+
30
30
+
this.artwork = proxy.artwork;
31
31
+
this.consult = proxy.consult;
32
32
+
this.detach = proxy.detach;
33
33
+
this.groupConsult = proxy.groupConsult;
34
34
+
this.list = proxy.list;
35
35
+
this.resolve = proxy.resolve;
36
36
+
}
37
37
+
38
38
+
// 🛠️
39
39
+
40
40
+
/** @param {Track[]} tracks */
41
41
+
sources(tracks) {
42
42
+
return tracks.map((t) => ({
43
43
+
label: t.uri,
44
44
+
uri: t.uri,
45
45
+
}));
46
46
+
}
47
47
+
}
48
48
+
49
49
+
export default EphemeralCacheInput;
50
50
+
51
51
+
////////////////////////////////////////////
52
52
+
// REGISTER
53
53
+
////////////////////////////////////////////
54
54
+
55
55
+
export const CLASS = EphemeralCacheInput;
56
56
+
export const NAME = "di-ephemeral-cache";
57
57
+
58
58
+
customElements.define(NAME, CLASS);
+130
src/components/input/ephemeral-cache/worker.js
···
1
1
+
import * as IDB from "idb-keyval";
2
2
+
3
3
+
import { ostiary, rpc } from "~/common/worker.js";
4
4
+
import { CACHE_KEY_PREFIX, SCHEME } from "./constants.js";
5
5
+
6
6
+
/**
7
7
+
* @import { InputActions as Actions } from "~/components/input/types.d.ts"
8
8
+
* @import { Track } from "~/definitions/types.d.ts"
9
9
+
*/
10
10
+
11
11
+
////////////////////////////////////////////
12
12
+
// STATE
13
13
+
////////////////////////////////////////////
14
14
+
15
15
+
/** @type {Map<string, string>} */
16
16
+
const blobUrls = new Map();
17
17
+
18
18
+
////////////////////////////////////////////
19
19
+
// ACTIONS
20
20
+
////////////////////////////////////////////
21
21
+
22
22
+
/**
23
23
+
* @type {Actions['artwork']}
24
24
+
*/
25
25
+
export async function artwork(_uri) {
26
26
+
return null;
27
27
+
}
28
28
+
29
29
+
/**
30
30
+
* @type {Actions['consult']}
31
31
+
*/
32
32
+
export async function consult(uriOrScheme) {
33
33
+
if (!uriOrScheme.includes("://")) {
34
34
+
return { supported: true, consult: "undetermined" };
35
35
+
}
36
36
+
37
37
+
const cached = await IDB.get(CACHE_KEY_PREFIX + uriOrScheme);
38
38
+
return { supported: true, consult: cached !== undefined };
39
39
+
}
40
40
+
41
41
+
/**
42
42
+
* @type {Actions['detach']}
43
43
+
*/
44
44
+
export async function detach({ fileUriOrScheme, tracks }) {
45
45
+
if (!fileUriOrScheme.includes("://")) {
46
46
+
if (fileUriOrScheme === SCHEME) {
47
47
+
await removeBlobs(tracks.map((t) => t.uri));
48
48
+
return [];
49
49
+
}
50
50
+
return tracks;
51
51
+
}
52
52
+
53
53
+
const remaining = tracks.filter((t) => t.uri !== fileUriOrScheme);
54
54
+
await removeBlobs([fileUriOrScheme]);
55
55
+
return remaining;
56
56
+
}
57
57
+
58
58
+
/**
59
59
+
* @type {Actions['groupConsult']}
60
60
+
*/
61
61
+
export async function groupConsult(uris) {
62
62
+
const cached = await Promise.all(
63
63
+
uris.map((uri) => IDB.get(CACHE_KEY_PREFIX + uri)),
64
64
+
);
65
65
+
66
66
+
return {
67
67
+
[SCHEME]: {
68
68
+
available: true,
69
69
+
scheme: SCHEME,
70
70
+
uris: uris.filter((_, i) => cached[i] !== undefined),
71
71
+
},
72
72
+
};
73
73
+
}
74
74
+
75
75
+
/**
76
76
+
* @type {Actions['list']}
77
77
+
*/
78
78
+
export async function list(tracks) {
79
79
+
return tracks;
80
80
+
}
81
81
+
82
82
+
/**
83
83
+
* @type {Actions['resolve']}
84
84
+
*/
85
85
+
export async function resolve({ uri }) {
86
86
+
const blob = /** @type {Blob | undefined} */ (await IDB.get(CACHE_KEY_PREFIX + uri));
87
87
+
if (!blob) return undefined;
88
88
+
89
89
+
let blobUrl = blobUrls.get(uri);
90
90
+
91
91
+
if (!blobUrl) {
92
92
+
blobUrl = URL.createObjectURL(blob);
93
93
+
blobUrls.set(uri, blobUrl);
94
94
+
}
95
95
+
96
96
+
return { expiresAt: Infinity, url: blobUrl };
97
97
+
}
98
98
+
99
99
+
////////////////////////////////////////////
100
100
+
// 🛠️
101
101
+
////////////////////////////////////////////
102
102
+
103
103
+
/**
104
104
+
* @param {string[]} uris
105
105
+
*/
106
106
+
async function removeBlobs(uris) {
107
107
+
await Promise.all(uris.map(async (uri) => {
108
108
+
const blobUrl = blobUrls.get(uri);
109
109
+
if (blobUrl) {
110
110
+
URL.revokeObjectURL(blobUrl);
111
111
+
blobUrls.delete(uri);
112
112
+
}
113
113
+
await IDB.del(CACHE_KEY_PREFIX + uri);
114
114
+
}));
115
115
+
}
116
116
+
117
117
+
////////////////////////////////////////////
118
118
+
// ⚡️
119
119
+
////////////////////////////////////////////
120
120
+
121
121
+
ostiary((context) => {
122
122
+
rpc(context, {
123
123
+
artwork,
124
124
+
consult,
125
125
+
detach,
126
126
+
groupConsult,
127
127
+
list,
128
128
+
resolve,
129
129
+
});
130
130
+
});
+1
src/components/orchestrator/output/element.js
···
144
144
<dtor-default
145
145
id="do-output__output"
146
146
output-selector="#do-output__dtor-initial-contents"
147
147
+
group="${ifDefined(group)}"
147
148
></dtor-default>
148
149
`;
149
150
}
+1
src/components/orchestrator/process-tracks/element.js
···
148
148
untracked(() => this.process());
149
149
});
150
150
}
151
151
+
151
152
}
152
153
153
154
// WORKERS
+1
-1
src/components/orchestrator/sources/element.js
···
69
69
}
70
70
} else {
71
71
const dep = deps[scheme];
72
72
-
if (!dep) sources = [];
72
72
+
if (!dep) sources = tracks.map((t) => ({ label: t.uri, uri: t.uri }));
73
73
else sources = dep.sources(tracks);
74
74
}
75
75
+52
-10
src/components/transformer/output/refiner/default/element.js
···
22
22
23
23
const base = this.base();
24
24
25
25
-
// Ephemeral signals
26
26
-
const ephemeralPlaylistItems = signal(/** @type {any[]} */ ([]));
27
27
-
const ephemeralTracks = signal(/** @type {any[]} */ ([]));
28
28
-
29
25
// Restore stored ephemeral items
30
26
IDB.get(IDB_KEY_PLAYLISTS).then((items) => {
31
31
-
if (items) ephemeralPlaylistItems.set(items);
27
27
+
if (items) this.#ephemeralPlaylistItems.set(items);
32
28
});
33
29
34
30
IDB.get(IDB_KEY_TRACKS).then((items) => {
35
35
-
if (items) ephemeralTracks.set(items);
31
31
+
if (items) this.#ephemeralTracks.set(items);
36
32
});
37
33
38
34
/** @type {OutputManagerDeputy} */
···
52
48
if (col.state !== "loaded") return col;
53
49
return {
54
50
state: "loaded",
55
55
-
data: [...col.data, ...ephemeralPlaylistItems.get()],
51
51
+
data: [...col.data, ...this.#ephemeralPlaylistItems.get()],
56
52
};
57
53
}),
58
54
save: async (newPlaylists) => {
···
69
65
});
70
66
71
67
await IDB.set(IDB_KEY_PLAYLISTS, ephemeral);
72
72
-
ephemeralPlaylistItems.set(ephemeral);
68
68
+
this.#ephemeralPlaylistItems.set(ephemeral);
73
69
74
70
await base.playlistItems.save(filtered);
75
71
},
···
81
77
if (col.state !== "loaded") return col;
82
78
return {
83
79
state: "loaded",
84
84
-
data: [...col.data, ...ephemeralTracks.get()],
80
80
+
data: [...col.data, ...this.#ephemeralTracks.get()],
85
81
};
86
82
}),
87
83
save: async (newTracks) => {
···
98
94
});
99
95
100
96
await IDB.set(IDB_KEY_TRACKS, ephemeral);
101
101
-
ephemeralTracks.set(ephemeral);
97
97
+
this.#ephemeralTracks.set(ephemeral);
102
98
103
99
await base.tracks.save(filtered);
104
100
},
···
113
109
this.playlistItems = manager.playlistItems;
114
110
this.tracks = manager.tracks;
115
111
this.ready = manager.ready;
112
112
+
}
113
113
+
114
114
+
// SIGNALS
115
115
+
116
116
+
#ephemeralPlaylistItems = signal(/** @type {PlaylistItem[]} */ ([]));
117
117
+
#ephemeralTracks = signal(/** @type {Track[]} */ ([]));
118
118
+
119
119
+
// LIFECYCLE
120
120
+
121
121
+
/** @override */
122
122
+
connectedCallback() {
123
123
+
if (this.hasAttribute("group")) {
124
124
+
const actions = this.broadcast(IDB_KEY_TRACKS, {
125
125
+
getEphemeralPlaylistItems: {
126
126
+
strategy: "leaderOnly",
127
127
+
fn: this.#ephemeralPlaylistItems.get,
128
128
+
},
129
129
+
setEphemeralPlaylistItems: {
130
130
+
strategy: "replicate",
131
131
+
fn: this.#ephemeralPlaylistItems.set,
132
132
+
},
133
133
+
getEphemeralTracks: {
134
134
+
strategy: "leaderOnly",
135
135
+
fn: this.#ephemeralTracks.get,
136
136
+
},
137
137
+
setEphemeralTracks: {
138
138
+
strategy: "replicate",
139
139
+
fn: this.#ephemeralTracks.set,
140
140
+
},
141
141
+
});
142
142
+
143
143
+
if (actions) {
144
144
+
this.#ephemeralPlaylistItems.set = actions.setEphemeralPlaylistItems;
145
145
+
this.#ephemeralTracks.set = actions.setEphemeralTracks;
146
146
+
147
147
+
actions.getEphemeralPlaylistItems().then((items) => {
148
148
+
this.#ephemeralPlaylistItems.value = items;
149
149
+
});
150
150
+
151
151
+
actions.getEphemeralTracks().then((tracks) => {
152
152
+
this.#ephemeralTracks.value = tracks;
153
153
+
});
154
154
+
}
155
155
+
}
156
156
+
157
157
+
super.connectedCallback();
116
158
}
117
159
}
118
160
+23
src/facets/connect/common.css
···
92
92
flex-shrink: 0;
93
93
}
94
94
95
95
+
.dropzone {
96
96
+
align-items: center;
97
97
+
border: 2px dashed var(--wa-color-neutral-border-quiet);
98
98
+
border-radius: var(--wa-border-radius-m);
99
99
+
color: var(--wa-color-text-quiet);
100
100
+
display: flex;
101
101
+
flex-direction: column;
102
102
+
gap: var(--wa-space-xs);
103
103
+
font-size: var(--wa-font-size-s);
104
104
+
justify-content: center;
105
105
+
padding: var(--wa-space-l);
106
106
+
transition:
107
107
+
background-color 150ms,
108
108
+
border-color 150ms,
109
109
+
color 150ms;
110
110
+
111
111
+
&.dropzone--active {
112
112
+
background-color: var(--wa-color-surface-sunken);
113
113
+
border-color: var(--wa-color-brand-500);
114
114
+
color: var(--wa-color-text-normal);
115
115
+
}
116
116
+
}
117
117
+
95
118
[hidden] {
96
119
display: none !important;
97
120
}
+202
-7
src/facets/connect/local/index.inline.js
···
1
1
import * as TID from "@atcute/tid";
2
2
-
import { html } from "lit-html";
2
2
+
import { html, nothing } from "lit-html";
3
3
4
4
import * as Output from "~/common/output.js";
5
5
import { SCHEME } from "~/components/input/local/constants.js";
···
49
49
50
50
description: html`
51
51
<p>Add local directories or files as audio input.</p>
52
52
+
53
53
+
<label class="dropzone" id="local-dropzone">
54
54
+
<input id="local-dropzone-input" type="file" multiple hidden />
55
55
+
<wa-icon library="phosphor/bold" name="upload-simple"></wa-icon>
56
56
+
<span>Drop or click to select files</span>
57
57
+
</label>
58
58
+
59
59
+
<wa-divider id="local-ephemeral-divider" hidden></wa-divider>
60
60
+
<div id="local-ephemeral-row" class="button-row" hidden>
61
61
+
<wa-button
62
62
+
id="local-clear-ephemeral-btn"
63
63
+
variant="danger"
64
64
+
appearance="outlined"
65
65
+
size="small"
66
66
+
style="width: 100%"
67
67
+
>
68
68
+
<wa-icon slot="start" library="phosphor/bold" name="trash"></wa-icon>
69
69
+
Clear files
70
70
+
</wa-button>
71
71
+
</div>
72
72
+
52
73
${supported
53
74
? html`
75
75
+
<wa-divider></wa-divider>
76
76
+
54
77
<div class="button-row">
55
78
<wa-button id="local-add-dir-btn" variant="neutral" appearance="filled">
56
79
<wa-icon slot="start" library="phosphor/fill" name="folder-open"></wa-icon>
···
62
85
</wa-button>
63
86
</div>
64
87
`
65
65
-
: html`
66
66
-
<wa-callout variant="warning">
67
67
-
Your browser does not support the File System Access API. Use a Chromium-based
68
68
-
browser to add local files.
69
69
-
</wa-callout>
70
70
-
`}
88
88
+
: nothing}
71
89
`,
72
90
73
91
formFields: html`
···
84
102
.querySelector("#local-add-files-btn")
85
103
?.addEventListener("click", () => addFiles());
86
104
105
105
+
document
106
106
+
.querySelector("#local-clear-ephemeral-btn")
107
107
+
?.addEventListener("click", () => clearEphemeral());
108
108
+
109
109
+
const dropzone = document.querySelector("#local-dropzone");
110
110
+
const dropzoneInput =
111
111
+
/** @type {HTMLInputElement | null} */ (document.querySelector(
112
112
+
"#local-dropzone-input",
113
113
+
));
114
114
+
115
115
+
dropzoneInput?.addEventListener("change", async () => {
116
116
+
const files = Array.from(dropzoneInput.files ?? []);
117
117
+
dropzoneInput.value = "";
118
118
+
if (files.length === 0) return;
119
119
+
await cacheFiles(files);
120
120
+
});
121
121
+
122
122
+
dropzone?.addEventListener("dragover", (e) => {
123
123
+
e.preventDefault();
124
124
+
dropzone.classList.add("dropzone--active");
125
125
+
});
126
126
+
127
127
+
dropzone?.addEventListener("dragleave", () => {
128
128
+
dropzone.classList.remove("dropzone--active");
129
129
+
});
130
130
+
131
131
+
dropzone?.addEventListener("drop", async (e) => {
132
132
+
e.preventDefault();
133
133
+
dropzone.classList.remove("dropzone--active");
134
134
+
135
135
+
const dragEvent = /** @type {DragEvent} */ (e);
136
136
+
const items = Array.from(dragEvent.dataTransfer?.items ?? []);
137
137
+
const files = await collectFiles(items);
138
138
+
if (files.length === 0) return;
139
139
+
140
140
+
await cacheFiles(files);
141
141
+
});
142
142
+
87
143
////////////////////////////////////////////
88
144
// REACTIVE LIST
89
145
////////////////////////////////////////////
146
146
+
147
147
+
const ephemeralDivider =
148
148
+
/** @type {HTMLElement | null} */ (document.querySelector(
149
149
+
"#local-ephemeral-divider",
150
150
+
));
151
151
+
const ephemeralRow =
152
152
+
/** @type {HTMLElement | null} */ (document.querySelector(
153
153
+
"#local-ephemeral-row",
154
154
+
));
90
155
91
156
effect(() => {
92
157
const tracksCol = outputOrchestrator.tracks.collection();
93
158
const tracks = tracksCol.state === "loaded" ? tracksCol.data : [];
159
159
+
const hasEphemeral = tracks.some((t) =>
160
160
+
t.uri.startsWith("ephemeral+cache://")
161
161
+
);
162
162
+
163
163
+
if (ephemeralDivider) ephemeralDivider.hidden = !hasEphemeral;
164
164
+
if (ephemeralRow) ephemeralRow.hidden = !hasEphemeral;
165
165
+
94
166
const entries = localInput?.sources(tracks) ?? [];
95
167
96
168
setItems(
···
122
194
if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks);
123
195
} catch (err) {
124
196
setError(err instanceof Error ? err.message : "Failed to remove entry");
197
197
+
}
198
198
+
}
199
199
+
200
200
+
async function clearEphemeral() {
201
201
+
setError(null);
202
202
+
try {
203
203
+
const tracks = await Output.data(outputOrchestrator.tracks);
204
204
+
const ephemeralUris = tracks
205
205
+
.filter((t) => t.uri.startsWith("ephemeral+cache://"))
206
206
+
.map((t) => t.uri);
207
207
+
208
208
+
await inputConfigurator.removeFromCache(ephemeralUris);
209
209
+
await outputOrchestrator.tracks.save(
210
210
+
tracks.filter((t) => !t.uri.startsWith("ephemeral+cache://")),
211
211
+
);
212
212
+
} catch (err) {
213
213
+
setError(
214
214
+
err instanceof Error ? err.message : "Failed to clear cached files",
215
215
+
);
125
216
}
126
217
}
127
218
···
180
271
}
181
272
}
182
273
}
274
274
+
275
275
+
/**
276
276
+
* @param {File[]} files
277
277
+
*/
278
278
+
async function cacheFiles(files) {
279
279
+
setError(null);
280
280
+
try {
281
281
+
const uris = await Promise.all(
282
282
+
files.map((file) => inputConfigurator.cacheBlob(file)),
283
283
+
);
284
284
+
const now = new Date().toISOString();
285
285
+
const existingTracks = await Output.data(outputOrchestrator.tracks);
286
286
+
const existingUris = new Set(existingTracks.map((t) => t.uri));
287
287
+
const newUris = uris.filter((uri) => !existingUris.has(uri));
288
288
+
await outputOrchestrator.tracks.save([
289
289
+
...existingTracks,
290
290
+
...newUris.map((uri) => {
291
291
+
/** @type {Track} */
292
292
+
const track = {
293
293
+
$type: "sh.diffuse.output.track",
294
294
+
id: TID.now(),
295
295
+
createdAt: now,
296
296
+
updatedAt: now,
297
297
+
ephemeral: true,
298
298
+
uri,
299
299
+
};
300
300
+
return track;
301
301
+
}),
302
302
+
]);
303
303
+
} catch (err) {
304
304
+
setError(err instanceof Error ? err.message : "Failed to cache files");
305
305
+
}
306
306
+
}
307
307
+
308
308
+
/**
309
309
+
* @param {DataTransferItem[]} items
310
310
+
* @returns {Promise<File[]>}
311
311
+
*/
312
312
+
async function collectFiles(items) {
313
313
+
const files = /** @type {File[]} */ ([]);
314
314
+
315
315
+
await Promise.all(
316
316
+
items.map(async (item) => {
317
317
+
if (item.kind !== "file") return;
318
318
+
319
319
+
const entry = item.webkitGetAsEntry?.();
320
320
+
if (entry?.isDirectory) {
321
321
+
const dirFiles = await readDirectoryEntry(
322
322
+
/** @type {FileSystemDirectoryEntry} */ (entry),
323
323
+
);
324
324
+
files.push(...dirFiles);
325
325
+
} else {
326
326
+
const file = item.getAsFile();
327
327
+
if (file) files.push(file);
328
328
+
}
329
329
+
}),
330
330
+
);
331
331
+
332
332
+
return files;
333
333
+
}
334
334
+
335
335
+
/**
336
336
+
* @param {FileSystemDirectoryEntry} dir
337
337
+
* @returns {Promise<File[]>}
338
338
+
*/
339
339
+
async function readDirectoryEntry(dir) {
340
340
+
const reader = dir.createReader();
341
341
+
342
342
+
return new Promise((resolve, reject) => {
343
343
+
/** @type {File[]} */
344
344
+
const files = [];
345
345
+
346
346
+
const readBatch = () => {
347
347
+
reader.readEntries(async (entries) => {
348
348
+
if (entries.length === 0) {
349
349
+
resolve(files);
350
350
+
return;
351
351
+
}
352
352
+
353
353
+
await Promise.all(
354
354
+
entries.map(async (entry) => {
355
355
+
if (entry.isDirectory) {
356
356
+
const nested = await readDirectoryEntry(
357
357
+
/** @type {FileSystemDirectoryEntry} */ (entry),
358
358
+
);
359
359
+
files.push(...nested);
360
360
+
} else {
361
361
+
const file = await new Promise(
362
362
+
/** @param {(f: File) => void} res */
363
363
+
(res, rej) =>
364
364
+
/** @type {FileSystemFileEntry} */ (entry).file(res, rej),
365
365
+
);
366
366
+
files.push(file);
367
367
+
}
368
368
+
}),
369
369
+
);
370
370
+
371
371
+
readBatch();
372
372
+
}, reject);
373
373
+
};
374
374
+
375
375
+
readBatch();
376
376
+
});
377
377
+
}
+13
src/facets/data/input-bundle/index.inline.js
···
1
1
import foundation from "~/common/foundation.js";
2
2
import { effect } from "~/common/signal.js";
3
3
4
4
+
import { NAME as EPHEMERAL_CACHE_NAME } from "~/components/input/ephemeral-cache/element.js";
4
5
import { NAME as HTTPS_NAME } from "~/components/input/https/element.js";
5
6
import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js";
6
7
import { NAME as LOCAL_NAME } from "~/components/input/local/element.js";
···
19
20
const input = foundation.signals.configurator.input();
20
21
if (!input) return;
21
22
23
23
+
ephemeralCache(input);
22
24
https(input);
23
25
icecast(input);
24
26
local(input);
25
27
opensubsonic(input);
26
28
s3(input);
27
29
});
30
30
+
31
31
+
////////////////////////////////////////////
32
32
+
// EPHEMERAL CACHE
33
33
+
////////////////////////////////////////////
34
34
+
35
35
+
/**
36
36
+
* @param {InputConfigurator} input
37
37
+
*/
38
38
+
export function ephemeralCache(input) {
39
39
+
input.append(document.createElement(EPHEMERAL_CACHE_NAME));
40
40
+
}
28
41
29
42
////////////////////////////////////////////
30
43
// HTTPS
+79
tests/components/configurator/input/test.ts
···
155
155
expect(result.supported).toBe(true);
156
156
});
157
157
158
158
+
it("cacheBlob stores a blob and returns an ephemeral+cache:// URI", async () => {
159
159
+
const result = await testWeb(async () => {
160
160
+
const mod = await import(
161
161
+
"~/components/configurator/input/element.js"
162
162
+
);
163
163
+
const configurator = new mod.CLASS();
164
164
+
document.body.append(configurator);
165
165
+
166
166
+
const blob = new Blob(["audio data"], { type: "audio/mpeg" });
167
167
+
return configurator.cacheBlob(blob);
168
168
+
});
169
169
+
170
170
+
expect(result).toMatch(/^ephemeral\+cache:\/\//);
171
171
+
});
172
172
+
173
173
+
it("cacheBlob returns the same URI for identical blobs", async () => {
174
174
+
const [uri1, uri2] = await testWeb(async () => {
175
175
+
const mod = await import(
176
176
+
"~/components/configurator/input/element.js"
177
177
+
);
178
178
+
const configurator = new mod.CLASS();
179
179
+
document.body.append(configurator);
180
180
+
181
181
+
const uri1 = await configurator.cacheBlob(
182
182
+
new Blob(["same content"], { type: "audio/mpeg" }),
183
183
+
);
184
184
+
const uri2 = await configurator.cacheBlob(
185
185
+
new Blob(["same content"], { type: "audio/mpeg" }),
186
186
+
);
187
187
+
return [uri1, uri2];
188
188
+
});
189
189
+
190
190
+
expect(uri1).toBe(uri2);
191
191
+
});
192
192
+
193
193
+
it("cacheBlob returns different URIs for different blobs", async () => {
194
194
+
const [uri1, uri2] = await testWeb(async () => {
195
195
+
const mod = await import(
196
196
+
"~/components/configurator/input/element.js"
197
197
+
);
198
198
+
const configurator = new mod.CLASS();
199
199
+
document.body.append(configurator);
200
200
+
201
201
+
const uri1 = await configurator.cacheBlob(
202
202
+
new Blob(["content A"], { type: "audio/mpeg" }),
203
203
+
);
204
204
+
const uri2 = await configurator.cacheBlob(
205
205
+
new Blob(["content B"], { type: "audio/mpeg" }),
206
206
+
);
207
207
+
return [uri1, uri2];
208
208
+
});
209
209
+
210
210
+
expect(uri1).not.toBe(uri2);
211
211
+
});
212
212
+
213
213
+
it("removeFromCache deletes a previously cached blob", async () => {
214
214
+
const result = await testWeb(async () => {
215
215
+
const IDB = await import("idb-keyval");
216
216
+
const { CACHE_KEY_PREFIX } = await import(
217
217
+
"~/components/input/ephemeral-cache/constants.js"
218
218
+
);
219
219
+
const mod = await import(
220
220
+
"~/components/configurator/input/element.js"
221
221
+
);
222
222
+
const configurator = new mod.CLASS();
223
223
+
document.body.append(configurator);
224
224
+
225
225
+
const uri = await configurator.cacheBlob(
226
226
+
new Blob(["to be removed"], { type: "audio/mpeg" }),
227
227
+
);
228
228
+
await configurator.removeFromCache([uri]);
229
229
+
230
230
+
const stored = await IDB.get(CACHE_KEY_PREFIX + uri);
231
231
+
return stored ?? null;
232
232
+
});
233
233
+
234
234
+
expect(result).toBe(null);
235
235
+
});
236
236
+
158
237
it("resolve with an HTTPS URI returns a url via the HTTPS input", async () => {
159
238
const result = await testWeb(async () => {
160
239
const mod = await import(
+379
tests/components/input/ephemeral-cache/test.ts
···
1
1
+
import { describe, it } from "@std/testing/bdd";
2
2
+
import { expect } from "@std/expect";
3
3
+
4
4
+
import { testWeb } from "@tests/common/index.ts";
5
5
+
import type { Track } from "~/definitions/types.d.ts";
6
6
+
7
7
+
describe("components/input/ephemeral-cache", () => {
8
8
+
it("has correct SCHEME property", async () => {
9
9
+
const scheme = await testWeb(async () => {
10
10
+
const mod = await import(
11
11
+
"~/components/input/ephemeral-cache/element.js"
12
12
+
);
13
13
+
const input = new mod.CLASS();
14
14
+
document.body.append(input);
15
15
+
return input.SCHEME;
16
16
+
});
17
17
+
18
18
+
expect(scheme).toBe("ephemeral+cache");
19
19
+
});
20
20
+
21
21
+
it("artwork returns null", async () => {
22
22
+
const result = await testWeb(async () => {
23
23
+
const mod = await import(
24
24
+
"~/components/input/ephemeral-cache/element.js"
25
25
+
);
26
26
+
const input = new mod.CLASS();
27
27
+
document.body.append(input);
28
28
+
return await input.artwork("ephemeral+cache://bafktest");
29
29
+
});
30
30
+
31
31
+
expect(result).toBe(null);
32
32
+
});
33
33
+
34
34
+
it("consult returns undetermined for scheme only", async () => {
35
35
+
const result = await testWeb(async () => {
36
36
+
const mod = await import(
37
37
+
"~/components/input/ephemeral-cache/element.js"
38
38
+
);
39
39
+
const input = new mod.CLASS();
40
40
+
document.body.append(input);
41
41
+
return await input.consult("ephemeral+cache");
42
42
+
});
43
43
+
44
44
+
expect(result.supported).toBe(true);
45
45
+
if (result.supported) {
46
46
+
expect(result.consult).toBe("undetermined");
47
47
+
}
48
48
+
});
49
49
+
50
50
+
it("consult returns false for an uncached URI", async () => {
51
51
+
const result = await testWeb(async () => {
52
52
+
const mod = await import(
53
53
+
"~/components/input/ephemeral-cache/element.js"
54
54
+
);
55
55
+
const input = new mod.CLASS();
56
56
+
document.body.append(input);
57
57
+
return await input.consult("ephemeral+cache://bafknotcached");
58
58
+
});
59
59
+
60
60
+
expect(result.supported).toBe(true);
61
61
+
if (result.supported) {
62
62
+
expect(result.consult).toBe(false);
63
63
+
}
64
64
+
});
65
65
+
66
66
+
it("consult returns true for a cached URI", async () => {
67
67
+
const result = await testWeb(async () => {
68
68
+
const IDB = await import("idb-keyval");
69
69
+
const { CACHE_KEY_PREFIX } = await import(
70
70
+
"~/components/input/ephemeral-cache/constants.js"
71
71
+
);
72
72
+
const mod = await import(
73
73
+
"~/components/input/ephemeral-cache/element.js"
74
74
+
);
75
75
+
const input = new mod.CLASS();
76
76
+
document.body.append(input);
77
77
+
78
78
+
const uri = "ephemeral+cache://bafkcacheduri";
79
79
+
await IDB.set(
80
80
+
CACHE_KEY_PREFIX + uri,
81
81
+
new Blob(["audio"], { type: "audio/mpeg" }),
82
82
+
);
83
83
+
84
84
+
const result = await input.consult(uri);
85
85
+
await IDB.del(CACHE_KEY_PREFIX + uri);
86
86
+
return result;
87
87
+
});
88
88
+
89
89
+
expect(result.supported).toBe(true);
90
90
+
if (result.supported) {
91
91
+
expect(result.consult).toBe(true);
92
92
+
}
93
93
+
});
94
94
+
95
95
+
it("groupConsult returns only cached URIs as available", async () => {
96
96
+
const result = await testWeb(async () => {
97
97
+
const IDB = await import("idb-keyval");
98
98
+
const { CACHE_KEY_PREFIX } = await import(
99
99
+
"~/components/input/ephemeral-cache/constants.js"
100
100
+
);
101
101
+
const mod = await import(
102
102
+
"~/components/input/ephemeral-cache/element.js"
103
103
+
);
104
104
+
const input = new mod.CLASS();
105
105
+
document.body.append(input);
106
106
+
107
107
+
const cachedUri = "ephemeral+cache://bafkgroupcached";
108
108
+
const uncachedUri = "ephemeral+cache://bafkgroupuncached";
109
109
+
await IDB.set(
110
110
+
CACHE_KEY_PREFIX + cachedUri,
111
111
+
new Blob(["audio"], { type: "audio/mpeg" }),
112
112
+
);
113
113
+
114
114
+
const result = await input.groupConsult([cachedUri, uncachedUri]);
115
115
+
await IDB.del(CACHE_KEY_PREFIX + cachedUri);
116
116
+
return result;
117
117
+
});
118
118
+
119
119
+
expect(result["ephemeral+cache"]?.available).toBe(true);
120
120
+
expect(result["ephemeral+cache"]?.uris).toEqual([
121
121
+
"ephemeral+cache://bafkgroupcached",
122
122
+
]);
123
123
+
});
124
124
+
125
125
+
it("groupConsult with no cached URIs returns empty uris list", async () => {
126
126
+
const result = await testWeb(async () => {
127
127
+
const mod = await import(
128
128
+
"~/components/input/ephemeral-cache/element.js"
129
129
+
);
130
130
+
const input = new mod.CLASS();
131
131
+
document.body.append(input);
132
132
+
133
133
+
return await input.groupConsult([
134
134
+
"ephemeral+cache://bafkuncached1",
135
135
+
"ephemeral+cache://bafkuncached2",
136
136
+
]);
137
137
+
});
138
138
+
139
139
+
expect(result["ephemeral+cache"]?.available).toBe(true);
140
140
+
expect(result["ephemeral+cache"]?.uris).toEqual([]);
141
141
+
});
142
142
+
143
143
+
it("resolve returns undefined for an uncached URI", async () => {
144
144
+
const result = await testWeb(async () => {
145
145
+
const mod = await import(
146
146
+
"~/components/input/ephemeral-cache/element.js"
147
147
+
);
148
148
+
const input = new mod.CLASS();
149
149
+
document.body.append(input);
150
150
+
const r = await input.resolve({ uri: "ephemeral+cache://bafknotcached" });
151
151
+
return r ?? null;
152
152
+
});
153
153
+
154
154
+
expect(result).toBe(null);
155
155
+
});
156
156
+
157
157
+
it("resolve returns a blob URL with Infinity expiry for a cached URI", async () => {
158
158
+
const result = await testWeb(async () => {
159
159
+
const IDB = await import("idb-keyval");
160
160
+
const { CACHE_KEY_PREFIX } = await import(
161
161
+
"~/components/input/ephemeral-cache/constants.js"
162
162
+
);
163
163
+
const mod = await import(
164
164
+
"~/components/input/ephemeral-cache/element.js"
165
165
+
);
166
166
+
const input = new mod.CLASS();
167
167
+
document.body.append(input);
168
168
+
169
169
+
const uri = "ephemeral+cache://bafkresolvetest";
170
170
+
await IDB.set(
171
171
+
CACHE_KEY_PREFIX + uri,
172
172
+
new Blob(["audio"], { type: "audio/mpeg" }),
173
173
+
);
174
174
+
175
175
+
const resolved = await input.resolve({ uri });
176
176
+
await IDB.del(CACHE_KEY_PREFIX + uri);
177
177
+
if (!resolved || !("url" in resolved)) return null;
178
178
+
return { url: resolved.url, neverExpires: resolved.expiresAt === Infinity };
179
179
+
});
180
180
+
181
181
+
expect(result).not.toBe(null);
182
182
+
if (result) {
183
183
+
expect(result.url).toMatch(/^blob:/);
184
184
+
expect(result.neverExpires).toBe(true);
185
185
+
}
186
186
+
});
187
187
+
188
188
+
it("resolve returns the same blob URL on repeated calls", async () => {
189
189
+
const [url1, url2] = await testWeb(async () => {
190
190
+
const IDB = await import("idb-keyval");
191
191
+
const { CACHE_KEY_PREFIX } = await import(
192
192
+
"~/components/input/ephemeral-cache/constants.js"
193
193
+
);
194
194
+
const mod = await import(
195
195
+
"~/components/input/ephemeral-cache/element.js"
196
196
+
);
197
197
+
const input = new mod.CLASS();
198
198
+
document.body.append(input);
199
199
+
200
200
+
const uri = "ephemeral+cache://bafkresolvetwice";
201
201
+
await IDB.set(
202
202
+
CACHE_KEY_PREFIX + uri,
203
203
+
new Blob(["audio"], { type: "audio/mpeg" }),
204
204
+
);
205
205
+
206
206
+
const r1 = await input.resolve({ uri });
207
207
+
const r2 = await input.resolve({ uri });
208
208
+
await IDB.del(CACHE_KEY_PREFIX + uri);
209
209
+
return [
210
210
+
r1 && "url" in r1 ? r1.url : null,
211
211
+
r2 && "url" in r2 ? r2.url : null,
212
212
+
];
213
213
+
});
214
214
+
215
215
+
expect(url1).not.toBe(null);
216
216
+
expect(url1).toBe(url2);
217
217
+
});
218
218
+
219
219
+
it("list returns tracks unchanged", async () => {
220
220
+
const result = await testWeb(async () => {
221
221
+
const mod = await import(
222
222
+
"~/components/input/ephemeral-cache/element.js"
223
223
+
);
224
224
+
const input = new mod.CLASS();
225
225
+
document.body.append(input);
226
226
+
227
227
+
const tracks: Track[] = [
228
228
+
{
229
229
+
$type: "sh.diffuse.output.track",
230
230
+
id: "t1",
231
231
+
uri: "ephemeral+cache://bafktrack1",
232
232
+
},
233
233
+
{
234
234
+
$type: "sh.diffuse.output.track",
235
235
+
id: "t2",
236
236
+
uri: "ephemeral+cache://bafktrack2",
237
237
+
},
238
238
+
];
239
239
+
240
240
+
return await input.list(tracks);
241
241
+
});
242
242
+
243
243
+
expect(result.map((t) => t.id)).toEqual(["t1", "t2"]);
244
244
+
});
245
245
+
246
246
+
it("detaches all tracks when given the scheme", async () => {
247
247
+
const remaining = await testWeb(async () => {
248
248
+
const IDB = await import("idb-keyval");
249
249
+
const { CACHE_KEY_PREFIX } = await import(
250
250
+
"~/components/input/ephemeral-cache/constants.js"
251
251
+
);
252
252
+
const mod = await import(
253
253
+
"~/components/input/ephemeral-cache/element.js"
254
254
+
);
255
255
+
const input = new mod.CLASS();
256
256
+
document.body.append(input);
257
257
+
258
258
+
const tracks: Track[] = [
259
259
+
{
260
260
+
$type: "sh.diffuse.output.track",
261
261
+
id: "t1",
262
262
+
uri: "ephemeral+cache://bafkdetach1",
263
263
+
},
264
264
+
{
265
265
+
$type: "sh.diffuse.output.track",
266
266
+
id: "t2",
267
267
+
uri: "ephemeral+cache://bafkdetach2",
268
268
+
},
269
269
+
];
270
270
+
271
271
+
for (const t of tracks) {
272
272
+
await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"]));
273
273
+
}
274
274
+
275
275
+
return await input.detach({ fileUriOrScheme: "ephemeral+cache", tracks });
276
276
+
});
277
277
+
278
278
+
expect(remaining.length).toBe(0);
279
279
+
});
280
280
+
281
281
+
it("detaches a specific track when given a URI", async () => {
282
282
+
const remaining = await testWeb(async () => {
283
283
+
const IDB = await import("idb-keyval");
284
284
+
const { CACHE_KEY_PREFIX } = await import(
285
285
+
"~/components/input/ephemeral-cache/constants.js"
286
286
+
);
287
287
+
const mod = await import(
288
288
+
"~/components/input/ephemeral-cache/element.js"
289
289
+
);
290
290
+
const input = new mod.CLASS();
291
291
+
document.body.append(input);
292
292
+
293
293
+
const tracks: Track[] = [
294
294
+
{
295
295
+
$type: "sh.diffuse.output.track",
296
296
+
id: "t1",
297
297
+
uri: "ephemeral+cache://bafkremove",
298
298
+
},
299
299
+
{
300
300
+
$type: "sh.diffuse.output.track",
301
301
+
id: "t2",
302
302
+
uri: "ephemeral+cache://bafkkeep",
303
303
+
},
304
304
+
];
305
305
+
306
306
+
for (const t of tracks) {
307
307
+
await IDB.set(CACHE_KEY_PREFIX + t.uri, new Blob(["audio"]));
308
308
+
}
309
309
+
310
310
+
return await input.detach({
311
311
+
fileUriOrScheme: "ephemeral+cache://bafkremove",
312
312
+
tracks,
313
313
+
});
314
314
+
});
315
315
+
316
316
+
expect(remaining.length).toBe(1);
317
317
+
expect(remaining[0].id).toBe("t2");
318
318
+
});
319
319
+
320
320
+
it("detach with non-matching scheme returns tracks unchanged", async () => {
321
321
+
const remaining = await testWeb(async () => {
322
322
+
const mod = await import(
323
323
+
"~/components/input/ephemeral-cache/element.js"
324
324
+
);
325
325
+
const input = new mod.CLASS();
326
326
+
document.body.append(input);
327
327
+
328
328
+
const tracks: Track[] = [
329
329
+
{
330
330
+
$type: "sh.diffuse.output.track",
331
331
+
id: "t1",
332
332
+
uri: "ephemeral+cache://bafk1",
333
333
+
},
334
334
+
{
335
335
+
$type: "sh.diffuse.output.track",
336
336
+
id: "t2",
337
337
+
uri: "ephemeral+cache://bafk2",
338
338
+
},
339
339
+
];
340
340
+
341
341
+
return await input.detach({ fileUriOrScheme: "https", tracks });
342
342
+
});
343
343
+
344
344
+
expect(remaining.map((t) => t.id)).toEqual(["t1", "t2"]);
345
345
+
});
346
346
+
347
347
+
it("sources returns tracks mapped by URI as label", async () => {
348
348
+
const sources = await testWeb(async () => {
349
349
+
const mod = await import(
350
350
+
"~/components/input/ephemeral-cache/element.js"
351
351
+
);
352
352
+
const input = new mod.CLASS();
353
353
+
document.body.append(input);
354
354
+
355
355
+
const tracks: Track[] = [
356
356
+
{
357
357
+
$type: "sh.diffuse.output.track",
358
358
+
id: "t1",
359
359
+
uri: "ephemeral+cache://bafksrc1",
360
360
+
},
361
361
+
{
362
362
+
$type: "sh.diffuse.output.track",
363
363
+
id: "t2",
364
364
+
uri: "ephemeral+cache://bafksrc2",
365
365
+
tags: { title: "My Song" },
366
366
+
},
367
367
+
];
368
368
+
369
369
+
return input.sources(tracks);
370
370
+
});
371
371
+
372
372
+
expect(sources.length).toBe(2);
373
373
+
expect(sources[0].uri).toBe("ephemeral+cache://bafksrc1");
374
374
+
expect(sources[0].label).toBe("ephemeral+cache://bafksrc1");
375
375
+
// label is always the URI regardless of tags, to keep sources stable across processing
376
376
+
expect(sources[1].uri).toBe("ephemeral+cache://bafksrc2");
377
377
+
expect(sources[1].label).toBe("ephemeral+cache://bafksrc2");
378
378
+
});
379
379
+
});