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
chore: https+json input plan
Steven Vandevelde
2 weeks ago
ed6ba99d
bc0e630f
+246
1 changed file
expand all
collapse all
unified
split
docs
plans
https-directory-listing.md
+246
docs/plans/https-directory-listing.md
···
1
1
+
# HTTPS directory listing (`https+json`)
2
2
+
3
3
+
## Context
4
4
+
5
5
+
Some HTTPS servers expose a directory listing API: appending `?ls` to a directory URL returns a JSON array describing its contents:
6
6
+
7
7
+
```json
8
8
+
[
9
9
+
{ "name": "my-directory", "type": "directory", "mtime": "2022-10-07T00:53:50Z" },
10
10
+
{ "name": "my_file.tar.gz", "type": "file", "mtime": "2022-09-27T22:44:34Z", "size": 332 }
11
11
+
]
12
12
+
```
13
13
+
14
14
+
Rather than bolting this onto the existing HTTPS input (which is designed for individual file URLs), this feature is implemented as a separate `https+json` input with its own URI scheme. Users add a server URL (e.g. `https+json://music.example.com`) and the input automatically discovers all audio files under it — recursively traversing subdirectories.
15
15
+
16
16
+
The existing HTTPS input is left untouched.
17
17
+
18
18
+
## Plan
19
19
+
20
20
+
### 1. Create `src/components/input/https-json/constants.js`
21
21
+
22
22
+
```js
23
23
+
export const SCHEME = "https+json";
24
24
+
```
25
25
+
26
26
+
### 2. Create `src/components/input/https-json/common.js`
27
27
+
28
28
+
Follows the same structure as `https/common.js`. Key additions are `buildURI` and `listDirectory`.
29
29
+
30
30
+
**`parseURI(uriString)`** — validates the `https+json:` scheme and returns components:
31
31
+
32
32
+
```js
33
33
+
export function parseURI(uriString) {
34
34
+
try {
35
35
+
const url = new URL(uriString);
36
36
+
if (url.protocol !== "https+json:") return undefined;
37
37
+
38
38
+
return {
39
39
+
// The real HTTPS URL (for fetch calls)
40
40
+
url: "https:" + uriString.slice("https+json:".length),
41
41
+
domain: url.hostname,
42
42
+
host: url.host,
43
43
+
path: url.pathname,
44
44
+
};
45
45
+
} catch {
46
46
+
return undefined;
47
47
+
}
48
48
+
}
49
49
+
```
50
50
+
51
51
+
**`buildURI(host, path)`** — constructs an `https+json://` URI:
52
52
+
53
53
+
```js
54
54
+
export function buildURI(host, path = "/") {
55
55
+
return `https+json://${host}${path}`;
56
56
+
}
57
57
+
```
58
58
+
59
59
+
**`listDirectory(httpsUrl)`** — fetches `url?ls`, parses the JSON listing, and recursively collects audio file URLs. Returns `https+json://` URIs (not plain `https://`):
60
60
+
61
61
+
```js
62
62
+
export async function listDirectory(httpsUrl) {
63
63
+
let entries;
64
64
+
65
65
+
try {
66
66
+
const response = await fetch(httpsUrl + "?ls");
67
67
+
if (!response.ok) return [];
68
68
+
entries = await response.json();
69
69
+
} catch {
70
70
+
return [];
71
71
+
}
72
72
+
73
73
+
if (!Array.isArray(entries)) return [];
74
74
+
75
75
+
const results = await Promise.all(
76
76
+
entries.map(async (entry) => {
77
77
+
if (entry.type === "file") {
78
78
+
if (!isAudioFile(entry.name)) return [];
79
79
+
const fileHttpsUrl = httpsUrl + entry.name;
80
80
+
// Return as https+json:// URI
81
81
+
return [fileHttpsUrl.replace(/^https:/, "https+json:")];
82
82
+
} else {
83
83
+
return listDirectory(httpsUrl + entry.name + "/");
84
84
+
}
85
85
+
}),
86
86
+
);
87
87
+
88
88
+
return results.flat(1);
89
89
+
}
90
90
+
```
91
91
+
92
92
+
Include `groupTracksByHost`, `groupUrisByHost`, `hostsFromTracks` — same implementations as in `https/common.js` but using `parseURI` from this module. Include `consultHostCached` — same implementation, caches per host.
93
93
+
94
94
+
### 3. Create `src/components/input/https-json/worker.js`
95
95
+
96
96
+
Five standard actions. Mirrors the S3 worker pattern for `list()`.
97
97
+
98
98
+
**`consult`** — same shape as HTTPS: scheme-only returns `undetermined`, full URI checks host reachability via `consultHostCached`.
99
99
+
100
100
+
**`detach`** — same shape as HTTPS: groups by host, removes the target host's group.
101
101
+
102
102
+
**`groupConsult`** — same shape as HTTPS: groups URIs by host, checks each host once.
103
103
+
104
104
+
**`list(cachedTracks)`** — the key new logic:
105
105
+
106
106
+
```js
107
107
+
export async function list(cachedTracks = []) {
108
108
+
// Build a URI → track cache for preserving metadata across relists.
109
109
+
/** @type {Record<string, Track>} */
110
110
+
const cacheByUri = {};
111
111
+
cachedTracks.forEach((t) => { cacheByUri[t.uri] = t; });
112
112
+
113
113
+
// Group by host; derive the scan root per host.
114
114
+
// If placeholder tracks exist for a host, use their path as the root.
115
115
+
// Otherwise, find the common path prefix of the audio file tracks.
116
116
+
const groups = groupTracksByHost(cachedTracks);
117
117
+
118
118
+
const promises = Object.values(groups).map(async ({ host, tracks }) => {
119
119
+
const root = scanRoot(tracks); // "/" or a shared directory prefix
120
120
+
const rootHttpsUrl = `https://${host}${root}`;
121
121
+
const now = new Date().toISOString();
122
122
+
123
123
+
const uris = await listDirectory(rootHttpsUrl);
124
124
+
125
125
+
let discovered = uris.map((uri) => {
126
126
+
const cached = cacheByUri[uri];
127
127
+
/** @type {Track} */
128
128
+
return {
129
129
+
$type: "sh.diffuse.output.track",
130
130
+
id: cached?.id ?? TID.now(),
131
131
+
createdAt: cached?.createdAt ?? now,
132
132
+
updatedAt: cached?.updatedAt ?? now,
133
133
+
stats: cached?.stats,
134
134
+
tags: cached?.tags,
135
135
+
uri,
136
136
+
};
137
137
+
});
138
138
+
139
139
+
if (!discovered.length) {
140
140
+
discovered = [{
141
141
+
$type: "sh.diffuse.output.track",
142
142
+
id: TID.now(),
143
143
+
createdAt: now,
144
144
+
updatedAt: now,
145
145
+
kind: "placeholder",
146
146
+
uri: buildURI(host, root),
147
147
+
}];
148
148
+
}
149
149
+
150
150
+
return discovered;
151
151
+
});
152
152
+
153
153
+
return (await Promise.all(promises)).flat(1);
154
154
+
}
155
155
+
```
156
156
+
157
157
+
`scanRoot(tracks)` is a small helper (defined in `worker.js` or `common.js`) that:
158
158
+
- Returns the path of the first placeholder-kind track if one exists
159
159
+
- Otherwise returns the longest common path prefix of all track paths, trimmed to the last `/`
160
160
+
161
161
+
**`resolve({ uri })`** — strips the scheme prefix and returns the `https://` URL with a one-year expiry (same as HTTPS input):
162
162
+
163
163
+
```js
164
164
+
export async function resolve({ uri }) {
165
165
+
const parsed = parseURI(uri);
166
166
+
if (!parsed) return undefined;
167
167
+
168
168
+
const expiresAt = Math.round(Date.now() / 1000) + 60 * 60 * 24 * 365;
169
169
+
return { url: parsed.url, expiresAt };
170
170
+
}
171
171
+
```
172
172
+
173
173
+
### 4. Create `src/components/input/https-json/element.js`
174
174
+
175
175
+
Minimal — same shape as `https/element.js`. `sources()` returns unique hosts from tracks.
176
176
+
177
177
+
```js
178
178
+
class HttpsJsonInput extends DiffuseElement {
179
179
+
static NAME = "diffuse/input/https-json";
180
180
+
static WORKER_URL = "components/input/https-json/worker.js";
181
181
+
182
182
+
SCHEME = SCHEME;
183
183
+
184
184
+
constructor() {
185
185
+
super();
186
186
+
this.proxy = this.workerProxy();
187
187
+
this.consult = this.proxy.consult;
188
188
+
this.detach = this.proxy.detach;
189
189
+
this.groupConsult = this.proxy.groupConsult;
190
190
+
this.list = this.proxy.list;
191
191
+
this.resolve = this.proxy.resolve;
192
192
+
}
193
193
+
194
194
+
sources(tracks) {
195
195
+
return Object.values(hostsFromTracks(tracks)).map((host) => ({
196
196
+
label: host,
197
197
+
uri: buildURI(host),
198
198
+
}));
199
199
+
}
200
200
+
}
201
201
+
202
202
+
export const CLASS = HttpsJsonInput;
203
203
+
export const NAME = "di-https-json";
204
204
+
205
205
+
customElements.define(NAME, CLASS);
206
206
+
```
207
207
+
208
208
+
### 5. Register in `src/components/orchestrator/input/element.js`
209
209
+
210
210
+
Add the import and the element to the rendered template:
211
211
+
212
212
+
```js
213
213
+
import "@components/input/https-json/element.js";
214
214
+
// ...
215
215
+
render({ html }) {
216
216
+
return html`
217
217
+
<dc-input>
218
218
+
<di-https></di-https>
219
219
+
<di-https-json></di-https-json>
220
220
+
<di-opensubsonic></di-opensubsonic>
221
221
+
<di-s3></di-s3>
222
222
+
</dc-input>
223
223
+
`;
224
224
+
}
225
225
+
```
226
226
+
227
227
+
## Files changed (5)
228
228
+
229
229
+
| File | Change | Difficulty |
230
230
+
|------|--------|------------|
231
231
+
| `src/components/input/https-json/constants.js` | New — define `SCHEME = "https+json"` | Trivial |
232
232
+
| `src/components/input/https-json/common.js` | New — `parseURI`, `buildURI`, `listDirectory`, grouping helpers, `consultHostCached` | Low |
233
233
+
| `src/components/input/https-json/worker.js` | New — all 5 actions; `list()` does recursive `?ls` fetching | Low-medium |
234
234
+
| `src/components/input/https-json/element.js` | New — `HttpsJsonInput` element, `di-https-json` | Trivial |
235
235
+
| `src/components/orchestrator/input/element.js` | Add import + `<di-https-json>` to template | Trivial |
236
236
+
237
237
+
**Overall difficulty: Low-medium.** All patterns are direct copies or adaptations of the existing HTTPS and S3 inputs. The only genuinely new logic is `listDirectory()` (recursive `?ls` fetch) and `scanRoot()` (derive listing root from cached tracks).
238
238
+
239
239
+
## Verification
240
240
+
241
241
+
- Add `https+json://music.example.com` as a source and trigger a library refresh
242
242
+
- Confirm audio files from the server's directory tree appear as tracks
243
243
+
- Confirm cached metadata (tags, stats) is preserved across relists
244
244
+
- Add a server with no audio files — confirm a placeholder track is produced
245
245
+
- Confirm `resolve()` returns a working `https://` URL for each track
246
246
+
- Confirm `di-https` (plain HTTPS) is unaffected