ff1-cli 1.0.0

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,387 @@
1
+ /**
2
+ * DP1 Feed Fetcher
3
+ * Utilities to fetch playlists from DP1 Feed API
4
+ * API: https://github.com/display-protocol/dp1-feed
5
+ */
6
+ const chalk = require('chalk');
7
+ const fuzzysort = require('fuzzysort');
8
+ const { getFeedConfig } = require('../config');
9
+ /**
10
+ * Get feed API base URLs from configuration
11
+ *
12
+ * @returns {string[]} Array of feed API base URLs
13
+ */
14
+ function getFeedApiUrls() {
15
+ const feedConfig = getFeedConfig();
16
+ return feedConfig.baseURLs;
17
+ }
18
+ /**
19
+ * Fetch playlists from a single feed URL with pagination
20
+ *
21
+ * @param {string} feedUrl - Feed API base URL
22
+ * @param {number} limit - Items per page (default: 50, max: 100)
23
+ * @returns {Promise<Array>} Array of playlists
24
+ */
25
+ async function fetchPlaylistsFromFeed(feedUrl, limit = 100) {
26
+ try {
27
+ // API has a maximum limit of 100
28
+ const validLimit = Math.min(limit, 100);
29
+ const response = await fetch(`${feedUrl}/playlists?limit=${validLimit}&sort=-created`);
30
+ if (!response.ok) {
31
+ console.log(chalk.yellow(` ⚠️ Feed ${feedUrl} returned ${response.status}`));
32
+ return [];
33
+ }
34
+ const data = await response.json();
35
+ const playlists = data.items || [];
36
+ // Add feedUrl to each playlist for tracking
37
+ return playlists.map((p) => ({
38
+ ...p,
39
+ feedUrl,
40
+ }));
41
+ }
42
+ catch (error) {
43
+ console.log(chalk.yellow(` ⚠️ Failed to fetch from ${feedUrl}: ${error.message}`));
44
+ return [];
45
+ }
46
+ }
47
+ /**
48
+ * Fetch playlists with pagination and fuzzy filtering to save memory
49
+ *
50
+ * @param {string} feedUrl - Feed API base URL
51
+ * @param {string} searchTerm - Search term for fuzzy filtering
52
+ * @param {number} pageSize - Items per page (default: 50, max: 100)
53
+ * @param {number} topN - Keep top N matches per page (default: 10)
54
+ * @param {number} maxItems - Maximum total items to fetch (default: 500)
55
+ * @returns {Promise<Array>} Array of best matching playlists
56
+ */
57
+ async function fetchPlaylistsWithPagination(feedUrl, searchTerm, pageSize = 50, topN = 10, maxItems = 500) {
58
+ const allMatches = [];
59
+ let offset = 0;
60
+ let hasMore = true;
61
+ let totalFetched = 0;
62
+ while (hasMore && totalFetched < maxItems) {
63
+ // Calculate limit for this page (might be less than pageSize on last page)
64
+ // API has a maximum limit of 100
65
+ const remainingItems = maxItems - totalFetched;
66
+ const currentLimit = Math.min(pageSize, remainingItems, 100);
67
+ try {
68
+ const response = await fetch(`${feedUrl}/playlists?limit=${currentLimit}&offset=${offset}&sort=-created`);
69
+ if (!response.ok) {
70
+ if (response.status === 404 || response.status === 400) {
71
+ // No more pages or offset not supported
72
+ hasMore = false;
73
+ break;
74
+ }
75
+ break;
76
+ }
77
+ const data = await response.json();
78
+ const playlists = data.items || [];
79
+ if (playlists.length === 0) {
80
+ hasMore = false;
81
+ break;
82
+ }
83
+ totalFetched += playlists.length;
84
+ // Extract titles for fuzzy matching
85
+ const titles = playlists.map((p) => p.title);
86
+ // Fuzzy match on this page
87
+ const results = fuzzysort.go(searchTerm, titles, {
88
+ threshold: -5000, // More lenient threshold
89
+ limit: topN, // Keep only top N per page
90
+ });
91
+ // Map results back to playlist objects
92
+ results.forEach((result) => {
93
+ const playlist = playlists.find((p) => p.title === result.target);
94
+ if (playlist) {
95
+ allMatches.push({
96
+ title: playlist.title,
97
+ id: playlist.id,
98
+ feedUrl,
99
+ score: result.score,
100
+ });
101
+ }
102
+ });
103
+ // Check if we've reached the end
104
+ if (playlists.length < currentLimit) {
105
+ hasMore = false;
106
+ }
107
+ else {
108
+ offset += playlists.length;
109
+ }
110
+ }
111
+ catch (_error) {
112
+ hasMore = false;
113
+ }
114
+ }
115
+ return allMatches;
116
+ }
117
+ /**
118
+ * Fetch all playlists from all configured feeds
119
+ *
120
+ * @returns {Promise<Array>} Array of all playlists from all feeds
121
+ */
122
+ async function fetchAllPlaylists() {
123
+ const feedUrls = getFeedApiUrls();
124
+ // Fetch playlists from all feeds in parallel
125
+ const allPlaylistsArrays = await Promise.all(feedUrls.map((url) => fetchPlaylistsFromFeed(url)));
126
+ // Flatten and combine results from all feeds
127
+ return allPlaylistsArrays.flat();
128
+ }
129
+ /**
130
+ * Search for exact playlist match by name across multiple feeds
131
+ *
132
+ * @param {string} playlistName - Exact playlist name to search for
133
+ * @returns {Promise<Object>} Search result with playlist or error
134
+ */
135
+ async function searchExactPlaylist(playlistName) {
136
+ try {
137
+ const playlists = await fetchAllPlaylists();
138
+ if (playlists.length === 0) {
139
+ return {
140
+ success: false,
141
+ error: 'No playlists found in any feed',
142
+ };
143
+ }
144
+ // Find exact match (case-insensitive)
145
+ const normalizedSearchName = playlistName.toLowerCase().trim();
146
+ const exactMatch = playlists.find((p) => p.title.toLowerCase().trim() === normalizedSearchName);
147
+ if (exactMatch) {
148
+ // Fetch full playlist details
149
+ const playlist = await getPlaylistById(exactMatch.id, exactMatch.feedUrl);
150
+ return {
151
+ success: true,
152
+ playlist,
153
+ };
154
+ }
155
+ else {
156
+ return {
157
+ success: false,
158
+ error: `No exact match found for playlist "${playlistName}"`,
159
+ };
160
+ }
161
+ }
162
+ catch (error) {
163
+ return {
164
+ success: false,
165
+ error: error.message,
166
+ };
167
+ }
168
+ }
169
+ /**
170
+ * Find the best matching playlist using fuzzy string matching with pagination
171
+ *
172
+ * @param {string} searchTerm - Search term to match against
173
+ * @returns {Promise<Object>} Result with best matching playlist name and map
174
+ */
175
+ async function findBestMatchingPlaylist(searchTerm) {
176
+ try {
177
+ const feedUrls = getFeedApiUrls();
178
+ // Fetch from all feeds in parallel with pagination and filtering
179
+ const allMatchesArrays = await Promise.all(feedUrls.map((url) => fetchPlaylistsWithPagination(url, searchTerm, 50, 10)));
180
+ // Flatten and combine results from all feeds
181
+ const allMatches = allMatchesArrays.flat();
182
+ if (allMatches.length === 0) {
183
+ return {
184
+ success: false,
185
+ error: `No matching playlists found for "${searchTerm}"`,
186
+ };
187
+ }
188
+ // Sort by score (highest first) and get best match
189
+ allMatches.sort((a, b) => b.score - a.score);
190
+ // Build playlistMap for ID lookup
191
+ const playlistMap = {};
192
+ allMatches.forEach((match) => {
193
+ playlistMap[match.title] = {
194
+ id: match.id,
195
+ feedUrl: match.feedUrl,
196
+ };
197
+ });
198
+ const bestMatch = allMatches[0].title;
199
+ // Simplified output: only show the best match, not all alternatives
200
+ console.log(chalk.green(`✓ Found: "${bestMatch}"`));
201
+ return {
202
+ success: true,
203
+ bestMatch,
204
+ playlistMap,
205
+ allMatches: allMatches.map((m) => m.title),
206
+ };
207
+ }
208
+ catch (error) {
209
+ return {
210
+ success: false,
211
+ error: error.message,
212
+ };
213
+ }
214
+ }
215
+ /**
216
+ * Get playlist by ID or slug from a specific feed or try all feeds
217
+ *
218
+ * @param {string} idOrSlug - Playlist ID (UUID) or slug
219
+ * @param {string} [feedUrl] - Optional specific feed URL to use
220
+ * @returns {Promise<Object>} Playlist object
221
+ */
222
+ async function getPlaylistById(idOrSlug, feedUrl = null) {
223
+ try {
224
+ const feedUrls = feedUrl ? [feedUrl] : getFeedApiUrls();
225
+ // Try each feed URL until we find the playlist
226
+ for (const url of feedUrls) {
227
+ try {
228
+ const response = await fetch(`${url}/playlists/${idOrSlug}`);
229
+ if (response.ok) {
230
+ const playlist = await response.json();
231
+ return playlist;
232
+ }
233
+ }
234
+ catch (_error) {
235
+ // Continue to next feed
236
+ continue;
237
+ }
238
+ }
239
+ throw new Error(`Playlist "${idOrSlug}" not found in any feed`);
240
+ }
241
+ catch (error) {
242
+ throw new Error(`Failed to fetch playlist: ${error.message}`);
243
+ }
244
+ }
245
+ /**
246
+ * Shuffle array using Fisher-Yates algorithm
247
+ *
248
+ * @param {Array} array - Array to shuffle
249
+ * @returns {Array} Shuffled array
250
+ */
251
+ function shuffleArray(array) {
252
+ const shuffled = [...array];
253
+ for (let i = shuffled.length - 1; i > 0; i--) {
254
+ const j = Math.floor(Math.random() * (i + 1));
255
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
256
+ }
257
+ return shuffled;
258
+ }
259
+ /**
260
+ * Get playlist items from a playlist
261
+ *
262
+ * @param {Object} playlist - DP1 playlist object
263
+ * @param {number} quantity - Number of items to extract
264
+ * @param {number} duration - Duration per item in seconds
265
+ * @param {boolean} shuffle - Whether to shuffle and randomly select items
266
+ * @returns {Array<Object>} Array of DP1 playlist items
267
+ */
268
+ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
269
+ if (!playlist.items || playlist.items.length === 0) {
270
+ return [];
271
+ }
272
+ let items = playlist.items;
273
+ // Shuffle and randomly select if requested
274
+ if (shuffle && items.length > quantity) {
275
+ items = shuffleArray(items);
276
+ }
277
+ // Take requested quantity
278
+ items = items.slice(0, quantity);
279
+ // Override duration and ensure created field exists
280
+ items = items.map((item) => ({
281
+ ...item,
282
+ duration: duration || item.duration,
283
+ created: item.created || new Date().toISOString(), // Ensure created field exists
284
+ }));
285
+ return items;
286
+ }
287
+ /**
288
+ * Fetch feed playlist (deterministic - exact match only)
289
+ *
290
+ * @param {string} playlistName - Exact playlist name
291
+ * @param {number} quantity - Number of items to fetch
292
+ * @param {number} duration - Duration per item
293
+ * @returns {Promise<Object>} Result with items
294
+ */
295
+ async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration = 10) {
296
+ const feedUrls = getFeedApiUrls();
297
+ console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
298
+ const result = await searchExactPlaylist(playlistName);
299
+ if (!result.success) {
300
+ console.log(chalk.yellow(` Playlist not found: ${result.error}`));
301
+ return {
302
+ success: false,
303
+ error: result.error,
304
+ items: [],
305
+ };
306
+ }
307
+ const items = extractPlaylistItems(result.playlist, quantity, duration);
308
+ console.log(chalk.green(`✓ Got ${items.length} item(s)\n`));
309
+ return {
310
+ success: true,
311
+ playlist: result.playlist,
312
+ items,
313
+ };
314
+ }
315
+ /**
316
+ * Search for playlists using fuzzy matching
317
+ *
318
+ * @param {string} playlistName - Playlist name (can be fuzzy)
319
+ * @param {number} quantity - Number of items to fetch
320
+ * @param {number} duration - Duration per item
321
+ * @returns {Promise<Object>} Result with best match and map for lookup
322
+ */
323
+ async function searchFeedPlaylists(playlistName, quantity = 5, duration = 10) {
324
+ const feedUrls = getFeedApiUrls();
325
+ console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
326
+ const result = await findBestMatchingPlaylist(playlistName);
327
+ if (!result.success) {
328
+ console.log(chalk.yellow(` Playlist not found: ${result.error}\n`));
329
+ return {
330
+ success: false,
331
+ error: result.error,
332
+ };
333
+ }
334
+ return {
335
+ success: true,
336
+ bestMatch: result.bestMatch,
337
+ playlistMap: result.playlistMap,
338
+ searchTerm: playlistName,
339
+ quantity,
340
+ duration,
341
+ };
342
+ }
343
+ /**
344
+ * Fetch specific playlist by ID or name and extract items
345
+ *
346
+ * @param {string} playlistIdOrName - Playlist ID, slug, or exact name
347
+ * @param {number} quantity - Number of items to fetch
348
+ * @param {number} duration - Duration per item
349
+ * @param {Object} playlistMap - Optional map of names to IDs for lookup
350
+ * @param {boolean} shuffle - Whether to shuffle and randomly select items
351
+ * @returns {Promise<Object>} Result with items
352
+ */
353
+ async function fetchPlaylistItems(playlistIdOrName, quantity = 5, duration = 10, playlistMap = null, shuffle = true) {
354
+ try {
355
+ let playlistId = playlistIdOrName;
356
+ let feedUrl = null;
357
+ // If playlistMap provided, look up the ID from the name
358
+ if (playlistMap && playlistMap[playlistIdOrName]) {
359
+ playlistId = playlistMap[playlistIdOrName].id;
360
+ feedUrl = playlistMap[playlistIdOrName].feedUrl;
361
+ }
362
+ const playlist = await getPlaylistById(playlistId, feedUrl);
363
+ const items = extractPlaylistItems(playlist, quantity, duration, shuffle);
364
+ console.log(chalk.green(`✓ Got ${items.length} item(s) from "${playlist.title || playlistId}"\n`));
365
+ return {
366
+ success: true,
367
+ playlist,
368
+ items,
369
+ };
370
+ }
371
+ catch (error) {
372
+ return {
373
+ success: false,
374
+ error: error.message,
375
+ items: [],
376
+ };
377
+ }
378
+ }
379
+ module.exports = {
380
+ searchExactPlaylist,
381
+ findBestMatchingPlaylist,
382
+ getPlaylistById,
383
+ extractPlaylistItems,
384
+ fetchFeedPlaylistDirect,
385
+ searchFeedPlaylists,
386
+ fetchPlaylistItems,
387
+ };
@@ -0,0 +1,176 @@
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.sendPlaylistToDevice = sendPlaylistToDevice;
41
+ const config_1 = require("../config");
42
+ const logger = __importStar(require("../logger"));
43
+ /**
44
+ * Send a DP1 playlist to an FF1 device using the cast API
45
+ *
46
+ * This function sends the entire DP1 JSON payload to a configured FF1 device.
47
+ * If a device name is provided, it searches for a device with that exact name.
48
+ * If no device name is provided, it uses the first configured device.
49
+ * The API-KEY header is only included if the device has an apiKey configured.
50
+ *
51
+ * @param {Object} params - Function parameters
52
+ * @param {Object} params.playlist - Complete DP1 v1.0.0 playlist object to send
53
+ * @param {string} [params.deviceName] - Name of the device to send to (exact match required)
54
+ * @returns {Promise<Object>} Result object
55
+ * @returns {boolean} returns.success - Whether the cast was successful
56
+ * @returns {string} [returns.device] - Device host that received the playlist
57
+ * @returns {string} [returns.deviceName] - Name of the device used
58
+ * @returns {Object} [returns.response] - Response from the device
59
+ * @returns {string} [returns.error] - Error message if failed
60
+ * @throws {Error} When device configuration is invalid or missing
61
+ * @example
62
+ * // Send to first device
63
+ * const result = await sendPlaylistToDevice({
64
+ * playlist: { version: '1.0.0', title: 'My Collection', items: [...] }
65
+ * });
66
+ *
67
+ * @example
68
+ * // Send to specific device by name
69
+ * const result = await sendPlaylistToDevice({
70
+ * playlist: { version: '1.0.0', title: 'My Collection', items: [...] },
71
+ * deviceName: 'Living Room Display'
72
+ * });
73
+ */
74
+ async function sendPlaylistToDevice({ playlist, deviceName, }) {
75
+ try {
76
+ // Validate input
77
+ if (!playlist || typeof playlist !== 'object') {
78
+ return {
79
+ success: false,
80
+ error: 'Invalid playlist: must provide a valid DP1 playlist object',
81
+ };
82
+ }
83
+ // Get device configuration
84
+ const deviceConfig = (0, config_1.getFF1DeviceConfig)();
85
+ if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
86
+ return {
87
+ success: false,
88
+ error: 'No FF1 devices configured. Please add devices to config.json under "ff1Devices"',
89
+ };
90
+ }
91
+ // Find device by name if provided, otherwise use first device
92
+ let device;
93
+ if (deviceName) {
94
+ device = deviceConfig.devices.find((d) => d.name === deviceName);
95
+ if (!device) {
96
+ const availableNames = deviceConfig.devices
97
+ .map((d) => d.name)
98
+ .filter(Boolean)
99
+ .join(', ');
100
+ return {
101
+ success: false,
102
+ error: `Device "${deviceName}" not found. Available devices: ${availableNames || 'none with names'}`,
103
+ };
104
+ }
105
+ logger.info(`Found device by name: ${deviceName}`);
106
+ }
107
+ else {
108
+ device = deviceConfig.devices[0];
109
+ logger.info('Using first configured device');
110
+ }
111
+ if (!device.host) {
112
+ return {
113
+ success: false,
114
+ error: 'Invalid device configuration: must include host',
115
+ };
116
+ }
117
+ logger.info(`Sending playlist to FF1 device: ${device.host}`);
118
+ // Construct API URL with optional topicID
119
+ let apiUrl = `${device.host}/api/cast`;
120
+ if (device.topicID && device.topicID.trim() !== '') {
121
+ apiUrl += `?topicID=${encodeURIComponent(device.topicID)}`;
122
+ logger.debug(`Using topicID: ${device.topicID}`);
123
+ }
124
+ // Wrap playlist in required structure
125
+ const requestBody = {
126
+ command: 'displayPlaylist',
127
+ request: {
128
+ dp1_call: playlist,
129
+ intent: { action: 'now_display' },
130
+ },
131
+ };
132
+ // Prepare headers
133
+ const headers = {
134
+ 'Content-Type': 'application/json',
135
+ };
136
+ // Add API-KEY header only if apiKey is provided
137
+ if (device.apiKey) {
138
+ headers['API-KEY'] = device.apiKey;
139
+ }
140
+ // Make the API request
141
+ const response = await fetch(apiUrl, {
142
+ method: 'POST',
143
+ headers,
144
+ body: JSON.stringify(requestBody),
145
+ });
146
+ // Check response status
147
+ if (!response.ok) {
148
+ const errorText = await response.text();
149
+ logger.error(`Failed to cast to device: ${response.status} ${response.statusText}`);
150
+ logger.debug(`Error details: ${errorText}`);
151
+ return {
152
+ success: false,
153
+ error: `Device returned error ${response.status}: ${response.statusText}`,
154
+ details: errorText,
155
+ };
156
+ }
157
+ // Parse response
158
+ const responseData = (await response.json());
159
+ logger.info('Successfully sent playlist to FF1 device');
160
+ logger.debug(`Device response: ${JSON.stringify(responseData)}`);
161
+ return {
162
+ success: true,
163
+ device: device.host,
164
+ deviceName: device.name || device.host,
165
+ response: responseData,
166
+ message: 'Playlist successfully sent to FF1 device',
167
+ };
168
+ }
169
+ catch (error) {
170
+ logger.error(`Error sending playlist to device: ${error.message}`);
171
+ return {
172
+ success: false,
173
+ error: error.message,
174
+ };
175
+ }
176
+ }