@ciphore/radiocli 0.2.3 → 0.2.5
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/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
package/dist/storage/store.js
CHANGED
|
@@ -1,43 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { platformPaths } from '../platform/paths.js';
|
|
2
|
+
import { assertStorageWritable } from '../platform/storage.js';
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
4
|
import { homedir } from 'node:os';
|
|
3
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
6
|
import { randomUUID } from 'node:crypto';
|
|
5
7
|
import { z } from 'zod';
|
|
6
8
|
import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
|
|
7
9
|
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
|
|
8
|
-
import { backupBadFile } from '../providers/cache.js';
|
|
9
10
|
import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
name: z.string(),
|
|
15
|
-
country: z.string().optional(),
|
|
16
|
-
countryCode: z.string().optional(),
|
|
17
|
-
state: z.string().optional(),
|
|
18
|
-
city: z.string().optional(),
|
|
19
|
-
language: z.string().optional(),
|
|
20
|
-
languageCodes: z.array(z.string()).optional(),
|
|
21
|
-
tags: z.array(z.string()),
|
|
22
|
-
codec: z.string().optional(),
|
|
23
|
-
bitrate: z.number().optional(),
|
|
24
|
-
homepage: z.string().optional(),
|
|
25
|
-
favicon: z.string().optional(),
|
|
26
|
-
streamUrl: z.string().optional(),
|
|
27
|
-
clickCount: z.number().optional(),
|
|
28
|
-
votes: z.number().optional(),
|
|
29
|
-
latitude: z.number().optional(),
|
|
30
|
-
longitude: z.number().optional(),
|
|
31
|
-
distanceKm: z.number().optional(),
|
|
32
|
-
hls: z.boolean().optional(),
|
|
33
|
-
lastCheckedOk: z.boolean().optional()
|
|
34
|
-
});
|
|
11
|
+
import { migrateStationIdentity } from './station-identity.js';
|
|
12
|
+
import { stationSchema } from './station-schema.js';
|
|
13
|
+
import { acquireFileLock } from './file-lock.js';
|
|
14
|
+
import { replaceFileSync } from './replace-file.js';
|
|
35
15
|
const defaultMediaKeys = {
|
|
36
16
|
previous: [],
|
|
37
17
|
playPause: [],
|
|
38
18
|
next: []
|
|
39
19
|
};
|
|
40
20
|
const MAX_ALARMS = 500;
|
|
21
|
+
// Keep the shared library readable by pre-scene releases whose background MCP
|
|
22
|
+
// workers quarantine an entire library when they see an unfamiliar enum value.
|
|
23
|
+
// Crossfade is the last style understood by those releases. New styles live in
|
|
24
|
+
// a separate preference file that old writers never read or overwrite.
|
|
25
|
+
const sharedLibraryReceiverStyles = new Set(receiverStyleNames.slice(0, receiverStyleNames.indexOf('crossfade') + 1));
|
|
26
|
+
const receiverPreferenceSchema = z.object({
|
|
27
|
+
version: z.literal(1),
|
|
28
|
+
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
|
|
29
|
+
libraryReceiverStyle: z.literal('ultracode')
|
|
30
|
+
});
|
|
41
31
|
const settingsSchema = z.object({
|
|
42
32
|
theme: z.enum(themeNames).default('green'),
|
|
43
33
|
receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
|
|
@@ -48,6 +38,7 @@ const settingsSchema = z.object({
|
|
|
48
38
|
shareDirectoryVotes: z.boolean().default(true),
|
|
49
39
|
preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
|
|
50
40
|
preferredAirPlayDevice: z.string().min(1).optional(),
|
|
41
|
+
linuxAirPlayEnabled: z.boolean().optional(),
|
|
51
42
|
tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
|
|
52
43
|
skipBrokenStreams: z.boolean().default(true),
|
|
53
44
|
mediaKeys: z
|
|
@@ -63,6 +54,7 @@ const settingsSchema = z.object({
|
|
|
63
54
|
reduceMotion: z.boolean().default(false),
|
|
64
55
|
mouseSupport: z.boolean().default(true),
|
|
65
56
|
automaticUpdateChecks: z.boolean().default(true),
|
|
57
|
+
desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
|
|
66
58
|
agentControl: z.object({
|
|
67
59
|
enabled: z.boolean().default(false),
|
|
68
60
|
openUiOnPlay: z.boolean().default(true),
|
|
@@ -166,6 +158,10 @@ const librarySchema = z.object({
|
|
|
166
158
|
}))
|
|
167
159
|
.default([]),
|
|
168
160
|
favorites: z.array(stationSchema).default([]),
|
|
161
|
+
presets: z.array(z.object({
|
|
162
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/),
|
|
163
|
+
station: stationSchema
|
|
164
|
+
})).max(200).default([]),
|
|
169
165
|
imported: z.array(stationSchema).default([]),
|
|
170
166
|
trackHistory: z
|
|
171
167
|
.array(z.object({
|
|
@@ -234,16 +230,70 @@ export class JsonLibraryStore {
|
|
|
234
230
|
state;
|
|
235
231
|
idGenerator;
|
|
236
232
|
now;
|
|
237
|
-
|
|
233
|
+
revision = '';
|
|
234
|
+
constructor(filePath = defaultLibraryPath(), options = {}) {
|
|
238
235
|
this.filePath = filePath;
|
|
239
236
|
this.idGenerator = options.idGenerator ?? randomUUID;
|
|
240
237
|
this.now = options.now ?? (() => new Date());
|
|
238
|
+
this.revision = this.diskRevision();
|
|
241
239
|
this.state = this.read();
|
|
240
|
+
// Normalize libraries saved by an earlier preview before any legacy
|
|
241
|
+
// background reader encounters its new enum value again.
|
|
242
|
+
if (!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferencePath()))
|
|
243
|
+
this.commit(this.state);
|
|
242
244
|
}
|
|
243
245
|
snapshot() {
|
|
246
|
+
this.refresh();
|
|
247
|
+
return this.cachedSnapshot();
|
|
248
|
+
}
|
|
249
|
+
cachedSnapshot() {
|
|
244
250
|
return structuredClone(this.state);
|
|
245
251
|
}
|
|
252
|
+
/** Refresh atomically replaced files without recovering a live listening session. */
|
|
253
|
+
refresh() {
|
|
254
|
+
const revision = this.diskRevision();
|
|
255
|
+
if (revision === this.revision)
|
|
256
|
+
return false;
|
|
257
|
+
const next = this.readForCurrentSession();
|
|
258
|
+
this.state = next;
|
|
259
|
+
this.revision = revision;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
settings() {
|
|
263
|
+
this.refresh();
|
|
264
|
+
return structuredClone(this.state.settings);
|
|
265
|
+
}
|
|
266
|
+
diskRevision() {
|
|
267
|
+
return [this.filePath, this.receiverPreferencePath()].map(file => {
|
|
268
|
+
try {
|
|
269
|
+
const info = statSync(file, { bigint: true });
|
|
270
|
+
return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code === 'ENOENT')
|
|
274
|
+
return 'missing';
|
|
275
|
+
throw storageError('read the RadioCLI library', error);
|
|
276
|
+
}
|
|
277
|
+
}).join('|');
|
|
278
|
+
}
|
|
279
|
+
savePreset(name, station) {
|
|
280
|
+
this.refresh();
|
|
281
|
+
const key = normalizePresetName(name);
|
|
282
|
+
const presets = this.state.presets ?? [];
|
|
283
|
+
if (presets.length >= 200 && !presets.some(preset => preset.name === key))
|
|
284
|
+
throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove <name>.');
|
|
285
|
+
this.commit({ ...this.state, presets: [{ name: key, station }, ...presets.filter(preset => preset.name !== key)] });
|
|
286
|
+
}
|
|
287
|
+
removePreset(name) {
|
|
288
|
+
this.refresh();
|
|
289
|
+
const key = normalizePresetName(name);
|
|
290
|
+
const presets = this.state.presets ?? [];
|
|
291
|
+
if (!presets.some(preset => preset.name === key))
|
|
292
|
+
throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`);
|
|
293
|
+
this.commit({ ...this.state, presets: presets.filter(preset => preset.name !== key) });
|
|
294
|
+
}
|
|
246
295
|
exportBackup(requestedPath) {
|
|
296
|
+
this.refresh();
|
|
247
297
|
const targetPath = requestedPath?.trim()
|
|
248
298
|
? resolveUserPath(requestedPath)
|
|
249
299
|
: resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
|
|
@@ -283,7 +333,9 @@ export class JsonLibraryStore {
|
|
|
283
333
|
? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
|
|
284
334
|
: undefined;
|
|
285
335
|
if (safetyBackupPath) {
|
|
286
|
-
|
|
336
|
+
// Another TUI or background player may have saved since this owner
|
|
337
|
+
// last refreshed. Preserve its latest data while holding the lock.
|
|
338
|
+
writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read()));
|
|
287
339
|
}
|
|
288
340
|
this.write(importedState);
|
|
289
341
|
this.state = importedState;
|
|
@@ -294,6 +346,7 @@ export class JsonLibraryStore {
|
|
|
294
346
|
}
|
|
295
347
|
}
|
|
296
348
|
updateSettings(settings) {
|
|
349
|
+
this.refresh();
|
|
297
350
|
return this.commit({
|
|
298
351
|
...this.state,
|
|
299
352
|
settings: {
|
|
@@ -304,12 +357,13 @@ export class JsonLibraryStore {
|
|
|
304
357
|
});
|
|
305
358
|
}
|
|
306
359
|
updateCheckState(updateCheck) {
|
|
360
|
+
this.refresh();
|
|
307
361
|
return this.commit({ ...this.state, updateCheck });
|
|
308
362
|
}
|
|
309
363
|
addAlarm(input) {
|
|
310
364
|
const release = acquireStoreLock(this.filePath);
|
|
311
365
|
try {
|
|
312
|
-
const latestState = this.
|
|
366
|
+
const latestState = this.readForCurrentSession();
|
|
313
367
|
if (latestState.alarms.length >= MAX_ALARMS) {
|
|
314
368
|
throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
|
|
315
369
|
}
|
|
@@ -351,7 +405,7 @@ export class JsonLibraryStore {
|
|
|
351
405
|
removeAlarm(id) {
|
|
352
406
|
const release = acquireStoreLock(this.filePath);
|
|
353
407
|
try {
|
|
354
|
-
const latestState = this.
|
|
408
|
+
const latestState = this.readForCurrentSession();
|
|
355
409
|
if (!latestState.alarms.some(alarm => alarm.id === id)) {
|
|
356
410
|
this.state = latestState;
|
|
357
411
|
return false;
|
|
@@ -373,10 +427,12 @@ export class JsonLibraryStore {
|
|
|
373
427
|
}));
|
|
374
428
|
}
|
|
375
429
|
getAlarm(id) {
|
|
430
|
+
this.refresh();
|
|
376
431
|
const alarm = this.state.alarms.find(item => item.id === id);
|
|
377
432
|
return alarm ? structuredClone(alarm) : undefined;
|
|
378
433
|
}
|
|
379
434
|
listAlarms() {
|
|
435
|
+
this.refresh();
|
|
380
436
|
return structuredClone(this.state.alarms);
|
|
381
437
|
}
|
|
382
438
|
recordAlarmOutcome(id, outcome, options = {}) {
|
|
@@ -408,6 +464,7 @@ export class JsonLibraryStore {
|
|
|
408
464
|
}));
|
|
409
465
|
}
|
|
410
466
|
addRecent(station) {
|
|
467
|
+
this.refresh();
|
|
411
468
|
const key = stationKey(station);
|
|
412
469
|
const recent = [
|
|
413
470
|
{ station, playedAt: new Date().toISOString() },
|
|
@@ -418,9 +475,10 @@ export class JsonLibraryStore {
|
|
|
418
475
|
// Persist the ICY track titles a station announces so "what was that song?"
|
|
419
476
|
// is answerable later. Consecutive duplicates on the same station are skipped.
|
|
420
477
|
recordTrack(station, title) {
|
|
478
|
+
this.refresh();
|
|
421
479
|
const cleaned = title.trim();
|
|
422
480
|
if (!cleaned) {
|
|
423
|
-
return this.
|
|
481
|
+
return this.cachedSnapshot();
|
|
424
482
|
}
|
|
425
483
|
const key = stationKey(station);
|
|
426
484
|
const last = this.state.trackHistory[0];
|
|
@@ -437,6 +495,7 @@ export class JsonLibraryStore {
|
|
|
437
495
|
}
|
|
438
496
|
// Most-recent-first search queries for up-arrow recall in the search box.
|
|
439
497
|
addSearch(query) {
|
|
498
|
+
this.refresh();
|
|
440
499
|
const cleaned = query.trim();
|
|
441
500
|
if (!cleaned) {
|
|
442
501
|
return this.snapshot();
|
|
@@ -445,6 +504,7 @@ export class JsonLibraryStore {
|
|
|
445
504
|
return this.commit({ ...this.state, searchHistory });
|
|
446
505
|
}
|
|
447
506
|
startListeningSession(station, startedAt = new Date()) {
|
|
507
|
+
this.refresh();
|
|
448
508
|
const finishedState = recoverActiveListeningSessionInState(this.state);
|
|
449
509
|
const session = {
|
|
450
510
|
id: `${startedAt.toISOString()}-${stationKey(station)}`,
|
|
@@ -461,6 +521,7 @@ export class JsonLibraryStore {
|
|
|
461
521
|
});
|
|
462
522
|
}
|
|
463
523
|
checkpointActiveListeningSession(activeAt = new Date()) {
|
|
524
|
+
this.refresh();
|
|
464
525
|
const active = this.state.activity.sessions[0];
|
|
465
526
|
if (!active || active.endedAt) {
|
|
466
527
|
return this.snapshot();
|
|
@@ -481,6 +542,7 @@ export class JsonLibraryStore {
|
|
|
481
542
|
});
|
|
482
543
|
}
|
|
483
544
|
finishActiveListeningSession(endedAt = new Date()) {
|
|
545
|
+
this.refresh();
|
|
484
546
|
const nextState = finishActiveListeningSessionInState(this.state, endedAt);
|
|
485
547
|
if (nextState === this.state) {
|
|
486
548
|
return this.snapshot();
|
|
@@ -488,6 +550,7 @@ export class JsonLibraryStore {
|
|
|
488
550
|
return this.commit(nextState);
|
|
489
551
|
}
|
|
490
552
|
toggleFavorite(station) {
|
|
553
|
+
this.refresh();
|
|
491
554
|
const key = stationKey(station);
|
|
492
555
|
const exists = this.state.favorites.some(item => stationKey(item) === key);
|
|
493
556
|
const favorites = exists
|
|
@@ -496,26 +559,36 @@ export class JsonLibraryStore {
|
|
|
496
559
|
return this.commit({ ...this.state, favorites });
|
|
497
560
|
}
|
|
498
561
|
addImported(stations) {
|
|
499
|
-
|
|
562
|
+
this.refresh();
|
|
563
|
+
const incoming = [];
|
|
564
|
+
const incomingKeys = new Set();
|
|
500
565
|
for (const station of stations) {
|
|
501
|
-
|
|
566
|
+
const key = stationKey(station);
|
|
567
|
+
if (incomingKeys.has(key))
|
|
568
|
+
continue;
|
|
569
|
+
incomingKeys.add(key);
|
|
570
|
+
incoming.push(station);
|
|
502
571
|
}
|
|
503
572
|
return this.commit({
|
|
504
573
|
...this.state,
|
|
505
|
-
imported: [
|
|
574
|
+
imported: [
|
|
575
|
+
...incoming,
|
|
576
|
+
...this.state.imported.filter(station => !incomingKeys.has(stationKey(station)))
|
|
577
|
+
].slice(0, 1000)
|
|
506
578
|
});
|
|
507
579
|
}
|
|
508
580
|
isFavorite(station) {
|
|
509
581
|
if (!station) {
|
|
510
582
|
return false;
|
|
511
583
|
}
|
|
584
|
+
this.refresh();
|
|
512
585
|
const key = stationKey(station);
|
|
513
586
|
return this.state.favorites.some(item => stationKey(item) === key);
|
|
514
587
|
}
|
|
515
588
|
mutateAlarm(id, change) {
|
|
516
589
|
const release = acquireStoreLock(this.filePath);
|
|
517
590
|
try {
|
|
518
|
-
const latestState = this.
|
|
591
|
+
const latestState = this.readForCurrentSession();
|
|
519
592
|
const existing = latestState.alarms.find(alarm => alarm.id === id);
|
|
520
593
|
if (!existing) {
|
|
521
594
|
this.state = latestState;
|
|
@@ -535,37 +608,130 @@ export class JsonLibraryStore {
|
|
|
535
608
|
}
|
|
536
609
|
}
|
|
537
610
|
read() {
|
|
538
|
-
|
|
539
|
-
|
|
611
|
+
let contents;
|
|
612
|
+
try {
|
|
613
|
+
contents = readFileSync(this.filePath, 'utf8');
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (error.code === 'ENOENT')
|
|
617
|
+
return defaultState();
|
|
618
|
+
throw storageError('read the RadioCLI library', error);
|
|
540
619
|
}
|
|
620
|
+
let state;
|
|
541
621
|
try {
|
|
542
|
-
|
|
622
|
+
state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
|
|
543
623
|
}
|
|
544
|
-
catch {
|
|
545
|
-
|
|
624
|
+
catch (error) {
|
|
625
|
+
if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
|
|
626
|
+
issue.path[0] === 'settings' && issue.path[1] === 'receiverStyle')) {
|
|
627
|
+
// Another running build may have saved a newer receiver. Its library
|
|
628
|
+
// is intact: never quarantine it or replace it with empty defaults.
|
|
629
|
+
throw new Error('The RadioCLI library uses a receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated styles.');
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
assertStorageWritable(this.filePath);
|
|
633
|
+
renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
// Reset only after preserving the original bytes. A failed read never reaches here.
|
|
637
|
+
throw storageError('preserve the corrupt RadioCLI library', error);
|
|
638
|
+
}
|
|
546
639
|
return defaultState();
|
|
547
640
|
}
|
|
641
|
+
// A legacy writer can still change ordinary preferences and favorites. Its
|
|
642
|
+
// fallback style must not erase the selection saved by an updated process.
|
|
643
|
+
let preference;
|
|
644
|
+
try {
|
|
645
|
+
preference = readFileSync(this.receiverPreferencePath(), 'utf8');
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
if (error.code === 'ENOENT')
|
|
649
|
+
return state;
|
|
650
|
+
throw storageError('read the receiver preference', error);
|
|
651
|
+
}
|
|
652
|
+
const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
|
|
653
|
+
if (!parsed.success)
|
|
654
|
+
throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
|
|
655
|
+
if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
|
|
656
|
+
state.settings.receiverStyle = parsed.data.receiverStyle;
|
|
657
|
+
return state;
|
|
658
|
+
}
|
|
659
|
+
receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
|
|
660
|
+
readForCurrentSession() {
|
|
661
|
+
const next = this.read();
|
|
662
|
+
// Disk intentionally stores auto instead of reopening a receiver on launch.
|
|
663
|
+
// Within this owner, unrelated durable changes retain its selected output.
|
|
664
|
+
if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') {
|
|
665
|
+
next.settings.preferredBackend = 'airplay';
|
|
666
|
+
}
|
|
667
|
+
return next;
|
|
548
668
|
}
|
|
549
669
|
commit(nextState) {
|
|
550
670
|
const release = acquireStoreLock(this.filePath);
|
|
551
671
|
try {
|
|
552
|
-
const latestState = this.
|
|
672
|
+
const latestState = this.readForCurrentSession();
|
|
553
673
|
const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
|
|
554
674
|
this.write(mergedState);
|
|
555
675
|
this.state = mergedState;
|
|
556
|
-
return this.
|
|
676
|
+
return this.cachedSnapshot();
|
|
557
677
|
}
|
|
558
678
|
finally {
|
|
559
679
|
release();
|
|
560
680
|
}
|
|
561
681
|
}
|
|
562
682
|
write(state) {
|
|
563
|
-
|
|
564
|
-
|
|
683
|
+
try {
|
|
684
|
+
assertStorageWritable(this.filePath);
|
|
685
|
+
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
686
|
+
const diskState = libraryStateForDisk(state);
|
|
687
|
+
const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
|
|
688
|
+
const preferencePath = this.receiverPreferencePath();
|
|
689
|
+
let previousPreference;
|
|
690
|
+
try {
|
|
691
|
+
previousPreference = readFileSync(preferencePath, 'utf8');
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (error.code !== 'ENOENT')
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
const nextPreference = separateReceiver
|
|
698
|
+
? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
|
|
699
|
+
: undefined;
|
|
700
|
+
const preferenceChanged = previousPreference !== nextPreference;
|
|
701
|
+
if (preferenceChanged) {
|
|
702
|
+
if (nextPreference === undefined)
|
|
703
|
+
rmSync(preferencePath);
|
|
704
|
+
else
|
|
705
|
+
writeTextAtomically(preferencePath, nextPreference);
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
writeJsonAtomically(this.filePath, separateReceiver
|
|
709
|
+
? { ...diskState, settings: { ...diskState.settings, receiverStyle: 'ultracode' } }
|
|
710
|
+
: diskState);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
if (preferenceChanged) {
|
|
714
|
+
if (previousPreference === undefined)
|
|
715
|
+
rmSync(preferencePath, { force: true });
|
|
716
|
+
else
|
|
717
|
+
writeTextAtomically(preferencePath, previousPreference);
|
|
718
|
+
}
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
this.revision = this.diskRevision();
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
this.revision = '';
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
throw storageError('write the RadioCLI library', error);
|
|
730
|
+
}
|
|
565
731
|
}
|
|
566
732
|
}
|
|
567
733
|
export function stationKey(station) {
|
|
568
|
-
return `${station.provider}:${station.id}`;
|
|
734
|
+
return `${station.provider}:${migrateStationIdentity(station).id}`;
|
|
569
735
|
}
|
|
570
736
|
function finishActiveListeningSessionInState(state, endedAt) {
|
|
571
737
|
const active = state.activity.sessions[0];
|
|
@@ -602,31 +768,21 @@ function recoverActiveListeningSessionInState(state) {
|
|
|
602
768
|
: Date.now();
|
|
603
769
|
return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
|
|
604
770
|
}
|
|
605
|
-
function
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
|
|
619
|
-
}
|
|
620
|
-
if (process.platform === 'win32') {
|
|
621
|
-
return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
|
|
622
|
-
}
|
|
623
|
-
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
|
|
624
|
-
}
|
|
625
|
-
function legacyDefaultStorePath() {
|
|
626
|
-
if (process.platform === 'darwin') {
|
|
627
|
-
return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
|
|
771
|
+
export function defaultLibraryPath(options = {}) {
|
|
772
|
+
const { library, legacyLibrary } = platformPaths(options);
|
|
773
|
+
for (const filePath of [library, legacyLibrary]) {
|
|
774
|
+
try {
|
|
775
|
+
statSync(filePath);
|
|
776
|
+
return filePath;
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
// A permission failure must not select an older library or empty defaults.
|
|
780
|
+
if (error.code !== 'ENOENT') {
|
|
781
|
+
throw storageError('read the RadioCLI library', error);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
628
784
|
}
|
|
629
|
-
return
|
|
785
|
+
return library;
|
|
630
786
|
}
|
|
631
787
|
function resolveUserPath(requestedPath) {
|
|
632
788
|
const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
|
|
@@ -644,6 +800,7 @@ function defaultState() {
|
|
|
644
800
|
return {
|
|
645
801
|
recent: [],
|
|
646
802
|
favorites: [],
|
|
803
|
+
presets: [],
|
|
647
804
|
imported: [],
|
|
648
805
|
trackHistory: [],
|
|
649
806
|
searchHistory: [],
|
|
@@ -673,9 +830,32 @@ function defaultState() {
|
|
|
673
830
|
};
|
|
674
831
|
}
|
|
675
832
|
function migrateLibraryState(state) {
|
|
833
|
+
const aliases = new Map();
|
|
834
|
+
const migrate = (station) => {
|
|
835
|
+
const next = migrateStationIdentity(station);
|
|
836
|
+
if (next.id !== station.id) {
|
|
837
|
+
const key = `${station.provider}:${station.id}`;
|
|
838
|
+
const targets = aliases.get(key) ?? new Set();
|
|
839
|
+
targets.add(stationKey(next));
|
|
840
|
+
aliases.set(key, targets);
|
|
841
|
+
}
|
|
842
|
+
return next;
|
|
843
|
+
};
|
|
844
|
+
const recent = state.recent.map(item => ({ ...item, station: migrate(item.station) }));
|
|
845
|
+
const favorites = state.favorites.map(migrate);
|
|
846
|
+
const imported = state.imported.map(migrate);
|
|
847
|
+
const presets = state.presets?.map(item => ({ ...item, station: migrate(item.station) }));
|
|
848
|
+
const sessions = state.activity.sessions.map(item => ({ ...item, station: migrate(item.station) }));
|
|
849
|
+
const alarms = state.alarms.map(alarm => normalizeAlarm({ ...alarm, station: migrate(alarm.station),
|
|
850
|
+
playback: { ...alarm.playback, ...(alarm.playback.fallbackStation ? { fallbackStation: migrate(alarm.playback.fallbackStation) } : {}) } }));
|
|
676
851
|
return {
|
|
677
852
|
...state,
|
|
678
|
-
|
|
853
|
+
recent, favorites, imported, presets, alarms,
|
|
854
|
+
activity: { sessions },
|
|
855
|
+
trackHistory: state.trackHistory.map(item => {
|
|
856
|
+
const targets = aliases.get(item.stationKey);
|
|
857
|
+
return targets?.size === 1 ? { ...item, stationKey: [...targets][0] } : item;
|
|
858
|
+
}),
|
|
679
859
|
settings: {
|
|
680
860
|
...state.settings,
|
|
681
861
|
preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
|
|
@@ -700,53 +880,98 @@ function libraryStateForDisk(state) {
|
|
|
700
880
|
: state;
|
|
701
881
|
}
|
|
702
882
|
function writeJsonAtomically(filePath, value) {
|
|
703
|
-
|
|
883
|
+
writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
884
|
+
}
|
|
885
|
+
function writeTextAtomically(filePath, text) {
|
|
886
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
704
887
|
try {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
888
|
+
const fd = openSync(tempPath, 'wx', 0o600);
|
|
889
|
+
try {
|
|
890
|
+
writeFileSync(fd, text, 'utf8');
|
|
891
|
+
if (process.platform !== 'win32')
|
|
892
|
+
chmodSync(tempPath, 0o600);
|
|
893
|
+
fsyncSync(fd);
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
try {
|
|
897
|
+
closeSync(fd);
|
|
898
|
+
}
|
|
899
|
+
catch { /* Preserve the original persistence error. */ }
|
|
900
|
+
throw error;
|
|
709
901
|
}
|
|
902
|
+
closeSync(fd);
|
|
903
|
+
replaceFileSync(tempPath, filePath);
|
|
710
904
|
}
|
|
711
905
|
catch (error) {
|
|
712
|
-
rmSync(tempPath, { force: true });
|
|
713
|
-
throw error;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
function acquireStoreLock(filePath) {
|
|
717
|
-
const lockPath = `${filePath}.lock`;
|
|
718
|
-
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
719
|
-
const deadline = Date.now() + 1000;
|
|
720
|
-
while (true) {
|
|
721
906
|
try {
|
|
722
|
-
|
|
723
|
-
return () => rmSync(lockPath, { recursive: true, force: true });
|
|
907
|
+
rmSync(tempPath, { force: true });
|
|
724
908
|
}
|
|
725
|
-
catch
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
909
|
+
catch { /* Preserve the original write failure. */ }
|
|
910
|
+
throw storageError('write RadioCLI data', error);
|
|
911
|
+
}
|
|
912
|
+
// Flush the replacement directory where supported. Once rename succeeds the
|
|
913
|
+
// transaction is committed: a directory-sync failure cannot roll it back or
|
|
914
|
+
// leave the caller's in-memory state describing the previous file. Windows
|
|
915
|
+
// and some network/removable filesystems do not support syncing directories.
|
|
916
|
+
let directoryFd;
|
|
917
|
+
try {
|
|
918
|
+
directoryFd = openSync(dirname(filePath), 'r');
|
|
919
|
+
fsyncSync(directoryFd);
|
|
920
|
+
}
|
|
921
|
+
catch { /* The file itself was flushed before replacement. */ }
|
|
922
|
+
finally {
|
|
923
|
+
if (directoryFd !== undefined)
|
|
729
924
|
try {
|
|
730
|
-
|
|
731
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
732
|
-
continue;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
continue;
|
|
737
|
-
}
|
|
738
|
-
if (Date.now() >= deadline) {
|
|
739
|
-
throw new Error(`RadioCLI library is busy: ${filePath}`);
|
|
925
|
+
closeSync(directoryFd);
|
|
740
926
|
}
|
|
741
|
-
|
|
742
|
-
|
|
927
|
+
catch { /* Already committed. */ }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function acquireStoreLock(filePath) {
|
|
931
|
+
const lockPath = `${filePath}.lock`;
|
|
932
|
+
try {
|
|
933
|
+
assertStorageWritable(filePath);
|
|
934
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
throw storageError('write the RadioCLI library', error);
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
return acquireFileLock(lockPath);
|
|
743
941
|
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
throw storageError('lock the RadioCLI library', error);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
class LibraryStorageError extends Error {
|
|
947
|
+
code;
|
|
948
|
+
errno;
|
|
949
|
+
constructor(operation, cause) {
|
|
950
|
+
const code = cause?.code;
|
|
951
|
+
const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
|
|
952
|
+
super(code === 'EBUSY'
|
|
953
|
+
? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.'
|
|
954
|
+
: `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, { cause });
|
|
955
|
+
this.name = 'LibraryStorageError';
|
|
956
|
+
this.code = code;
|
|
957
|
+
this.errno = cause?.errno;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function storageError(operation, cause) {
|
|
961
|
+
return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
|
|
962
|
+
}
|
|
963
|
+
function normalizePresetName(name) {
|
|
964
|
+
const key = name.trim().toLowerCase();
|
|
965
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key))
|
|
966
|
+
throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
|
|
967
|
+
return key;
|
|
744
968
|
}
|
|
745
969
|
function mergeLibraryChanges(base, disk, next) {
|
|
746
970
|
return {
|
|
747
971
|
recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
|
|
748
972
|
favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
|
|
749
|
-
|
|
973
|
+
presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
|
|
974
|
+
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
|
|
750
975
|
trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
|
|
751
976
|
searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
|
|
752
977
|
alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
|
|
@@ -768,15 +993,16 @@ function normalizeAlarm(alarm) {
|
|
|
768
993
|
};
|
|
769
994
|
return { ...alarm, schedule };
|
|
770
995
|
}
|
|
771
|
-
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
|
|
996
|
+
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false, applyOrderChanges = false) {
|
|
772
997
|
const baseByKey = new Map(base.map(item => [keyFor(item), item]));
|
|
998
|
+
const basePositions = new Map(base.map((item, index) => [keyFor(item), index]));
|
|
773
999
|
const nextKeys = new Set(next.map(keyFor));
|
|
774
1000
|
const removedKeys = applyRemovals
|
|
775
1001
|
? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
|
|
776
1002
|
: new Set();
|
|
777
|
-
const localChanges = next.filter(item => {
|
|
1003
|
+
const localChanges = next.filter((item, index) => {
|
|
778
1004
|
const prior = baseByKey.get(keyFor(item));
|
|
779
|
-
return prior === undefined || !sameValue(prior, item);
|
|
1005
|
+
return prior === undefined || !sameValue(prior, item) || applyOrderChanges && basePositions.get(keyFor(item)) !== index;
|
|
780
1006
|
});
|
|
781
1007
|
const changedKeys = new Set(localChanges.map(keyFor));
|
|
782
1008
|
return [
|