@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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/davison.js +65 -0
- package/dist/assets/index-BXibJ7mX.js +165 -0
- package/dist/assets/index-DjSu-QAM.css +1 -0
- package/dist/index.html +13 -0
- package/dist-server/config.js +24 -0
- package/dist-server/index.js +88 -0
- package/dist-server/routes/api.js +259 -0
- package/dist-server/services/audio-gen.js +60 -0
- package/dist-server/services/music-discovery.js +73 -0
- package/dist-server/services/music-server.js +90 -0
- package/dist-server/services/playlist-store.js +84 -0
- package/dist-server/services/queue-manager.js +339 -0
- package/dist-server/services/sonos-controller.js +298 -0
- package/dist-server/services/webdav.js +381 -0
- package/package.json +61 -0
- package/patches/nephele.patch +26 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
const ALLOWED_UPDATES = new Set(['name', 'trackIds']);
|
|
6
|
+
let playlists = [];
|
|
7
|
+
function isValidPlaylistArray(data) {
|
|
8
|
+
if (!Array.isArray(data))
|
|
9
|
+
return false;
|
|
10
|
+
return data.every(item => item !== null &&
|
|
11
|
+
typeof item === 'object' &&
|
|
12
|
+
typeof item.id === 'string' &&
|
|
13
|
+
typeof item.name === 'string' &&
|
|
14
|
+
Array.isArray(item.trackIds));
|
|
15
|
+
}
|
|
16
|
+
export async function loadPlaylists() {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(config.playlistsFile)) {
|
|
19
|
+
await mkdir(config.dataDir, { recursive: true });
|
|
20
|
+
playlists = [];
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const data = await readFile(config.playlistsFile, 'utf-8');
|
|
24
|
+
const parsed = JSON.parse(data);
|
|
25
|
+
if (isValidPlaylistArray(parsed)) {
|
|
26
|
+
playlists = parsed;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.warn('Corrupted playlists.json, starting fresh');
|
|
30
|
+
playlists = [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.warn('Failed to load playlists:', err);
|
|
35
|
+
playlists = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function savePlaylists() {
|
|
39
|
+
await mkdir(config.dataDir, { recursive: true });
|
|
40
|
+
const tmp = config.playlistsFile + '.tmp';
|
|
41
|
+
await writeFile(tmp, JSON.stringify(playlists, null, 2), 'utf-8');
|
|
42
|
+
await rename(tmp, config.playlistsFile);
|
|
43
|
+
}
|
|
44
|
+
export function getPlaylists() {
|
|
45
|
+
return playlists.map(p => ({ ...p }));
|
|
46
|
+
}
|
|
47
|
+
export function getPlaylist(id) {
|
|
48
|
+
const p = playlists.find(p => p.id === id);
|
|
49
|
+
return p ? { ...p } : undefined;
|
|
50
|
+
}
|
|
51
|
+
export async function createPlaylist(name, trackIds = []) {
|
|
52
|
+
const playlist = {
|
|
53
|
+
id: randomUUID(),
|
|
54
|
+
name,
|
|
55
|
+
trackIds,
|
|
56
|
+
createdAt: new Date().toISOString(),
|
|
57
|
+
updatedAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
playlists.push(playlist);
|
|
60
|
+
await savePlaylists();
|
|
61
|
+
return { ...playlist };
|
|
62
|
+
}
|
|
63
|
+
export async function deletePlaylist(id) {
|
|
64
|
+
const idx = playlists.findIndex(p => p.id === id);
|
|
65
|
+
if (idx === -1)
|
|
66
|
+
return false;
|
|
67
|
+
playlists.splice(idx, 1);
|
|
68
|
+
await savePlaylists();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export async function updatePlaylist(id, updates) {
|
|
72
|
+
const playlist = playlists.find(p => p.id === id);
|
|
73
|
+
if (!playlist)
|
|
74
|
+
return null;
|
|
75
|
+
const safe = {};
|
|
76
|
+
for (const key of ALLOWED_UPDATES) {
|
|
77
|
+
if (key in updates) {
|
|
78
|
+
safe[key] = updates[key];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
Object.assign(playlist, safe, { updatedAt: new Date().toISOString() });
|
|
82
|
+
await savePlaylists();
|
|
83
|
+
return { ...playlist };
|
|
84
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { getTrackById, getMusicLibrary } from './music-discovery.js';
|
|
3
|
+
import { sonosController } from './sonos-controller.js';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
const VALID_LOOP_MODES = new Set(['none', 'one', 'all']);
|
|
6
|
+
function folderPath(relativePath) {
|
|
7
|
+
const idx = relativePath.lastIndexOf('/');
|
|
8
|
+
return idx >= 0 ? relativePath.slice(0, idx) : null;
|
|
9
|
+
}
|
|
10
|
+
class QueueManager extends EventEmitter {
|
|
11
|
+
state = {
|
|
12
|
+
queue: [],
|
|
13
|
+
history: [],
|
|
14
|
+
currentIndex: null,
|
|
15
|
+
loopMode: 'all',
|
|
16
|
+
autoPlay: true,
|
|
17
|
+
};
|
|
18
|
+
mutationQueue = Promise.resolve();
|
|
19
|
+
serialized(fn) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
this.mutationQueue = this.mutationQueue.then(() => fn().then(resolve, reject));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
getState() {
|
|
25
|
+
return { ...this.state, currentTrack: this.getCurrentTrack(), nextTrack: this.getNextTrack() };
|
|
26
|
+
}
|
|
27
|
+
getQueue() {
|
|
28
|
+
return [...this.state.queue];
|
|
29
|
+
}
|
|
30
|
+
getCurrentTrack() {
|
|
31
|
+
if (this.state.currentIndex === null)
|
|
32
|
+
return null;
|
|
33
|
+
return this.state.queue[this.state.currentIndex] ?? null;
|
|
34
|
+
}
|
|
35
|
+
getNextTrack() {
|
|
36
|
+
if (this.state.currentIndex === null)
|
|
37
|
+
return null;
|
|
38
|
+
const next = this.getNextIndex();
|
|
39
|
+
return next !== null ? this.state.queue[next] : null;
|
|
40
|
+
}
|
|
41
|
+
getNextIndex() {
|
|
42
|
+
const { queue, currentIndex, loopMode } = this.state;
|
|
43
|
+
if (queue.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
if (currentIndex === null)
|
|
46
|
+
return 0;
|
|
47
|
+
if (loopMode === 'one')
|
|
48
|
+
return currentIndex;
|
|
49
|
+
const next = currentIndex + 1;
|
|
50
|
+
if (next >= queue.length) {
|
|
51
|
+
return loopMode === 'all' ? 0 : null;
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
getPreviousIndex() {
|
|
56
|
+
const { queue, history } = this.state;
|
|
57
|
+
if (history.length > 0) {
|
|
58
|
+
const track = history[history.length - 1];
|
|
59
|
+
this.state.history = history.slice(0, -1);
|
|
60
|
+
const idx = queue.findIndex(t => t.id === track.id);
|
|
61
|
+
return idx >= 0 ? idx : this.state.currentIndex;
|
|
62
|
+
}
|
|
63
|
+
if (queue.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
if (this.state.currentIndex === null)
|
|
66
|
+
return 0;
|
|
67
|
+
const prev = this.state.currentIndex - 1;
|
|
68
|
+
return prev >= 0 ? prev : (this.state.loopMode === 'all' ? queue.length - 1 : 0);
|
|
69
|
+
}
|
|
70
|
+
async playTrack(index) {
|
|
71
|
+
const track = this.state.queue[index];
|
|
72
|
+
if (!track)
|
|
73
|
+
return;
|
|
74
|
+
this.state.currentIndex = index;
|
|
75
|
+
this.emit('track-change', track);
|
|
76
|
+
try {
|
|
77
|
+
const encodedPath = track.relativePath.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
78
|
+
const streamUrl = `http://${config.host}:${config.port}/music-files/${track.baseIdx}/${encodedPath}`;
|
|
79
|
+
await sonosController.playUri(streamUrl, track.title);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.error('Failed to play track:', track.id, track.title, err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async play() {
|
|
86
|
+
await this.serialized(async () => {
|
|
87
|
+
if (this.state.currentIndex === null && this.state.queue.length > 0) {
|
|
88
|
+
await this.playTrack(0);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
await sonosController.play();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async pause() {
|
|
96
|
+
await sonosController.pause();
|
|
97
|
+
}
|
|
98
|
+
async stop() {
|
|
99
|
+
await this.serialized(async () => {
|
|
100
|
+
await sonosController.stop();
|
|
101
|
+
this.state.currentIndex = null;
|
|
102
|
+
this.emit('state-change', this.getState());
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async next() {
|
|
106
|
+
await this.serialized(async () => {
|
|
107
|
+
const nextIdx = this.getNextIndex();
|
|
108
|
+
if (nextIdx === null)
|
|
109
|
+
return;
|
|
110
|
+
const current = this.getCurrentTrack();
|
|
111
|
+
if (current) {
|
|
112
|
+
this.state.history = [...this.state.history, current];
|
|
113
|
+
}
|
|
114
|
+
await this.playTrack(nextIdx);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async previous() {
|
|
118
|
+
await this.serialized(async () => {
|
|
119
|
+
const prevIdx = this.getPreviousIndex();
|
|
120
|
+
if (prevIdx === null)
|
|
121
|
+
return;
|
|
122
|
+
const current = this.getCurrentTrack();
|
|
123
|
+
if (current && this.state.history[this.state.history.length - 1]?.id !== current.id) {
|
|
124
|
+
this.state.history = [...this.state.history, current];
|
|
125
|
+
}
|
|
126
|
+
await this.playTrack(prevIdx);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
resolveTracks(trackIds) {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const result = [];
|
|
132
|
+
for (const id of trackIds) {
|
|
133
|
+
if (seen.has(id))
|
|
134
|
+
continue;
|
|
135
|
+
seen.add(id);
|
|
136
|
+
const track = getTrackById(id);
|
|
137
|
+
if (track)
|
|
138
|
+
result.push(track);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
async addToQueue(trackIds) {
|
|
143
|
+
await this.serialized(async () => {
|
|
144
|
+
const tracks = this.resolveTracks(trackIds);
|
|
145
|
+
if (tracks.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
// Deduplicate against existing queue
|
|
148
|
+
const existingIds = new Set(this.state.queue.map(t => t.id));
|
|
149
|
+
const newTracks = tracks.filter(t => !existingIds.has(t.id));
|
|
150
|
+
if (newTracks.length === 0)
|
|
151
|
+
return;
|
|
152
|
+
this.state.queue = [...this.state.queue, ...newTracks];
|
|
153
|
+
this.emit('queue-change', this.getQueue());
|
|
154
|
+
if (this.state.currentIndex === null && this.state.autoPlay) {
|
|
155
|
+
await this.playTrack(0);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async playNow(trackIds) {
|
|
160
|
+
await this.serialized(async () => {
|
|
161
|
+
const tracks = this.resolveTracks(trackIds);
|
|
162
|
+
if (tracks.length === 0)
|
|
163
|
+
return;
|
|
164
|
+
this.state.queue = tracks;
|
|
165
|
+
this.state.history = [];
|
|
166
|
+
this.state.currentIndex = null;
|
|
167
|
+
this.emit('queue-change', this.getQueue());
|
|
168
|
+
await this.playTrack(0);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async playNext(trackIds) {
|
|
172
|
+
await this.serialized(async () => {
|
|
173
|
+
const tracks = this.resolveTracks(trackIds);
|
|
174
|
+
if (tracks.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
// Deduplicate against existing queue
|
|
177
|
+
const existingIds = new Set(this.state.queue.map(t => t.id));
|
|
178
|
+
const newTracks = tracks.filter(t => !existingIds.has(t.id));
|
|
179
|
+
if (newTracks.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const insertAt = this.state.currentIndex !== null ? this.state.currentIndex + 1 : this.state.queue.length;
|
|
182
|
+
this.state.queue.splice(insertAt, 0, ...newTracks);
|
|
183
|
+
this.emit('queue-change', this.getQueue());
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async playFolderOrNow(trackId) {
|
|
187
|
+
await this.serialized(async () => {
|
|
188
|
+
const track = getTrackById(trackId);
|
|
189
|
+
if (!track)
|
|
190
|
+
return;
|
|
191
|
+
const existingIds = new Set(this.state.queue.map(t => t.id));
|
|
192
|
+
if (existingIds.has(track.id))
|
|
193
|
+
return;
|
|
194
|
+
const folder = folderPath(track.relativePath);
|
|
195
|
+
// Folder-fill: queue empty + track has a directory
|
|
196
|
+
if (this.state.queue.length === 0 && folder) {
|
|
197
|
+
const allTracks = getMusicLibrary();
|
|
198
|
+
const folderTracks = allTracks
|
|
199
|
+
.filter(t => t.baseIdx === track.baseIdx && folderPath(t.relativePath) === folder)
|
|
200
|
+
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
201
|
+
const startIdx = folderTracks.findIndex(t => t.id === trackId);
|
|
202
|
+
this.state.queue = [...folderTracks.slice(startIdx), ...folderTracks.slice(0, startIdx)];
|
|
203
|
+
this.state.history = [];
|
|
204
|
+
this.state.currentIndex = null;
|
|
205
|
+
this.emit('queue-change', this.getQueue());
|
|
206
|
+
await this.playTrack(0);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Insert next + play
|
|
210
|
+
const insertAt = this.state.currentIndex !== null
|
|
211
|
+
? this.state.currentIndex + 1
|
|
212
|
+
: this.state.queue.length;
|
|
213
|
+
this.state.queue.splice(insertAt, 0, track);
|
|
214
|
+
this.emit('queue-change', this.getQueue());
|
|
215
|
+
await this.playTrack(insertAt);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
removeFromQueue(index) {
|
|
219
|
+
if (index < 0 || index >= this.state.queue.length)
|
|
220
|
+
return;
|
|
221
|
+
if (!Number.isInteger(index))
|
|
222
|
+
return;
|
|
223
|
+
this.state.queue = this.state.queue.filter((_, i) => i !== index);
|
|
224
|
+
if (this.state.queue.length === 0) {
|
|
225
|
+
this.state.currentIndex = null;
|
|
226
|
+
this.state.history = [];
|
|
227
|
+
}
|
|
228
|
+
else if (this.state.currentIndex !== null) {
|
|
229
|
+
if (index < this.state.currentIndex) {
|
|
230
|
+
this.state.currentIndex--;
|
|
231
|
+
}
|
|
232
|
+
else if (index === this.state.currentIndex) {
|
|
233
|
+
this.state.currentIndex = Math.min(this.state.currentIndex, this.state.queue.length - 1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.emit('queue-change', this.getQueue());
|
|
237
|
+
}
|
|
238
|
+
clearQueue() {
|
|
239
|
+
this.state.queue = [];
|
|
240
|
+
this.state.history = [];
|
|
241
|
+
this.state.currentIndex = null;
|
|
242
|
+
this.emit('queue-change', this.getQueue());
|
|
243
|
+
}
|
|
244
|
+
reorderQueue(from, to) {
|
|
245
|
+
if (from < 0 || from >= this.state.queue.length)
|
|
246
|
+
return;
|
|
247
|
+
if (to < 0 || to >= this.state.queue.length)
|
|
248
|
+
return;
|
|
249
|
+
if (!Number.isInteger(from) || !Number.isInteger(to))
|
|
250
|
+
return;
|
|
251
|
+
const queue = [...this.state.queue];
|
|
252
|
+
const [moved] = queue.splice(from, 1);
|
|
253
|
+
queue.splice(to, 0, moved);
|
|
254
|
+
this.state.queue = queue;
|
|
255
|
+
this.emit('queue-change', this.getQueue());
|
|
256
|
+
}
|
|
257
|
+
setLoopMode(mode) {
|
|
258
|
+
if (!VALID_LOOP_MODES.has(mode)) {
|
|
259
|
+
console.warn('Invalid loop mode:', mode);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.state.loopMode = mode;
|
|
263
|
+
this.emit('loop-change', mode);
|
|
264
|
+
}
|
|
265
|
+
async setAutoPlay(auto) {
|
|
266
|
+
this.state.autoPlay = auto;
|
|
267
|
+
}
|
|
268
|
+
/** Re-send current track URI — used when device becomes available after queue was filled */
|
|
269
|
+
async resumePlayback() {
|
|
270
|
+
await this.serialized(async () => {
|
|
271
|
+
if (!sonosController.hasDevice())
|
|
272
|
+
return;
|
|
273
|
+
const track = this.getCurrentTrack();
|
|
274
|
+
if (!track)
|
|
275
|
+
return;
|
|
276
|
+
// Don't resume if Sonos is already playing something
|
|
277
|
+
const status = await sonosController.getStatus();
|
|
278
|
+
if (status && status.state !== 'STOPPED')
|
|
279
|
+
return;
|
|
280
|
+
await this.playTrack(this.state.currentIndex);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
async jumpTo(trackId) {
|
|
284
|
+
await this.serialized(async () => {
|
|
285
|
+
const idx = this.state.queue.findIndex(t => t.id === trackId);
|
|
286
|
+
if (idx === -1)
|
|
287
|
+
return;
|
|
288
|
+
await this.playTrack(idx);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
setQueue(tracks, startIndex = 0) {
|
|
292
|
+
this.state.queue = [...tracks];
|
|
293
|
+
this.state.history = [];
|
|
294
|
+
this.state.currentIndex = null;
|
|
295
|
+
this.emit('queue-change', this.getQueue());
|
|
296
|
+
if (tracks.length > 0) {
|
|
297
|
+
this.playTrack(startIndex).catch(err => {
|
|
298
|
+
console.error('Failed to start new queue:', err);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
export const queueManager = new QueueManager();
|
|
304
|
+
let pollTimer = null;
|
|
305
|
+
const POLL_INTERVAL = 2000;
|
|
306
|
+
const AUTO_ADVANCE_THRESHOLD = 2;
|
|
307
|
+
export function startAutoAdvancePolling() {
|
|
308
|
+
if (pollTimer)
|
|
309
|
+
return;
|
|
310
|
+
pollTimer = setInterval(async () => {
|
|
311
|
+
try {
|
|
312
|
+
const status = await sonosController.getStatus();
|
|
313
|
+
if (!status)
|
|
314
|
+
return;
|
|
315
|
+
const current = queueManager.getCurrentTrack();
|
|
316
|
+
if (!current || status.state === 'PLAYING')
|
|
317
|
+
return;
|
|
318
|
+
if (status.state === 'STOPPED' && status.track.duration > 0) {
|
|
319
|
+
const elapsed = status.track.position;
|
|
320
|
+
const remaining = status.track.duration - elapsed;
|
|
321
|
+
if (remaining < AUTO_ADVANCE_THRESHOLD) {
|
|
322
|
+
const nextId = queueManager.getNextTrack();
|
|
323
|
+
if (nextId) {
|
|
324
|
+
await queueManager.next();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
console.warn('Auto-advance poll error:', err);
|
|
331
|
+
}
|
|
332
|
+
}, POLL_INTERVAL);
|
|
333
|
+
}
|
|
334
|
+
export function stopAutoAdvancePolling() {
|
|
335
|
+
if (pollTimer) {
|
|
336
|
+
clearInterval(pollTimer);
|
|
337
|
+
pollTimer = null;
|
|
338
|
+
}
|
|
339
|
+
}
|