@amoshydra/davison 0.0.1

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.
@@ -0,0 +1,298 @@
1
+ import { Sonos } from 'sonos';
2
+ import { EventEmitter } from 'node:events';
3
+ import dgram from 'node:dgram';
4
+ import os from 'node:os';
5
+ import net from 'node:net';
6
+ import { getMusicLibrary } from './music-discovery.js';
7
+ const SSDP_MULTICAST_ADDR = '239.255.255.250';
8
+ const SSDP_PORT = 1900;
9
+ const SONOS_UPNP_PORT = 1400;
10
+ const SSDP_TIMEOUT = 5000;
11
+ const TCP_SCAN_PORT = 1400;
12
+ const TCP_SCAN_TIMEOUT = 500;
13
+ const TCP_SCAN_RESOLVE_DELAY = 600;
14
+ const MULTICAST_TTL = 4;
15
+ const IGNORED_IFACES = ['wg', 'tun', 'docker', 'br-'];
16
+ function findLanAddress() {
17
+ const ifaces = os.networkInterfaces();
18
+ for (const [name, addrs] of Object.entries(ifaces)) {
19
+ if (!addrs)
20
+ continue;
21
+ if (IGNORED_IFACES.some(p => name.startsWith(p)))
22
+ continue;
23
+ for (const addr of addrs) {
24
+ if (addr.family === 'IPv4' && !addr.internal) {
25
+ return addr.address;
26
+ }
27
+ }
28
+ }
29
+ return '0.0.0.0';
30
+ }
31
+ function getSubnets() {
32
+ const subnets = [];
33
+ const ifaces = os.networkInterfaces();
34
+ for (const [name, addrs] of Object.entries(ifaces)) {
35
+ if (!addrs)
36
+ continue;
37
+ if (IGNORED_IFACES.some(p => name.startsWith(p)) || name === 'lo')
38
+ continue;
39
+ for (const addr of addrs) {
40
+ if (addr.family === 'IPv4' && !addr.internal && addr.cidr) {
41
+ subnets.push(addr.cidr);
42
+ }
43
+ }
44
+ }
45
+ return subnets;
46
+ }
47
+ function normalizeSonosState(state) {
48
+ const upper = state.toUpperCase();
49
+ if (upper === 'PLAYING' || upper === 'TRANSITIONING')
50
+ return 'PLAYING';
51
+ if (upper === 'PAUSED_PLAYBACK' || upper === 'PAUSED')
52
+ return 'PAUSED_PLAYBACK';
53
+ if (upper === 'STOPPED')
54
+ return 'STOPPED';
55
+ console.warn('Unknown Sonos state:', state);
56
+ return 'STOPPED';
57
+ }
58
+ class SonosController extends EventEmitter {
59
+ devices = new Map();
60
+ deviceInfo = new Map();
61
+ selectedDevice = null;
62
+ discovering = false;
63
+ async discoverDevices(timeout = SSDP_TIMEOUT) {
64
+ if (this.discovering) {
65
+ console.debug('Discovery already in progress');
66
+ return this.getDevices();
67
+ }
68
+ this.discovering = true;
69
+ try {
70
+ const lanAddr = findLanAddress();
71
+ const foundIps = new Set();
72
+ // SSDP multicast discovery
73
+ try {
74
+ await this.ssdpDiscover(foundIps, lanAddr, timeout);
75
+ }
76
+ catch (err) {
77
+ console.warn('SSDP discovery failed:', err);
78
+ }
79
+ // Fallback: TCP port scan
80
+ if (foundIps.size === 0) {
81
+ try {
82
+ await this.tcpScan(foundIps);
83
+ }
84
+ catch (err) {
85
+ console.warn('TCP scan failed:', err);
86
+ }
87
+ }
88
+ // Build new device map, preserve existing selected device
89
+ const newDevices = new Map();
90
+ const newDeviceInfo = new Map();
91
+ for (const ip of foundIps) {
92
+ try {
93
+ const device = new Sonos(ip);
94
+ const desc = await device.deviceDescription();
95
+ const id = desc.UUID || device.host;
96
+ const name = desc.roomName || desc.displayName || device.host;
97
+ const group = name; // simplified; getAllGroups is slow per-device
98
+ newDevices.set(id, device);
99
+ newDeviceInfo.set(id, { id, name, ip: device.host, model: desc.modelName || '', group });
100
+ this.emit('device-found', newDeviceInfo.get(id));
101
+ }
102
+ catch (err) {
103
+ console.warn(`Failed to register device at ${ip}:`, err);
104
+ }
105
+ }
106
+ this.devices = newDevices;
107
+ this.deviceInfo = newDeviceInfo;
108
+ // If previously selected device is gone, clear selection
109
+ if (this.selectedDevice && !this.devices.has(this.selectedDevice)) {
110
+ console.warn('Previously selected device no longer available');
111
+ this.selectedDevice = null;
112
+ }
113
+ return this.getDevices();
114
+ }
115
+ finally {
116
+ this.discovering = false;
117
+ }
118
+ }
119
+ ssdpDiscover(foundIps, lanAddr, timeout) {
120
+ return new Promise((resolve) => {
121
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
122
+ socket.on('message', (msg, rinfo) => {
123
+ if (msg.toString().includes('Sonos')) {
124
+ foundIps.add(rinfo.address);
125
+ }
126
+ });
127
+ socket.on('error', (err) => {
128
+ console.warn('SSDP socket error:', err);
129
+ });
130
+ try {
131
+ socket.bind(0, lanAddr, () => {
132
+ socket.setMulticastTTL(MULTICAST_TTL);
133
+ socket.setMulticastInterface(lanAddr);
134
+ socket.setBroadcast(true);
135
+ try {
136
+ socket.addMembership(SSDP_MULTICAST_ADDR, lanAddr);
137
+ }
138
+ catch (err) {
139
+ console.warn('Failed to join SSDP multicast group:', err);
140
+ }
141
+ const search = Buffer.from('M-SEARCH * HTTP/1.1\r\n' +
142
+ `HOST: ${SSDP_MULTICAST_ADDR}:${SSDP_PORT}\r\n` +
143
+ 'MAN: "ssdp:discover"\r\n' +
144
+ 'MX: 3\r\n' +
145
+ 'ST: urn:schemas-upnp-org:device:ZonePlayer:1\r\n' +
146
+ '\r\n');
147
+ socket.send(search, 0, search.length, SSDP_PORT, SSDP_MULTICAST_ADDR);
148
+ socket.send(search, 0, search.length, SSDP_PORT, '255.255.255.255');
149
+ });
150
+ }
151
+ catch (err) {
152
+ console.warn('Failed to bind SSDP socket:', err);
153
+ }
154
+ setTimeout(() => {
155
+ try {
156
+ socket.close();
157
+ }
158
+ catch { /* best effort */ }
159
+ resolve();
160
+ }, timeout);
161
+ });
162
+ }
163
+ tcpScan(foundIps) {
164
+ const subnets = getSubnets();
165
+ const scanPromises = [];
166
+ for (const cidr of subnets) {
167
+ const [base, prefixStr] = cidr.split('/');
168
+ const prefix = parseInt(prefixStr, 10);
169
+ if (prefix >= 24) {
170
+ const parts = base.split('.');
171
+ const networkPrefix = parts.slice(0, 3).join('.');
172
+ for (let i = 1; i <= 254; i++) {
173
+ const ip = `${networkPrefix}.${i}`;
174
+ scanPromises.push(this.tryConnect(ip, foundIps));
175
+ }
176
+ }
177
+ }
178
+ return Promise.all(scanPromises).then(() => { });
179
+ }
180
+ tryConnect(ip, foundIps) {
181
+ return new Promise((resolve) => {
182
+ const s = new net.Socket();
183
+ let resolved = false;
184
+ const done = () => {
185
+ if (!resolved) {
186
+ resolved = true;
187
+ s.destroy();
188
+ resolve();
189
+ }
190
+ };
191
+ s.setTimeout(TCP_SCAN_TIMEOUT);
192
+ s.on('connect', () => {
193
+ foundIps.add(ip);
194
+ done();
195
+ });
196
+ s.on('error', done);
197
+ s.on('timeout', done);
198
+ s.connect(TCP_SCAN_PORT, ip);
199
+ setTimeout(done, TCP_SCAN_RESOLVE_DELAY);
200
+ });
201
+ }
202
+ getDevices() {
203
+ return Array.from(this.deviceInfo.values());
204
+ }
205
+ selectDevice(id) {
206
+ if (!this.devices.has(id))
207
+ return false;
208
+ this.selectedDevice = id;
209
+ return true;
210
+ }
211
+ hasDevice() {
212
+ return this.selectedDevice !== null;
213
+ }
214
+ getDevice() {
215
+ if (!this.selectedDevice)
216
+ return undefined;
217
+ return this.devices.get(this.selectedDevice);
218
+ }
219
+ async getStatus() {
220
+ const device = this.getDevice();
221
+ if (!device)
222
+ return null;
223
+ try {
224
+ const [trackInfo, transportInfo, volume, muted] = await Promise.all([
225
+ device.currentTrack(),
226
+ device.getCurrentState(),
227
+ device.getVolume(),
228
+ device.getMuted(),
229
+ ]);
230
+ const sonosTitle = trackInfo?.title || '';
231
+ const sonosArtist = trackInfo?.artist || '';
232
+ const library = getMusicLibrary();
233
+ const matched = library.find(t => t.title === sonosTitle && t.artist === sonosArtist);
234
+ return {
235
+ state: normalizeSonosState(transportInfo),
236
+ track: {
237
+ trackId: matched?.id,
238
+ title: sonosTitle || 'Unknown',
239
+ artist: sonosArtist || 'Unknown',
240
+ album: trackInfo?.album || 'Unknown',
241
+ albumArt: trackInfo?.albumArtURI || '',
242
+ duration: trackInfo?.duration || 0,
243
+ position: trackInfo?.position || 0,
244
+ },
245
+ volume,
246
+ muted,
247
+ };
248
+ }
249
+ catch (err) {
250
+ console.warn('Failed to get Sonos status:', err);
251
+ return null;
252
+ }
253
+ }
254
+ async play() {
255
+ await this.getDevice()?.play();
256
+ }
257
+ async pause() {
258
+ await this.getDevice()?.pause();
259
+ }
260
+ async stop() {
261
+ await this.getDevice()?.stop();
262
+ }
263
+ async next() {
264
+ await this.getDevice()?.next();
265
+ }
266
+ async previous() {
267
+ await this.getDevice()?.previous();
268
+ }
269
+ async setVolume(volume) {
270
+ await this.getDevice()?.setVolume(volume);
271
+ }
272
+ async getVolume() {
273
+ return (await this.getDevice()?.getVolume()) ?? 0;
274
+ }
275
+ async playUri(uri, title) {
276
+ const device = this.getDevice();
277
+ if (!device) {
278
+ throw new Error('No device selected');
279
+ }
280
+ const trackTitle = title || uri.split('/').pop() || 'Music';
281
+ const escapedTitle = trackTitle.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
282
+ const metadata = `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="A:TRACKS" restricted="true"><dc:title>${escapedTitle}</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>`;
283
+ try {
284
+ await device.setAVTransportURI({ uri, metadata });
285
+ }
286
+ catch (err) {
287
+ console.error(`Failed to play URI on ${device.host}:`, err);
288
+ throw err;
289
+ }
290
+ }
291
+ async queueUri(uri) {
292
+ await this.getDevice()?.queue(uri);
293
+ }
294
+ async flushQueue() {
295
+ await this.getDevice()?.flush();
296
+ }
297
+ }
298
+ export const sonosController = new SonosController();
@@ -0,0 +1,381 @@
1
+ import createServer from 'nephele';
2
+ import { ResourceNotFoundError, BadGatewayError, ForbiddenError } from 'nephele/dist/Errors/index.js';
3
+ import { createReadStream } from 'node:fs';
4
+ import { stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { getMusicLibrary } from './music-discovery.js';
7
+ import os from 'node:os';
8
+ import mime from 'mime';
9
+ function buildDirs(tracks) {
10
+ const roots = [];
11
+ for (const track of tracks) {
12
+ const parts = track.relativePath.split('/');
13
+ const fileName = parts.pop();
14
+ // Sanitize baseName: replace / with _ for filesystem-safe names
15
+ const baseName = track.baseName.replace(/\//g, '_');
16
+ const dirParts = baseName ? [baseName, ...parts] : parts;
17
+ let level = roots;
18
+ for (const segment of dirParts) {
19
+ let dir = level.find(d => d.name === segment);
20
+ if (!dir) {
21
+ dir = { name: segment, tracks: [], dirs: [] };
22
+ level.push(dir);
23
+ }
24
+ level = dir.dirs;
25
+ }
26
+ level.push({ name: fileName, tracks: [{ filePath: track.filePath, title: track.title }], dirs: [] });
27
+ }
28
+ function wrapRootTracks(node) {
29
+ const trackLeaves = node.dirs.filter(d => d.dirs.length === 0 && d.tracks.length <= 1);
30
+ const subdirs = node.dirs.filter(d => d.dirs.length > 0 || d.tracks.length > 1);
31
+ if (trackLeaves.length > 0 && subdirs.length > 0) {
32
+ node.dirs = [...subdirs, { name: '<root>', tracks: trackLeaves.flatMap(d => d.tracks), dirs: [] }];
33
+ }
34
+ for (const sub of node.dirs)
35
+ wrapRootTracks(sub);
36
+ node.dirs.sort((a, b) => {
37
+ const af = a.dirs.length > 0 || a.tracks.length > 1 || a.name === '<root>';
38
+ const bf = b.dirs.length > 0 || b.tracks.length > 1 || b.name === '<root>';
39
+ if (af && !bf)
40
+ return -1;
41
+ if (!af && bf)
42
+ return 1;
43
+ if (a.name === '<root>')
44
+ return 1;
45
+ if (b.name === '<root>')
46
+ return -1;
47
+ return a.name.localeCompare(b.name);
48
+ });
49
+ }
50
+ for (const r of roots)
51
+ wrapRootTracks(r);
52
+ roots.sort((a, b) => {
53
+ const af = a.dirs.length > 0 || a.tracks.length > 1 || a.name === '<root>';
54
+ const bf = b.dirs.length > 0 || b.tracks.length > 1 || b.name === '<root>';
55
+ if (af && !bf)
56
+ return -1;
57
+ if (!af && bf)
58
+ return 1;
59
+ if (a.name === '<root>')
60
+ return 1;
61
+ if (b.name === '<root>')
62
+ return -1;
63
+ return a.name.localeCompare(b.name);
64
+ });
65
+ return roots;
66
+ }
67
+ // ─── Virtual adapter ───
68
+ function findNode(pathParts, nodes) {
69
+ if (pathParts.length === 0)
70
+ return { node: nodes.length > 0 ? { name: '', tracks: [], dirs: nodes } : null, trackIndex: null, realPath: null };
71
+ const name = pathParts[0];
72
+ const rest = pathParts.slice(1);
73
+ for (const n of nodes) {
74
+ if (n.name === name) {
75
+ // Leaf track node (single file, no subdirs)
76
+ if (n.dirs.length === 0 && n.tracks.length === 1) {
77
+ return { node: null, trackIndex: null, realPath: n.tracks[0].filePath };
78
+ }
79
+ // Multi-track leaf node (e.g. <root> group, several loose files)
80
+ if (rest.length === 0 && n.tracks.length > 0) {
81
+ return { node: n, trackIndex: null, realPath: null };
82
+ }
83
+ if (rest.length === 0)
84
+ return { node: n, trackIndex: null, realPath: null };
85
+ if (n.dirs.length > 0)
86
+ return findNode(rest, n.dirs);
87
+ if (n.tracks.length > 0) {
88
+ for (let i = 0; i < n.tracks.length; i++) {
89
+ if (rest[0] === path.basename(n.tracks[i].filePath)) {
90
+ return { node: n, trackIndex: i, realPath: n.tracks[i].filePath };
91
+ }
92
+ }
93
+ }
94
+ return { node: null, trackIndex: null, realPath: null };
95
+ }
96
+ }
97
+ return { node: null, trackIndex: null, realPath: null };
98
+ }
99
+ async function getRealSize(filePath) {
100
+ try {
101
+ return (await stat(filePath)).size;
102
+ }
103
+ catch {
104
+ return 0;
105
+ }
106
+ }
107
+ async function getRealMtime(filePath) {
108
+ try {
109
+ return (await stat(filePath)).mtime;
110
+ }
111
+ catch {
112
+ return new Date();
113
+ }
114
+ }
115
+ // Simple read-only in-memory resource
116
+ function createVirtualResource(adapter, baseUrl, nodePath, node, realPath, isCollection) {
117
+ let cachedSize;
118
+ let cachedMtime;
119
+ async function ensureStats() {
120
+ if (realPath && cachedSize === undefined) {
121
+ const s = await stat(realPath);
122
+ cachedSize = s.size;
123
+ cachedMtime = s.mtime;
124
+ }
125
+ }
126
+ return {
127
+ adapter,
128
+ baseUrl,
129
+ path: nodePath,
130
+ collection: isCollection,
131
+ get absolutePath() { return realPath || nodePath; },
132
+ async getCanonicalPath() {
133
+ const segments = nodePath.split('/').filter(Boolean);
134
+ const p = segments.length > 0 ? '/' + segments.join(path.sep) : '/';
135
+ return isCollection ? p.replace(/\/?$/, '/') : p;
136
+ },
137
+ async getCanonicalUrl() {
138
+ const segments = nodePath.split('/').filter(Boolean);
139
+ const relPath = segments.length > 0 ? segments.join('/') + (isCollection ? '/' : '') : '';
140
+ return new URL(relPath, baseUrl);
141
+ },
142
+ async isCollection() { return isCollection; },
143
+ async exists() { return node !== null || realPath !== null; },
144
+ async getSize() {
145
+ if (realPath) {
146
+ await ensureStats();
147
+ return cachedSize || 0;
148
+ }
149
+ return 0;
150
+ },
151
+ async getLength() { return this.getSize(); },
152
+ async getModificationTime() {
153
+ if (realPath) {
154
+ await ensureStats();
155
+ return cachedMtime || new Date();
156
+ }
157
+ return new Date();
158
+ },
159
+ async getCreationTime() {
160
+ if (realPath) {
161
+ await ensureStats();
162
+ return cachedMtime || new Date();
163
+ }
164
+ return new Date();
165
+ },
166
+ async getEtag() {
167
+ const { createHash } = await import('node:crypto');
168
+ return createHash('md5').update(nodePath).digest('hex');
169
+ },
170
+ async getContentType() {
171
+ if (realPath)
172
+ return mime.getType(realPath) || 'application/octet-stream';
173
+ return 'httpd/unix-directory';
174
+ },
175
+ async getMediaType() { return this.getContentType(); },
176
+ async getStream(range) {
177
+ if (!realPath) {
178
+ const { Readable } = await import('node:stream');
179
+ return Readable.from([]);
180
+ }
181
+ if (range) {
182
+ return createReadStream(realPath, { start: range.start, end: range.end });
183
+ }
184
+ return createReadStream(realPath);
185
+ },
186
+ async getInternalMembers() {
187
+ if (!isCollection || !node)
188
+ return [];
189
+ const members = [];
190
+ // Subdirectories and track leaf nodes (sorted by buildDirs)
191
+ for (const dir of node.dirs) {
192
+ // Leaf track nodes (in dirs array from buildDirs) are files, not directories
193
+ if (dir.tracks.length === 1 && dir.dirs.length === 0) {
194
+ const filePath = dir.tracks[0].filePath;
195
+ const fileName = path.basename(filePath);
196
+ members.push(createVirtualResource(adapter, baseUrl, `${nodePath}/${fileName}`, null, filePath, false));
197
+ }
198
+ else {
199
+ members.push(createVirtualResource(adapter, baseUrl, `${nodePath}/${dir.name}/`, dir, null, true));
200
+ }
201
+ }
202
+ // Tracks at this level
203
+ for (let i = 0; i < node.tracks.length; i++) {
204
+ const fileName = path.basename(node.tracks[i].filePath);
205
+ members.push(createVirtualResource(adapter, baseUrl, `${nodePath}/${fileName}`, null, node.tracks[i].filePath, false));
206
+ }
207
+ return members;
208
+ },
209
+ async readMetadataFile() { return {}; },
210
+ async writeMetadataFile() { },
211
+ async delete() { throw new ForbiddenError('Forbidden'); },
212
+ async setProperty() { },
213
+ async removeProperty() { },
214
+ getProperties() {
215
+ const resolve = async (name) => {
216
+ switch (name) {
217
+ case 'getcontentlength':
218
+ return String(isCollection ? 0 : (realPath ? await getRealSize(realPath) : 0));
219
+ case 'getlastmodified': {
220
+ const d = await getRealMtime(realPath || '');
221
+ return d.toUTCString();
222
+ }
223
+ case 'creationdate': {
224
+ const d = await getRealMtime(realPath || '');
225
+ return d.toISOString();
226
+ }
227
+ case 'getcontenttype':
228
+ if (realPath)
229
+ return mime.getType(realPath) || 'application/octet-stream';
230
+ return 'httpd/unix-directory';
231
+ case 'getetag': {
232
+ const { createHash } = await import('node:crypto');
233
+ return createHash('md5').update(nodePath).digest('hex');
234
+ }
235
+ case 'resourcetype':
236
+ return isCollection ? { collection: {} } : undefined;
237
+ case 'displayname':
238
+ return nodePath.split('/').filter(Boolean).pop() || '/';
239
+ case 'supportedlock':
240
+ return undefined;
241
+ default:
242
+ return undefined;
243
+ }
244
+ };
245
+ // Standard live property names
246
+ const liveNames = [
247
+ 'creationdate', 'getcontentlength', 'getcontenttype',
248
+ 'getetag', 'getlastmodified', 'resourcetype', 'supportedlock',
249
+ 'displayname',
250
+ ];
251
+ return {
252
+ get: resolve,
253
+ getByUser: async (name) => resolve(name),
254
+ getAllByUser: async () => {
255
+ const out = {};
256
+ for (const name of liveNames) {
257
+ const v = await resolve(name);
258
+ if (v !== undefined)
259
+ out[name] = v;
260
+ }
261
+ return out;
262
+ },
263
+ listByUser: async () => liveNames,
264
+ listLiveByUser: async () => liveNames,
265
+ listDeadByUser: async () => [],
266
+ getAll: async () => {
267
+ const out = {};
268
+ for (const name of liveNames) {
269
+ const v = await resolve(name);
270
+ if (v !== undefined)
271
+ out[name] = v;
272
+ }
273
+ return out;
274
+ },
275
+ list: async () => liveNames,
276
+ listLive: async () => liveNames,
277
+ listDead: async () => [],
278
+ set: async () => { },
279
+ setByUser: async () => { },
280
+ remove: async () => { },
281
+ removeByUser: async () => { },
282
+ runInstructions: async () => undefined,
283
+ runInstructionsByUser: async () => undefined,
284
+ };
285
+ },
286
+ getLocks() { return []; },
287
+ getLocksByUser() { return []; },
288
+ addLock() { },
289
+ removeLock() { },
290
+ removeLocksByUser() { },
291
+ cleanLocks() { },
292
+ };
293
+ }
294
+ // ─── Nephele Adapter ───
295
+ class VirtualAdapter {
296
+ tree;
297
+ rootNode;
298
+ constructor(tree) {
299
+ this.tree = tree;
300
+ this.rootNode = { name: '', tracks: [], dirs: tree };
301
+ }
302
+ urlToRelativePath(url, baseUrl) {
303
+ const urlPath = decodeURIComponent(url.pathname).replace(/\/?$/, '/');
304
+ const basePath = decodeURIComponent(baseUrl.pathname).replace(/\/?$/, '/');
305
+ if (!urlPath.startsWith(basePath))
306
+ return null;
307
+ const rel = urlPath.substring(basePath.length).replace(/\/$/, '');
308
+ return '/' + rel;
309
+ }
310
+ urlToAbsolutePath(url, baseUrl) {
311
+ const rel = this.urlToRelativePath(url, baseUrl);
312
+ if (rel == null)
313
+ return null;
314
+ return rel;
315
+ }
316
+ async getUid() { return os.userInfo().uid; }
317
+ async getGid() { return os.userInfo().gid; }
318
+ async getGids() { return [os.userInfo().gid]; }
319
+ async getComplianceClasses() { return []; }
320
+ async getAllowedMethods() { return []; }
321
+ async getOptionsResponseCacheControl() { return 'max-age=604800'; }
322
+ async isAuthorized() { return true; }
323
+ getMethod(method) {
324
+ // Standard HTTP methods are handled by Nephele's built-in routes (GET, HEAD,
325
+ // PUT, DELETE, COPY, MOVE, MKCOL, LOCK, UNLOCK, OPTIONS, PROPFIND, PROPPATCH).
326
+ // This fallback is only for unrecognized methods.
327
+ const err = new Error('Method not supported: ' + method);
328
+ err.statusCode = 405;
329
+ throw err;
330
+ }
331
+ async getResource(url, baseUrl) {
332
+ const relPath = this.urlToRelativePath(url, baseUrl);
333
+ if (relPath == null)
334
+ throw new BadGatewayError('Bad Gateway');
335
+ const parts = relPath.split('/').filter(Boolean);
336
+ if (parts.length === 0) {
337
+ // Root node
338
+ return createVirtualResource(this, baseUrl, '/', this.rootNode, null, true);
339
+ }
340
+ const { node, trackIndex, realPath } = findNode(parts, this.tree);
341
+ if (!node && !realPath)
342
+ throw new ResourceNotFoundError('Resource not found');
343
+ const isCollection = !realPath;
344
+ const nodePath = '/' + parts.join('/');
345
+ if (realPath) {
346
+ return createVirtualResource(this, baseUrl, nodePath, null, realPath, false);
347
+ }
348
+ if (node && node.tracks.length === 1 && node.dirs.length === 0) {
349
+ return createVirtualResource(this, baseUrl, nodePath, null, node.tracks[0].filePath, false);
350
+ }
351
+ // Handle multi-track nodes: find track by filename in URL
352
+ if (node && node.tracks.length > 1) {
353
+ const fileName = parts[parts.length - 1];
354
+ const matched = node.tracks.find(t => path.basename(t.filePath) === fileName);
355
+ if (matched) {
356
+ return createVirtualResource(this, baseUrl, nodePath, null, matched.filePath, false);
357
+ }
358
+ }
359
+ return createVirtualResource(this, baseUrl, nodePath + '/', node, null, true);
360
+ }
361
+ async newResource() { throw new ForbiddenError('Forbidden'); }
362
+ async newCollection() { throw new ForbiddenError('Forbidden'); }
363
+ }
364
+ // ─── Server setup ───
365
+ let _webdavApp = null;
366
+ export function initWebdav() {
367
+ if (_webdavApp)
368
+ return _webdavApp;
369
+ const tracks = getMusicLibrary();
370
+ const tree = buildDirs(tracks);
371
+ const adapter = new VirtualAdapter(tree);
372
+ class NoAuth {
373
+ async authenticate() { return { username: 'sonos' }; }
374
+ async cleanAuthentication() { }
375
+ }
376
+ _webdavApp = createServer({
377
+ adapter: adapter,
378
+ authenticator: new NoAuth(),
379
+ });
380
+ return _webdavApp;
381
+ }