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,325 @@
1
+ /**
2
+ * Function Calling Layer
3
+ * These are the actual implementations called by AI orchestrator via function calling
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
+ const chalk = require('chalk');
39
+ const playlistBuilder = require('./playlist-builder');
40
+ const ff1Device = require('./ff1-device');
41
+ const domainResolver = require('./domain-resolver');
42
+ /**
43
+ * Build DP1 v1.0.0 compliant playlist
44
+ *
45
+ * This is the actual implementation called by AI orchestrator's function calling.
46
+ * Uses core playlist-builder utilities.
47
+ *
48
+ * @param {Object} params - Build parameters
49
+ * @param {Array<Object>} params.items - Playlist items
50
+ * @param {string} [params.title] - Playlist title (auto-generated if not provided)
51
+ * @param {string} [params.slug] - Playlist slug (auto-generated if not provided)
52
+ * @returns {Promise<Object>} DP1 playlist
53
+ * @example
54
+ * const playlist = await buildDP1Playlist({ items, title: 'My Playlist' });
55
+ */
56
+ async function buildDP1Playlist(params) {
57
+ const { items, title, slug } = params;
58
+ return await playlistBuilder.buildDP1Playlist({ items, title, slug });
59
+ }
60
+ /**
61
+ * Send playlist to FF1 device
62
+ *
63
+ * This is the actual implementation called by AI orchestrator's function calling.
64
+ *
65
+ * @param {Object} params - Send parameters
66
+ * @param {Object} params.playlist - DP1 playlist
67
+ * @param {string} [params.deviceName] - Device name (null for first device)
68
+ * @returns {Promise<Object>} Result
69
+ * @returns {boolean} returns.success - Whether send succeeded
70
+ * @returns {string} [returns.deviceHost] - Device host address
71
+ * @returns {string} [returns.deviceName] - Device name
72
+ * @returns {string} [returns.error] - Error message if failed
73
+ * @example
74
+ * const result = await sendPlaylistToDevice({ playlist, deviceName: 'MyDevice' });
75
+ */
76
+ async function sendPlaylistToDevice(params) {
77
+ const { playlist, deviceName } = params;
78
+ const result = await ff1Device.sendPlaylistToDevice({
79
+ playlist,
80
+ deviceName,
81
+ });
82
+ if (result.success) {
83
+ console.log(chalk.green('\n✓ Sent to device'));
84
+ if (result.deviceName) {
85
+ console.log(chalk.gray(` ${result.deviceName}`));
86
+ }
87
+ }
88
+ else {
89
+ console.error(chalk.red('\n✗ Could not send to device'));
90
+ if (result.error) {
91
+ console.error(chalk.red(` ${result.error}`));
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+ /**
97
+ * Resolve blockchain domain names to addresses
98
+ *
99
+ * This is the actual implementation called by AI orchestrator's function calling.
100
+ * Supports ENS (.eth) and TNS (.tez) domains with batch resolution.
101
+ *
102
+ * @param {Object} params - Resolution parameters
103
+ * @param {Array<string>} params.domains - Array of domain names to resolve
104
+ * @param {boolean} [params.displayResults] - Whether to display results (default: true)
105
+ * @returns {Promise<Object>} Resolution result
106
+ * @returns {boolean} returns.success - Whether at least one domain was resolved
107
+ * @returns {Object} returns.domainMap - Map of domain to resolved address
108
+ * @returns {Array<Object>} returns.resolutions - Detailed resolution results
109
+ * @returns {Array<string>} returns.errors - Array of error messages
110
+ * @example
111
+ * const result = await resolveDomains({ domains: ['vitalik.eth', 'alice.tez'] });
112
+ * console.log(result.domainMap); // { 'vitalik.eth': '0x...', 'alice.tez': 'tz...' }
113
+ */
114
+ async function resolveDomains(params) {
115
+ const { domains, displayResults = true } = params;
116
+ if (!domains || !Array.isArray(domains) || domains.length === 0) {
117
+ const error = 'No domains provided for resolution';
118
+ console.error(chalk.red(`\n✗ ${error}`));
119
+ return {
120
+ success: false,
121
+ domainMap: {},
122
+ resolutions: [],
123
+ errors: [error],
124
+ };
125
+ }
126
+ const result = await domainResolver.resolveDomainsBatch(domains);
127
+ if (displayResults) {
128
+ domainResolver.displayResolutionResults(result);
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * Verify a playlist against DP-1 specification
134
+ *
135
+ * This is the actual implementation called by AI orchestrator's function calling.
136
+ * Uses dp1-js library for standards-compliant validation. Must be called before
137
+ * sending a playlist to a device.
138
+ *
139
+ * @param {Object} params - Verification parameters
140
+ * @param {Object} params.playlist - Playlist object to verify
141
+ * @returns {Promise<Object>} Verification result
142
+ * @returns {boolean} returns.valid - Whether playlist is valid
143
+ * @returns {string} [returns.error] - Error message if invalid
144
+ * @returns {Array<Object>} [returns.details] - Detailed validation errors
145
+ * @example
146
+ * const result = await verifyPlaylist({ playlist });
147
+ * if (result.valid) {
148
+ * console.log('Playlist is valid');
149
+ * } else {
150
+ * console.error('Invalid:', result.error);
151
+ * }
152
+ */
153
+ async function verifyPlaylist(params) {
154
+ const { playlist } = params;
155
+ if (!playlist) {
156
+ return {
157
+ valid: false,
158
+ error: 'No playlist provided for verification',
159
+ };
160
+ }
161
+ console.log(chalk.cyan('\nValidating playlist...'));
162
+ // Dynamic import to avoid circular dependency
163
+ const playlistVerifier = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
164
+ const verify = playlistVerifier.verifyPlaylist ||
165
+ (playlistVerifier.default && playlistVerifier.default.verifyPlaylist) ||
166
+ playlistVerifier.default;
167
+ if (typeof verify !== 'function') {
168
+ return {
169
+ valid: false,
170
+ error: 'Playlist verifier is not available',
171
+ };
172
+ }
173
+ const result = verify(playlist);
174
+ if (result.valid) {
175
+ console.log(chalk.green('✓ Playlist looks good'));
176
+ if (playlist.title) {
177
+ console.log(chalk.gray(` Title: ${playlist.title}`));
178
+ }
179
+ if (playlist.items) {
180
+ console.log(chalk.gray(` Items: ${playlist.items.length}`));
181
+ }
182
+ console.log();
183
+ }
184
+ else {
185
+ console.error(chalk.red('✗ Playlist has issues'));
186
+ console.error(chalk.red(` ${result.error}`));
187
+ if (result.details && result.details.length > 0) {
188
+ console.log(chalk.yellow('\n Missing or invalid fields:'));
189
+ result.details.forEach((detail) => {
190
+ console.log(chalk.yellow(` • ${detail.path}: ${detail.message}`));
191
+ });
192
+ }
193
+ console.log();
194
+ }
195
+ return result;
196
+ }
197
+ /**
198
+ * Verify and validate Ethereum and Tezos addresses
199
+ *
200
+ * This function is called by the intent parser to validate addresses entered by users.
201
+ * It provides detailed feedback on address validity and format issues.
202
+ *
203
+ * @param {Object} params - Verification parameters
204
+ * @param {Array<string>} params.addresses - Array of addresses to verify
205
+ * @returns {Promise<Object>} Verification result
206
+ * @returns {boolean} returns.valid - Whether all addresses are valid
207
+ * @returns {Array<Object>} returns.results - Detailed validation for each address
208
+ * @returns {Array<string>} returns.errors - List of validation errors
209
+ * @example
210
+ * const result = await verifyAddresses({
211
+ * addresses: ['0x1234567890123456789012345678901234567890', 'tz1VSUr8wwNhLAzempoch5d6hLKEUNvD14']
212
+ * });
213
+ * if (!result.valid) {
214
+ * result.errors.forEach(err => console.error(err));
215
+ * }
216
+ */
217
+ async function verifyAddresses(params) {
218
+ const { addresses } = params;
219
+ if (!addresses || !Array.isArray(addresses) || addresses.length === 0) {
220
+ return {
221
+ valid: false,
222
+ results: [],
223
+ errors: ['No addresses provided for verification'],
224
+ };
225
+ }
226
+ // Dynamic import to avoid circular dependency
227
+ const addressValidator = await Promise.resolve().then(() => __importStar(require('./address-validator')));
228
+ const validateAddresses = addressValidator.validateAddresses ||
229
+ (addressValidator.default && addressValidator.default.validateAddresses) ||
230
+ addressValidator.default;
231
+ if (typeof validateAddresses !== 'function') {
232
+ return {
233
+ valid: false,
234
+ results: [],
235
+ errors: ['Address validator is not available'],
236
+ };
237
+ }
238
+ const result = validateAddresses(addresses);
239
+ // Display results
240
+ if (result.valid) {
241
+ console.log(chalk.green('\n✓ All addresses are valid'));
242
+ result.results.forEach((r) => {
243
+ const typeLabel = r.type === 'ethereum'
244
+ ? 'Ethereum'
245
+ : r.type === 'ens'
246
+ ? 'ENS Domain'
247
+ : r.type === 'tezos-domain'
248
+ ? 'Tezos Domain'
249
+ : r.type === 'contract'
250
+ ? 'Tezos Contract'
251
+ : 'Tezos User';
252
+ console.log(chalk.gray(` • ${r.address} (${typeLabel})`));
253
+ if (r.normalized) {
254
+ console.log(chalk.gray(` Checksummed: ${r.normalized}`));
255
+ }
256
+ });
257
+ console.log();
258
+ }
259
+ else {
260
+ console.error(chalk.red('\n✗ Address validation failed'));
261
+ result.errors.forEach((err) => {
262
+ console.error(chalk.red(` • ${err}`));
263
+ });
264
+ console.log();
265
+ }
266
+ return result;
267
+ }
268
+ /**
269
+ * Get list of configured FF1 devices
270
+ *
271
+ * This function retrieves the list of all configured FF1 devices from config.
272
+ * Called by intent parser to resolve generic device references like "FF1", "my device".
273
+ *
274
+ * @returns {Promise<Object>} Device list result
275
+ * @returns {boolean} returns.success - Whether devices were retrieved
276
+ * @returns {Array<Object>} returns.devices - Array of device configurations
277
+ * @returns {string} returns.devices[].name - Device name
278
+ * @returns {string} returns.devices[].host - Device host URL
279
+ * @returns {string} [returns.devices[].topicID] - Optional topic ID
280
+ * @returns {string} [returns.error] - Error message if no devices configured
281
+ * @example
282
+ * const result = await getConfiguredDevices();
283
+ * if (result.success && result.devices.length > 0) {
284
+ * const firstDevice = result.devices[0].name;
285
+ * }
286
+ */
287
+ async function getConfiguredDevices() {
288
+ const configModule = await Promise.resolve().then(() => __importStar(require('../config')));
289
+ const getFF1DeviceConfig = configModule.getFF1DeviceConfig ||
290
+ (configModule.default && configModule.default.getFF1DeviceConfig) ||
291
+ configModule.default;
292
+ if (typeof getFF1DeviceConfig !== 'function') {
293
+ return {
294
+ success: false,
295
+ devices: [],
296
+ error: 'FF1 device configuration is not available',
297
+ };
298
+ }
299
+ const deviceConfig = getFF1DeviceConfig();
300
+ if (!deviceConfig.devices || deviceConfig.devices.length === 0) {
301
+ return {
302
+ success: false,
303
+ devices: [],
304
+ error: 'No FF1 devices configured',
305
+ };
306
+ }
307
+ // Return sanitized device list (without API keys)
308
+ const devices = deviceConfig.devices.map((d) => ({
309
+ name: d.name || d.host,
310
+ host: d.host,
311
+ topicID: d.topicID,
312
+ }));
313
+ return {
314
+ success: true,
315
+ devices,
316
+ };
317
+ }
318
+ module.exports = {
319
+ buildDP1Playlist,
320
+ sendPlaylistToDevice,
321
+ resolveDomains,
322
+ verifyPlaylist,
323
+ getConfiguredDevices,
324
+ verifyAddresses,
325
+ };
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Utilities - Actual function implementations
3
+ * Contains the real logic for querying NFTs and building playlists
4
+ */
5
+ const chalk = require('chalk');
6
+ const nftIndexer = require('./nft-indexer');
7
+ const feedFetcher = require('./feed-fetcher');
8
+ const playlistBuilder = require('./playlist-builder');
9
+ const functions = require('./functions');
10
+ const domainResolver = require('./domain-resolver');
11
+ /**
12
+ * Initialize utilities with configuration
13
+ *
14
+ * The indexer now uses a hardcoded production endpoint, so no configuration is needed.
15
+ * This function is kept for backwards compatibility.
16
+ *
17
+ * @param {Object} _config - Unused config parameter
18
+ */
19
+ function initializeUtilities(_config) {
20
+ nftIndexer.initializeIndexer();
21
+ }
22
+ /**
23
+ * Query tokens from an owner address
24
+ *
25
+ * Fetches all tokens owned by an address, with optional random selection.
26
+ * If no tokens found, instructs user to add address via Feral File mobile app.
27
+ * Supports pagination to fetch all tokens when quantity is "all".
28
+ *
29
+ * @param {string} ownerAddress - Owner wallet address
30
+ * @param {number|string} [quantity] - Number of random tokens to select, or "all" to fetch all tokens
31
+ * @param {number} duration - Duration per item in seconds
32
+ * @returns {Promise<Array>} Array of DP1 playlist items
33
+ */
34
+ async function queryTokensByAddress(ownerAddress, quantity, duration = 10) {
35
+ try {
36
+ const shouldFetchAll = quantity === 'all' || quantity === undefined || quantity === null;
37
+ const batchSize = 100; // Fetch 100 tokens per page
38
+ let allTokens = [];
39
+ let offset = 0;
40
+ let hasMore = true;
41
+ // Fetch tokens with pagination if "all" is requested
42
+ if (shouldFetchAll) {
43
+ console.log(chalk.cyan(` Fetching all tokens from ${ownerAddress}...`));
44
+ while (hasMore) {
45
+ const result = await nftIndexer.queryTokensByOwner(ownerAddress, batchSize, offset);
46
+ if (!result.success) {
47
+ if (offset === 0) {
48
+ // First page failed
49
+ console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
50
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
51
+ return [];
52
+ }
53
+ // Subsequent pages failed - stop pagination but keep what we have
54
+ hasMore = false;
55
+ break;
56
+ }
57
+ if (result.tokens.length === 0) {
58
+ // No more tokens
59
+ hasMore = false;
60
+ break;
61
+ }
62
+ allTokens = allTokens.concat(result.tokens);
63
+ offset += batchSize;
64
+ console.log(chalk.gray(` → Fetched ${allTokens.length} tokens so far...`));
65
+ // If we got fewer tokens than the batch size, we've reached the end
66
+ if (result.tokens.length < batchSize) {
67
+ hasMore = false;
68
+ }
69
+ }
70
+ if (allTokens.length === 0 && offset === 0) {
71
+ console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
72
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
73
+ return [];
74
+ }
75
+ }
76
+ else {
77
+ // Fetch specific quantity with single request
78
+ const limit = Math.min(quantity, 100); // Cap at 100 per request
79
+ const result = await nftIndexer.queryTokensByOwner(ownerAddress, limit);
80
+ if (!result.success) {
81
+ console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
82
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
83
+ return [];
84
+ }
85
+ if (result.tokens.length === 0) {
86
+ console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
87
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
88
+ return [];
89
+ }
90
+ allTokens = result.tokens;
91
+ }
92
+ let selectedTokens = allTokens;
93
+ // Apply quantity limit with random selection (only if numeric quantity specified)
94
+ if (typeof quantity === 'number' && selectedTokens.length > quantity) {
95
+ selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
96
+ }
97
+ console.log(chalk.grey(`✓ Got ${selectedTokens.length} token(s)`));
98
+ // Convert tokens to DP1 items
99
+ const items = [];
100
+ let skippedCount = 0;
101
+ for (const token of selectedTokens) {
102
+ // Detect blockchain from contract address (support both camelCase and snake_case)
103
+ let chain = 'ethereum';
104
+ const contractAddr = token.contract_address || token.contractAddress || '';
105
+ if (contractAddr.startsWith('KT')) {
106
+ chain = 'tezos';
107
+ }
108
+ // Map indexer token data to standard format
109
+ const tokenData = nftIndexer.mapIndexerDataToStandardFormat(token, chain);
110
+ if (tokenData.success) {
111
+ const dp1Result = nftIndexer.convertToDP1Item(tokenData, duration);
112
+ if (dp1Result.success && dp1Result.item) {
113
+ items.push(dp1Result.item);
114
+ }
115
+ else if (!dp1Result.success) {
116
+ skippedCount++;
117
+ }
118
+ }
119
+ }
120
+ if (skippedCount > 0) {
121
+ console.log(chalk.yellow(` ⚠ Skipped ${skippedCount} token(s) with invalid data (data URIs or URLs too long)`));
122
+ }
123
+ return items;
124
+ }
125
+ catch (error) {
126
+ console.error(chalk.red(` Error: ${error.message}\n`));
127
+ throw error;
128
+ }
129
+ }
130
+ /**
131
+ * Query data for a single requirement (handles build_playlist, fetch_feed, and query_address)
132
+ *
133
+ * @param {Object} requirement - Requirement object
134
+ * @param {string} requirement.type - Requirement type (build_playlist, fetch_feed, or query_address)
135
+ * @param {string} [requirement.blockchain] - Blockchain network (for build_playlist)
136
+ * @param {string} [requirement.contractAddress] - Contract address (for build_playlist)
137
+ * @param {Array<string>} [requirement.tokenIds] - Token IDs (for build_playlist)
138
+ * @param {string} [requirement.ownerAddress] - Owner address (for query_address)
139
+ * @param {string} [requirement.playlistName] - Feed playlist name (for fetch_feed)
140
+ * @param {number} [requirement.quantity] - Number of items
141
+ * @param {number} duration - Duration per item in seconds
142
+ * @returns {Promise<Array>} Array of DP1 playlist items
143
+ */
144
+ async function queryRequirement(requirement, duration = 10) {
145
+ const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity } = requirement;
146
+ // Handle query_address type
147
+ if (type === 'query_address') {
148
+ // Check if ownerAddress is a domain name (.eth or .tez)
149
+ if (ownerAddress && (ownerAddress.endsWith('.eth') || ownerAddress.endsWith('.tez'))) {
150
+ console.log(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
151
+ const resolution = await domainResolver.resolveDomain(ownerAddress);
152
+ if (resolution.resolved && resolution.address) {
153
+ console.log(chalk.gray(` ${resolution.domain} → ${resolution.address}`));
154
+ // Use resolved address instead of domain
155
+ return await queryTokensByAddress(resolution.address, quantity, duration);
156
+ }
157
+ else {
158
+ console.log(chalk.red(` Could not resolve domain ${ownerAddress}: ${resolution.error || 'Unknown error'}`));
159
+ return [];
160
+ }
161
+ }
162
+ else {
163
+ return await queryTokensByAddress(ownerAddress, quantity, duration);
164
+ }
165
+ }
166
+ // Handle fetch_feed type
167
+ if (type === 'fetch_feed') {
168
+ console.log(chalk.cyan(`Getting items from "${playlistName}"...`));
169
+ const result = await feedFetcher.fetchFeedPlaylistDirect(playlistName, quantity, duration);
170
+ if (result.success && result.items) {
171
+ return result.items;
172
+ }
173
+ else {
174
+ console.log(chalk.yellow(` Could not fetch playlist: ${result.error || 'No items found'}\n`));
175
+ return [];
176
+ }
177
+ }
178
+ // Handle build_playlist type (original NFT querying logic)
179
+ console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
180
+ let items = [];
181
+ try {
182
+ // Handle different blockchain types
183
+ if (blockchain.toLowerCase() === 'tezos') {
184
+ // Tezos NFTs
185
+ if (tokenIds && tokenIds.length > 0) {
186
+ const tokens = tokenIds.map((tokenId) => ({
187
+ chain: 'tezos',
188
+ contractAddress,
189
+ tokenId,
190
+ }));
191
+ items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
192
+ }
193
+ else {
194
+ console.log(chalk.yellow(' No token IDs specified'));
195
+ }
196
+ }
197
+ else if (blockchain.toLowerCase() === 'ethereum' || blockchain.toLowerCase() === 'eth') {
198
+ // Ethereum NFTs (including Art Blocks, Feral File, etc.)
199
+ if (contractAddress && tokenIds && tokenIds.length > 0) {
200
+ const tokens = tokenIds.map((tokenId) => ({
201
+ chain: 'ethereum',
202
+ contractAddress,
203
+ tokenId,
204
+ }));
205
+ items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
206
+ }
207
+ else {
208
+ console.log(chalk.yellow(' Contract address and token IDs required'));
209
+ }
210
+ }
211
+ else {
212
+ console.log(chalk.yellow(` Unsupported blockchain: ${blockchain}`));
213
+ }
214
+ if (items.length > 0) {
215
+ console.log(chalk.green(`✓ Got ${items.length} item(s)`));
216
+ }
217
+ else {
218
+ console.log(chalk.yellow(` No items found. Check token IDs, contract address, or try querying by owner address.`));
219
+ }
220
+ // Apply quantity limit
221
+ if (quantity && items.length > quantity) {
222
+ items = items.slice(0, quantity);
223
+ }
224
+ }
225
+ catch (error) {
226
+ console.error(chalk.red(` Error: ${error.message}\n`));
227
+ throw error;
228
+ }
229
+ return items;
230
+ }
231
+ /**
232
+ * Build DP1 playlist from items
233
+ *
234
+ * Uses the core playlist-builder utility to create a DP1 v1.0.0 compliant playlist.
235
+ *
236
+ * @param {Array<Object>} items - Array of DP1 playlist items
237
+ * @param {string} [title] - Playlist title
238
+ * @param {string} [slug] - Playlist slug
239
+ * @returns {Promise<Object>} DP1 playlist
240
+ * @example
241
+ * const playlist = await buildDP1Playlist(items, 'My Playlist', 'my-playlist');
242
+ */
243
+ async function buildDP1Playlist(items, title, slug) {
244
+ return await playlistBuilder.buildDP1Playlist({ items, title, slug });
245
+ }
246
+ /**
247
+ * Send playlist to FF1 device
248
+ *
249
+ * @param {Object} playlist - DP1 playlist
250
+ * @param {string} [deviceName] - Device name
251
+ * @returns {Promise<Object>} Result
252
+ */
253
+ async function sendToDevice(playlist, deviceName) {
254
+ const { sendPlaylistToDevice } = require('./functions');
255
+ return await sendPlaylistToDevice({ playlist, deviceName });
256
+ }
257
+ /**
258
+ * Shuffle array using Fisher-Yates algorithm
259
+ *
260
+ * @param {Array} array - Array to shuffle
261
+ * @returns {Array} Shuffled array
262
+ */
263
+ function shuffleArray(array) {
264
+ for (let i = array.length - 1; i > 0; i--) {
265
+ const j = Math.floor(Math.random() * (i + 1));
266
+ [array[i], array[j]] = [array[j], array[i]];
267
+ }
268
+ return array;
269
+ }
270
+ /**
271
+ * Build playlist directly from requirements (deterministic, no AI)
272
+ *
273
+ * @param {Object} params - Playlist parameters
274
+ * @param {Array<Object>} params.requirements - Array of requirements
275
+ * @param {Object} params.playlistSettings - Playlist settings
276
+ * @param {Object} options - Build options
277
+ * @param {boolean} [options.verbose] - Verbose output
278
+ * @param {string} [options.outputPath] - Output file path
279
+ * @returns {Promise<Object>} Result with playlist
280
+ */
281
+ async function buildPlaylistDirect(params, options = {}) {
282
+ const { requirements, playlistSettings } = params;
283
+ const { verbose = false, outputPath = 'playlist.json' } = options;
284
+ const allItems = [];
285
+ const duration = playlistSettings.durationPerItem || 10;
286
+ console.log(chalk.cyan('\nBuilding playlist from your requirements...\n'));
287
+ // Process each requirement
288
+ for (let i = 0; i < requirements.length; i++) {
289
+ const requirement = requirements[i];
290
+ const reqNum = i + 1;
291
+ console.log(chalk.cyan(`[${reqNum}/${requirements.length}] ${requirement.blockchain || 'Source'}`));
292
+ try {
293
+ const items = await queryRequirement(requirement, duration);
294
+ allItems.push(...items);
295
+ }
296
+ catch (error) {
297
+ console.error(chalk.red(` ✗ Failed: ${error.message}`));
298
+ if (verbose) {
299
+ console.error(chalk.gray(error.stack));
300
+ }
301
+ }
302
+ }
303
+ if (allItems.length === 0) {
304
+ throw new Error('No items collected from any requirement');
305
+ }
306
+ // Apply ordering
307
+ let finalItems = allItems;
308
+ if (!playlistSettings.preserveOrder) {
309
+ console.log(chalk.cyan('Shuffling items...'));
310
+ finalItems = shuffleArray([...allItems]);
311
+ }
312
+ console.log(chalk.cyan(`Creating playlist with ${finalItems.length} items...`));
313
+ // Build DP1 playlist
314
+ const playlist = await buildDP1Playlist(finalItems, playlistSettings.title, playlistSettings.slug);
315
+ // Save playlist to file
316
+ const { savePlaylist } = require('../utils');
317
+ const savedPath = await savePlaylist(playlist, outputPath);
318
+ console.log(chalk.green(`✓ Playlist ready: ${savedPath}`));
319
+ // Send to device if requested
320
+ if (playlistSettings.deviceName !== undefined) {
321
+ console.log(chalk.cyan('\nSending to device...'));
322
+ await sendToDevice(playlist, playlistSettings.deviceName);
323
+ }
324
+ // Publish to feed server if requested
325
+ let publishResult = null;
326
+ if (playlistSettings.feedServer) {
327
+ console.log(chalk.cyan('\nPublishing to feed server...'));
328
+ try {
329
+ const { publishPlaylist } = require('./playlist-publisher');
330
+ publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl, playlistSettings.feedServer.apiKey);
331
+ if (publishResult.success) {
332
+ console.log(chalk.green(`✓ Published to feed server`));
333
+ if (publishResult.playlistId) {
334
+ console.log(chalk.gray(` Playlist ID: ${publishResult.playlistId}`));
335
+ }
336
+ if (publishResult.feedServer) {
337
+ console.log(chalk.gray(` Server: ${publishResult.feedServer}`));
338
+ }
339
+ }
340
+ else {
341
+ console.error(chalk.red(`✗ Failed to publish: ${publishResult.error}`));
342
+ if (publishResult.message) {
343
+ console.error(chalk.gray(` ${publishResult.message}`));
344
+ }
345
+ }
346
+ }
347
+ catch (error) {
348
+ console.error(chalk.red(`✗ Failed to publish: ${error.message}`));
349
+ if (verbose) {
350
+ console.error(chalk.gray(error.stack));
351
+ }
352
+ }
353
+ }
354
+ return {
355
+ playlist,
356
+ published: publishResult?.success || false,
357
+ publishResult,
358
+ };
359
+ }
360
+ module.exports = {
361
+ initializeUtilities,
362
+ queryRequirement,
363
+ queryTokensByAddress,
364
+ buildDP1Playlist,
365
+ sendToDevice,
366
+ resolveDomains: functions.resolveDomains,
367
+ shuffleArray,
368
+ buildPlaylistDirect,
369
+ feedFetcher,
370
+ // Export core playlist builder utilities
371
+ playlistBuilder,
372
+ };