@feralfile/cli 1.1.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 +96 -0
- package/config.json.example +96 -0
- package/dist/index.js +54 -0
- package/dist/src/ai-orchestrator/index.js +1019 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/commands/build.js +69 -0
- package/dist/src/commands/chat.js +189 -0
- package/dist/src/commands/config.js +68 -0
- package/dist/src/commands/device.js +278 -0
- package/dist/src/commands/helpers/config-files.js +62 -0
- package/dist/src/commands/helpers/device-discovery.js +111 -0
- package/dist/src/commands/helpers/playlist-display.js +161 -0
- package/dist/src/commands/helpers/prompt.js +65 -0
- package/dist/src/commands/helpers/ssh-helpers.js +44 -0
- package/dist/src/commands/play.js +110 -0
- package/dist/src/commands/publish.js +115 -0
- package/dist/src/commands/setup.js +225 -0
- package/dist/src/commands/sign.js +41 -0
- package/dist/src/commands/ssh.js +108 -0
- package/dist/src/commands/status.js +126 -0
- package/dist/src/commands/validate.js +18 -0
- package/dist/src/config.js +441 -0
- package/dist/src/intent-parser/index.js +1382 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +82 -0
- package/dist/src/main.js +459 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/device-default.js +36 -0
- package/dist/src/utilities/device-lookup.js +107 -0
- package/dist/src/utilities/device-normalize.js +62 -0
- package/dist/src/utilities/device-upsert.js +91 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/ed25519-key-derive.js +155 -0
- package/dist/src/utilities/feed-fetcher.js +471 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +250 -0
- package/dist/src/utilities/ff1-discovery.js +330 -0
- package/dist/src/utilities/functions.js +308 -0
- package/dist/src/utilities/index.js +469 -0
- package/dist/src/utilities/nft-indexer.js +1024 -0
- package/dist/src/utilities/playlist-builder.js +523 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +260 -0
- package/dist/src/utilities/playlist-signer.js +204 -0
- package/dist/src/utilities/playlist-signing-role.js +41 -0
- package/dist/src/utilities/playlist-source.js +128 -0
- package/dist/src/utilities/playlist-verifier.js +274 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +206 -0
- package/docs/EXAMPLES.md +390 -0
- package/docs/FUNCTION_CALLING.md +96 -0
- package/docs/PROJECT_SPEC.md +228 -0
- package/docs/README.md +348 -0
- package/docs/RELEASING.md +73 -0
- package/package.json +76 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FF1 device compatibility helpers for command preflight checks.
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.resolveConfiguredDevice = resolveConfiguredDevice;
|
|
40
|
+
exports.assertFF1CommandCompatibility = assertFF1CommandCompatibility;
|
|
41
|
+
const config_1 = require("../config");
|
|
42
|
+
const logger = __importStar(require("../logger"));
|
|
43
|
+
const FF1_COMMAND_POLICIES = {
|
|
44
|
+
displayPlaylist: {
|
|
45
|
+
minimumVersion: '1.0.0',
|
|
46
|
+
},
|
|
47
|
+
sshAccess: {
|
|
48
|
+
minimumVersion: '1.0.9',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Load and validate the configured FF1 device selected by name.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} [deviceName] - Optional device name, exact match required
|
|
55
|
+
* @param {Object} [options] - Optional dependency overrides
|
|
56
|
+
* @param {Function} [options.getFF1DeviceConfigFn] - Optional config loader override
|
|
57
|
+
* @returns {FF1DeviceSelectionResult} Selected device or reason for failure
|
|
58
|
+
* @throws {Error} Never throws; malformed configuration is returned as an error result
|
|
59
|
+
* @example
|
|
60
|
+
* const result = resolveConfiguredDevice('Living Room');
|
|
61
|
+
*/
|
|
62
|
+
function resolveConfiguredDevice(deviceName, options = {}) {
|
|
63
|
+
const getFF1DeviceConfigFn = options.getFF1DeviceConfigFn || config_1.getFF1DeviceConfig;
|
|
64
|
+
const deviceConfig = getFF1DeviceConfigFn();
|
|
65
|
+
if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
success: false,
|
|
68
|
+
error: 'No FF1 devices configured. Add devices to config.json under "ff1Devices"',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
let device = deviceConfig.devices[0];
|
|
72
|
+
if (deviceName) {
|
|
73
|
+
device = deviceConfig.devices.find((item) => item.name === deviceName);
|
|
74
|
+
if (!device) {
|
|
75
|
+
const availableNames = deviceConfig.devices
|
|
76
|
+
.map((item) => item.name)
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(', ');
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: `Device "${deviceName}" not found. Available devices: ${availableNames || 'none with names'}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
logger.info(`Found device by name: ${deviceName}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
logger.info('Using first configured device');
|
|
88
|
+
}
|
|
89
|
+
if (!device.host) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: 'Invalid device configuration: must include host',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
device,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ensure the target device supports the requested FF1 command.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} device - FF1 device configuration
|
|
104
|
+
* @param {FF1Command} command - Command to execute
|
|
105
|
+
* @param {Object} [options] - Optional dependency overrides
|
|
106
|
+
* @param {Function} [options.fetchFn] - Optional fetch implementation
|
|
107
|
+
* @returns {Promise<FF1CompatibilityResult>} Compatibility result
|
|
108
|
+
* @throws {Error} Never throws; network and parsing failures produce a compatible result
|
|
109
|
+
* @example
|
|
110
|
+
* const result = await assertFF1CommandCompatibility(device, 'displayPlaylist');
|
|
111
|
+
*/
|
|
112
|
+
async function assertFF1CommandCompatibility(device, command, options = {}) {
|
|
113
|
+
const fetchFn = options.fetchFn || globalThis.fetch.bind(globalThis);
|
|
114
|
+
const policy = getCommandPolicy(command);
|
|
115
|
+
const versionResult = await detectFF1VersionSafely(device.host, buildVersionHeaders(device), fetchFn);
|
|
116
|
+
return resolveCompatibility(device, command, policy, versionResult);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Return command compatibility requirements.
|
|
120
|
+
*
|
|
121
|
+
* @param {FF1Command} command - Command to check
|
|
122
|
+
* @returns {FF1CommandPolicy} Policy metadata
|
|
123
|
+
* @example
|
|
124
|
+
* getCommandPolicy('sshAccess'); // { minimumVersion: '1.0.0' }
|
|
125
|
+
*/
|
|
126
|
+
function getCommandPolicy(command) {
|
|
127
|
+
return FF1_COMMAND_POLICIES[command];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Detect FF1 version and recover compatibility when detection fails.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} host - Device host URL
|
|
133
|
+
* @param {Object} headers - Request headers
|
|
134
|
+
* @param {Function} fetchFn - Fetch implementation
|
|
135
|
+
* @returns {Promise<FF1VersionProbe | null>} Detected version metadata
|
|
136
|
+
*/
|
|
137
|
+
async function detectFF1VersionSafely(host, headers, fetchFn) {
|
|
138
|
+
try {
|
|
139
|
+
return await detectFF1Version(host, headers, fetchFn);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
logger.debug('FF1 version detection failed; continuing with command', error.message);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve final compatibility decision from detected version and policy.
|
|
148
|
+
*
|
|
149
|
+
* @param {FF1Device} device - Target device
|
|
150
|
+
* @param {FF1Command} command - Command requested
|
|
151
|
+
* @param {FF1CommandPolicy} policy - Version policy
|
|
152
|
+
* @param {FF1VersionProbe | null} versionResult - Detected version probe
|
|
153
|
+
* @returns {FF1CompatibilityResult} Compatibility decision
|
|
154
|
+
*/
|
|
155
|
+
function resolveCompatibility(device, command, policy, versionResult) {
|
|
156
|
+
if (!versionResult) {
|
|
157
|
+
logger.warn(`Could not verify FF1 OS version for ${device.name || device.host}`);
|
|
158
|
+
return { compatible: true };
|
|
159
|
+
}
|
|
160
|
+
const normalizedVersion = normalizeVersion(versionResult.version);
|
|
161
|
+
if (!normalizedVersion) {
|
|
162
|
+
return {
|
|
163
|
+
compatible: true,
|
|
164
|
+
version: versionResult.version,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (compareVersions(normalizedVersion, policy.minimumVersion) < 0) {
|
|
168
|
+
return {
|
|
169
|
+
compatible: false,
|
|
170
|
+
version: normalizedVersion,
|
|
171
|
+
error: `Unsupported FF1 OS ${normalizedVersion} for ${command}. FF1 OS must be ${policy.minimumVersion} or newer.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
compatible: true,
|
|
176
|
+
version: normalizedVersion,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Detect FF1 OS version via POST /api/cast with getDeviceStatus command.
|
|
181
|
+
*
|
|
182
|
+
* Reads `message.installedVersion` from the device status response.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} host - Device host URL
|
|
185
|
+
* @param {Record<string, string>} headers - Request headers (e.g. API-KEY)
|
|
186
|
+
* @param {FetchFunction} fetchFn - Fetch implementation
|
|
187
|
+
* @returns {Promise<FF1VersionProbe | null>} Version probe or null if unavailable
|
|
188
|
+
* @example
|
|
189
|
+
* const probe = await detectFF1Version('http://ff1.local', {}, fetch);
|
|
190
|
+
*/
|
|
191
|
+
async function detectFF1Version(host, headers, fetchFn) {
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetchFn(`${host}/api/cast`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ command: 'getDeviceStatus', request: {} }),
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const data = (await response.json());
|
|
202
|
+
const version = data?.message?.installedVersion;
|
|
203
|
+
if (!version) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return { version };
|
|
207
|
+
}
|
|
208
|
+
catch (_error) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Build headers shared by cast requests.
|
|
214
|
+
*
|
|
215
|
+
* @param {FF1Device} device - Target device
|
|
216
|
+
* @returns {Record<string, string>} Headers map
|
|
217
|
+
* @example
|
|
218
|
+
* const headers = buildVersionHeaders(device);
|
|
219
|
+
*/
|
|
220
|
+
function buildVersionHeaders(device) {
|
|
221
|
+
const headers = {};
|
|
222
|
+
if (device.apiKey) {
|
|
223
|
+
headers['API-KEY'] = device.apiKey;
|
|
224
|
+
}
|
|
225
|
+
return headers;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Parse and normalize a version string to x.y.z format.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} version - Raw version string
|
|
231
|
+
* @returns {string | null} Normalized semver-like version
|
|
232
|
+
* @example
|
|
233
|
+
* normalizeVersion('v1.2') // '1.2.0'
|
|
234
|
+
*/
|
|
235
|
+
function normalizeVersion(version) {
|
|
236
|
+
const raw = version.trim();
|
|
237
|
+
const match = raw.match(/(?:v)?(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
238
|
+
if (!match) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const major = match[1];
|
|
242
|
+
const minor = match[2];
|
|
243
|
+
const patch = match[3] || '0';
|
|
244
|
+
return `${major}.${minor}.${patch}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Compare two semantic versions in x.y.z format.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} left - First version
|
|
250
|
+
* @param {string} right - Second version
|
|
251
|
+
* @returns {number} 1 if left > right, -1 if left < right, 0 if equal
|
|
252
|
+
* @example
|
|
253
|
+
* compareVersions('1.2.1', '1.2.0'); // 1
|
|
254
|
+
*/
|
|
255
|
+
function compareVersions(left, right) {
|
|
256
|
+
const leftParts = left.split('.').map((value) => Number.parseInt(value, 10));
|
|
257
|
+
const rightParts = right.split('.').map((value) => Number.parseInt(value, 10));
|
|
258
|
+
for (let i = 0; i < 3; i++) {
|
|
259
|
+
const leftPart = leftParts[i] || 0;
|
|
260
|
+
const rightPart = rightParts[i] || 0;
|
|
261
|
+
if (leftPart > rightPart) {
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
if (leftPart < rightPart) {
|
|
265
|
+
return -1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FF1 Device Communication Module
|
|
4
|
+
* Handles sending DP1 playlists to FF1 devices via the Relayer API
|
|
5
|
+
*/
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
|
|
41
|
+
exports.sendPlaylistToDevice = sendPlaylistToDevice;
|
|
42
|
+
const logger = __importStar(require("../logger"));
|
|
43
|
+
const ff1_compatibility_1 = require("./ff1-compatibility");
|
|
44
|
+
const SEND_RETRY_ATTEMPTS = 3;
|
|
45
|
+
const SEND_RETRY_BASE_DELAY_MS = 750;
|
|
46
|
+
/**
|
|
47
|
+
* Sleep for a short duration before retrying transient network errors.
|
|
48
|
+
*
|
|
49
|
+
* @param {number} delayMs - Milliseconds to wait
|
|
50
|
+
* @returns {Promise<void>} Promise that resolves after the delay
|
|
51
|
+
*/
|
|
52
|
+
function waitForRetry(delayMs) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
setTimeout(resolve, delayMs);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* isTransientDeviceNetworkError returns true when a send failure is likely temporary.
|
|
59
|
+
*
|
|
60
|
+
* This classifier intentionally targets resolver and route-level failures that are
|
|
61
|
+
* common on local mDNS/Wi-Fi environments. Permanent command errors should surface
|
|
62
|
+
* immediately without retry loops.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} error - Error thrown by fetch
|
|
65
|
+
* @returns {boolean} True when retrying is likely to recover
|
|
66
|
+
*/
|
|
67
|
+
function isTransientDeviceNetworkError(error) {
|
|
68
|
+
if (!(error instanceof Error)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const networkCodes = new Set([
|
|
72
|
+
'EHOSTUNREACH',
|
|
73
|
+
'ENETUNREACH',
|
|
74
|
+
'ENOTFOUND',
|
|
75
|
+
'EAI_AGAIN',
|
|
76
|
+
'ETIMEDOUT',
|
|
77
|
+
'ECONNREFUSED',
|
|
78
|
+
'ECONNRESET',
|
|
79
|
+
]);
|
|
80
|
+
const message = error.message || '';
|
|
81
|
+
const messageLooksTransient = message.includes('fetch failed') ||
|
|
82
|
+
message.includes('getaddrinfo') ||
|
|
83
|
+
message.includes('EHOSTUNREACH') ||
|
|
84
|
+
message.includes('ENOTFOUND') ||
|
|
85
|
+
message.includes('ETIMEDOUT') ||
|
|
86
|
+
message.includes('network timeout') ||
|
|
87
|
+
message.includes('No route to host');
|
|
88
|
+
if (messageLooksTransient) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const causeCode = error.cause &&
|
|
92
|
+
typeof error.cause === 'object' &&
|
|
93
|
+
'code' in error.cause &&
|
|
94
|
+
typeof error.cause.code === 'string'
|
|
95
|
+
? error.cause.code
|
|
96
|
+
: undefined;
|
|
97
|
+
return Boolean(causeCode && networkCodes.has(causeCode));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Send a DP1 playlist to an FF1 device using the cast API
|
|
101
|
+
*
|
|
102
|
+
* This function sends the entire DP1 JSON payload to a configured FF1 device.
|
|
103
|
+
* If a device name is provided, it searches for a device with that exact name.
|
|
104
|
+
* If no device name is provided, it uses the first configured device.
|
|
105
|
+
* The API-KEY header is only included if the device has an apiKey configured.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} params - Function parameters
|
|
108
|
+
* @param {Object} params.playlist - Complete DP1 v1.0.0 playlist object to send
|
|
109
|
+
* @param {string} [params.deviceName] - Name of the device to send to (exact match required)
|
|
110
|
+
* @returns {Promise<Object>} Result object
|
|
111
|
+
* @returns {boolean} returns.success - Whether the cast was successful
|
|
112
|
+
* @returns {string} [returns.device] - Device host that received the playlist
|
|
113
|
+
* @returns {string} [returns.deviceName] - Name of the device used
|
|
114
|
+
* @returns {Object} [returns.response] - Response from the device
|
|
115
|
+
* @returns {string} [returns.error] - Error message if failed
|
|
116
|
+
* @throws {Error} When device configuration is invalid or missing
|
|
117
|
+
* @example
|
|
118
|
+
* // Send to first device
|
|
119
|
+
* const result = await sendPlaylistToDevice({
|
|
120
|
+
* playlist: { version: '1.0.0', title: 'My Collection', items: [...] }
|
|
121
|
+
* });
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* // Send to specific device by name
|
|
125
|
+
* const result = await sendPlaylistToDevice({
|
|
126
|
+
* playlist: { version: '1.0.0', title: 'My Collection', items: [...] },
|
|
127
|
+
* deviceName: 'Living Room Display'
|
|
128
|
+
* });
|
|
129
|
+
*/
|
|
130
|
+
async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
131
|
+
let device;
|
|
132
|
+
try {
|
|
133
|
+
// Validate input
|
|
134
|
+
if (!playlist || typeof playlist !== 'object') {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: 'Invalid playlist: must provide a valid DP1 playlist object',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
|
|
141
|
+
if (!resolved.success || !resolved.device) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: resolved.error || 'FF1 device is not configured correctly',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
device = resolved.device;
|
|
148
|
+
const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'displayPlaylist');
|
|
149
|
+
if (!compatibility.compatible) {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
error: compatibility.error || 'FF1 OS does not support playlist casting',
|
|
153
|
+
details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
logger.info(`Sending playlist to FF1 device: ${device.host}`);
|
|
157
|
+
// Construct API URL with optional topicID
|
|
158
|
+
let apiUrl = `${device.host}/api/cast`;
|
|
159
|
+
if (device.topicID && device.topicID.trim() !== '') {
|
|
160
|
+
apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
|
|
161
|
+
logger.debug(`Using topicID: ${device.topicID}`);
|
|
162
|
+
}
|
|
163
|
+
// Wrap playlist in required structure
|
|
164
|
+
const requestBody = {
|
|
165
|
+
command: 'displayPlaylist',
|
|
166
|
+
request: {
|
|
167
|
+
dp1_call: playlist,
|
|
168
|
+
intent: { action: 'now_display' },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
// Prepare headers
|
|
172
|
+
const headers = {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
};
|
|
175
|
+
// Add API-KEY header only if apiKey is provided
|
|
176
|
+
if (device.apiKey) {
|
|
177
|
+
headers['API-KEY'] = device.apiKey;
|
|
178
|
+
}
|
|
179
|
+
// Make the API request with bounded retries for transient local network errors.
|
|
180
|
+
let response = null;
|
|
181
|
+
for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) {
|
|
182
|
+
try {
|
|
183
|
+
response = await fetch(apiUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: JSON.stringify(requestBody),
|
|
187
|
+
});
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
const shouldRetry = attempt < SEND_RETRY_ATTEMPTS && isTransientDeviceNetworkError(error);
|
|
192
|
+
if (!shouldRetry) {
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
|
|
196
|
+
logger.warn(`Transient network error while sending playlist (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}): ${error.message}`);
|
|
197
|
+
logger.debug(`Retrying playlist send in ${retryDelay}ms`);
|
|
198
|
+
await waitForRetry(retryDelay);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!response) {
|
|
202
|
+
const deviceLabel = device.name || device.host;
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
206
|
+
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
207
|
+
'If the device IP changed (e.g. after a factory reset), run: ff1 setup',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Check response status
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
const errorText = await response.text();
|
|
213
|
+
logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
|
|
214
|
+
logger.debug(`Error details: ${errorText}`);
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: `Device returned error ${response.status}: ${response.statusText}`,
|
|
218
|
+
details: errorText,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Parse response
|
|
222
|
+
const responseData = (await response.json());
|
|
223
|
+
logger.info('Successfully sent playlist to FF1 device');
|
|
224
|
+
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
225
|
+
return {
|
|
226
|
+
success: true,
|
|
227
|
+
device: device.host,
|
|
228
|
+
deviceName: device.name || device.host,
|
|
229
|
+
response: responseData,
|
|
230
|
+
message: 'Playlist successfully sent to FF1 device',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const errorMessage = error.message;
|
|
235
|
+
logger.error(`Error sending playlist to device: ${errorMessage}`);
|
|
236
|
+
if (device && isTransientDeviceNetworkError(error)) {
|
|
237
|
+
const deviceLabel = device.name || device.host;
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
241
|
+
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
242
|
+
'If the device IP changed (e.g. after a factory reset), run: ff1 setup',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
error: errorMessage,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|