@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/config.json.example +96 -0
  4. package/dist/index.js +54 -0
  5. package/dist/src/ai-orchestrator/index.js +1019 -0
  6. package/dist/src/ai-orchestrator/registry.js +96 -0
  7. package/dist/src/commands/build.js +69 -0
  8. package/dist/src/commands/chat.js +189 -0
  9. package/dist/src/commands/config.js +68 -0
  10. package/dist/src/commands/device.js +278 -0
  11. package/dist/src/commands/helpers/config-files.js +62 -0
  12. package/dist/src/commands/helpers/device-discovery.js +111 -0
  13. package/dist/src/commands/helpers/playlist-display.js +161 -0
  14. package/dist/src/commands/helpers/prompt.js +65 -0
  15. package/dist/src/commands/helpers/ssh-helpers.js +44 -0
  16. package/dist/src/commands/play.js +110 -0
  17. package/dist/src/commands/publish.js +115 -0
  18. package/dist/src/commands/setup.js +225 -0
  19. package/dist/src/commands/sign.js +41 -0
  20. package/dist/src/commands/ssh.js +108 -0
  21. package/dist/src/commands/status.js +126 -0
  22. package/dist/src/commands/validate.js +18 -0
  23. package/dist/src/config.js +441 -0
  24. package/dist/src/intent-parser/index.js +1382 -0
  25. package/dist/src/intent-parser/utils.js +108 -0
  26. package/dist/src/logger.js +82 -0
  27. package/dist/src/main.js +459 -0
  28. package/dist/src/types.js +5 -0
  29. package/dist/src/utilities/address-validator.js +242 -0
  30. package/dist/src/utilities/device-default.js +36 -0
  31. package/dist/src/utilities/device-lookup.js +107 -0
  32. package/dist/src/utilities/device-normalize.js +62 -0
  33. package/dist/src/utilities/device-upsert.js +91 -0
  34. package/dist/src/utilities/domain-resolver.js +291 -0
  35. package/dist/src/utilities/ed25519-key-derive.js +155 -0
  36. package/dist/src/utilities/feed-fetcher.js +471 -0
  37. package/dist/src/utilities/ff1-compatibility.js +269 -0
  38. package/dist/src/utilities/ff1-device.js +250 -0
  39. package/dist/src/utilities/ff1-discovery.js +330 -0
  40. package/dist/src/utilities/functions.js +308 -0
  41. package/dist/src/utilities/index.js +469 -0
  42. package/dist/src/utilities/nft-indexer.js +1024 -0
  43. package/dist/src/utilities/playlist-builder.js +523 -0
  44. package/dist/src/utilities/playlist-publisher.js +131 -0
  45. package/dist/src/utilities/playlist-send.js +260 -0
  46. package/dist/src/utilities/playlist-signer.js +204 -0
  47. package/dist/src/utilities/playlist-signing-role.js +41 -0
  48. package/dist/src/utilities/playlist-source.js +128 -0
  49. package/dist/src/utilities/playlist-verifier.js +274 -0
  50. package/dist/src/utilities/ssh-access.js +145 -0
  51. package/dist/src/utils.js +48 -0
  52. package/docs/CONFIGURATION.md +206 -0
  53. package/docs/EXAMPLES.md +390 -0
  54. package/docs/FUNCTION_CALLING.md +96 -0
  55. package/docs/PROJECT_SPEC.md +228 -0
  56. package/docs/README.md +348 -0
  57. package/docs/RELEASING.md +73 -0
  58. package/package.json +76 -0
@@ -0,0 +1,469 @@
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
+ const logger = require('../logger');
12
+ const printedTokenCountKeys = new Set();
13
+ /**
14
+ * Detect blockchain from contract address format.
15
+ *
16
+ * @param {string} contractAddress - Contract address
17
+ * @returns {string} Chain name
18
+ */
19
+ function detectChainFromContractAddress(contractAddress) {
20
+ if (String(contractAddress || '').startsWith('KT')) {
21
+ return 'tezos';
22
+ }
23
+ return 'ethereum';
24
+ }
25
+ /**
26
+ * Convert indexer token rows to DP1 items.
27
+ *
28
+ * @param {Array<Object>} tokens - Raw indexer token rows
29
+ * @param {number} duration - Duration per item in seconds
30
+ * @returns {{ items: Array<Object>, skippedCount: number }} Converted items and skipped count
31
+ */
32
+ function convertIndexerTokensToDP1Items(tokens, duration) {
33
+ const items = [];
34
+ let skippedCount = 0;
35
+ for (const token of tokens) {
36
+ const contractAddr = token.contract_address || token.contractAddress || '';
37
+ const chain = detectChainFromContractAddress(contractAddr);
38
+ const tokenData = nftIndexer.mapIndexerDataToStandardFormat(token, chain);
39
+ if (tokenData.success) {
40
+ const dp1Result = nftIndexer.convertToDP1Item(tokenData, duration);
41
+ if (dp1Result.success && dp1Result.item) {
42
+ items.push(dp1Result.item);
43
+ }
44
+ else if (!dp1Result.success) {
45
+ skippedCount++;
46
+ }
47
+ }
48
+ }
49
+ return { items, skippedCount };
50
+ }
51
+ /**
52
+ * Check if an address looks like an EVM address.
53
+ *
54
+ * @param {string} address - Address string
55
+ * @returns {boolean} True when address matches 0x + 40 hex chars
56
+ */
57
+ function isLikelyEvmAddress(address) {
58
+ return /^0x[a-fA-F0-9]{40}$/.test(String(address || ''));
59
+ }
60
+ /**
61
+ * Query random tokens from a contract and convert to DP1 items.
62
+ *
63
+ * @param {string} contractAddress - Contract address
64
+ * @param {number|string} [quantity] - Number of random tokens to select
65
+ * @param {number} duration - Duration per item in seconds
66
+ * @returns {Promise<Array<Object>>} Array of DP1 items
67
+ */
68
+ async function queryTokensByContractAddress(contractAddress, quantity, duration = 10) {
69
+ console.log(chalk.cyan(` Fetching ${quantity || 100} random token(s) from contract ${contractAddress.substring(0, 10)}...`));
70
+ const limit = Math.min(quantity || 100, 100);
71
+ const result = await nftIndexer.queryTokensByContract(contractAddress, limit);
72
+ if (!result.success) {
73
+ console.log(chalk.yellow(` Could not fetch tokens from contract`));
74
+ return [];
75
+ }
76
+ if (result.tokens.length === 0) {
77
+ console.log(chalk.yellow(` No tokens found in contract ${contractAddress}`));
78
+ return [];
79
+ }
80
+ let selectedTokens = result.tokens;
81
+ if (typeof quantity === 'number' && selectedTokens.length > quantity) {
82
+ selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
83
+ }
84
+ console.log(chalk.dim(` Got ${selectedTokens.length} token(s)`));
85
+ const { items, skippedCount } = convertIndexerTokensToDP1Items(selectedTokens, duration);
86
+ if (skippedCount > 0) {
87
+ console.log(chalk.yellow(` Skipped ${skippedCount} token(s) with invalid data (data URIs or URLs too long)`));
88
+ }
89
+ return items;
90
+ }
91
+ /**
92
+ * Initialize utilities with configuration
93
+ *
94
+ * The indexer now uses a hardcoded production endpoint, so no configuration is needed.
95
+ * This function is kept for backwards compatibility.
96
+ *
97
+ * @param {Object} _config - Unused config parameter
98
+ */
99
+ function initializeUtilities(_config) {
100
+ nftIndexer.initializeIndexer();
101
+ }
102
+ /**
103
+ * Query tokens from an owner address
104
+ *
105
+ * Fetches all tokens owned by an address, with optional random selection.
106
+ * If no tokens found, instructs user to add address via Feral File mobile app.
107
+ * Supports pagination to fetch all tokens when quantity is "all".
108
+ *
109
+ * @param {string} ownerAddress - Owner wallet address
110
+ * @param {number|string} [quantity] - Number of random tokens to select, or "all" to fetch all tokens
111
+ * @param {number} duration - Duration per item in seconds
112
+ * @returns {Promise<Array>} Array of DP1 playlist items
113
+ */
114
+ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, options = {}) {
115
+ try {
116
+ const { suppressNotFoundGuidance = false } = options;
117
+ const shouldFetchAll = quantity === 'all' || quantity === undefined || quantity === null;
118
+ const batchSize = 50; // Fetch 50 tokens per page
119
+ let allTokens = [];
120
+ let offset = 0;
121
+ let hasMore = true;
122
+ // Fetch tokens with pagination if "all" is requested
123
+ if (shouldFetchAll) {
124
+ console.log(chalk.cyan(` Fetching all tokens from ${ownerAddress}...`));
125
+ while (hasMore) {
126
+ const result = await nftIndexer.queryTokensByOwner(ownerAddress, batchSize, offset);
127
+ if (!result.success) {
128
+ if (offset === 0) {
129
+ // First page failed
130
+ console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
131
+ if (!suppressNotFoundGuidance) {
132
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
133
+ }
134
+ return [];
135
+ }
136
+ // Subsequent pages failed - stop pagination but keep what we have
137
+ hasMore = false;
138
+ break;
139
+ }
140
+ if (result.tokens.length === 0) {
141
+ // No more tokens
142
+ hasMore = false;
143
+ break;
144
+ }
145
+ allTokens = allTokens.concat(result.tokens);
146
+ offset += batchSize;
147
+ console.log(chalk.dim(` → Fetched ${allTokens.length} tokens so far...`));
148
+ // If we got fewer tokens than the batch size, we've reached the end
149
+ if (result.tokens.length < batchSize) {
150
+ hasMore = false;
151
+ }
152
+ }
153
+ if (allTokens.length === 0 && offset === 0) {
154
+ console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
155
+ if (!suppressNotFoundGuidance) {
156
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
157
+ }
158
+ return [];
159
+ }
160
+ }
161
+ else {
162
+ // Fetch specific quantity with single request
163
+ const limit = Math.min(quantity, 100); // Cap at 100 per request
164
+ const result = await nftIndexer.queryTokensByOwner(ownerAddress, limit);
165
+ if (!result.success) {
166
+ console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
167
+ if (!suppressNotFoundGuidance) {
168
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
169
+ }
170
+ return [];
171
+ }
172
+ if (result.tokens.length === 0) {
173
+ console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
174
+ if (!suppressNotFoundGuidance) {
175
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
176
+ }
177
+ return [];
178
+ }
179
+ allTokens = result.tokens;
180
+ }
181
+ let selectedTokens = allTokens;
182
+ // Apply quantity limit with random selection (only if numeric quantity specified)
183
+ if (typeof quantity === 'number' && selectedTokens.length > quantity) {
184
+ selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
185
+ }
186
+ const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration}`;
187
+ if (!printedTokenCountKeys.has(tokenCountKey)) {
188
+ console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
189
+ printedTokenCountKeys.add(tokenCountKey);
190
+ }
191
+ const { items, skippedCount } = convertIndexerTokensToDP1Items(selectedTokens, duration);
192
+ if (skippedCount > 0) {
193
+ console.log(chalk.yellow(` Skipped ${skippedCount} token(s) with invalid data (data URIs or URLs too long)`));
194
+ }
195
+ return items;
196
+ }
197
+ catch (error) {
198
+ console.error(chalk.red(` Error: ${error.message}\n`));
199
+ throw error;
200
+ }
201
+ }
202
+ /**
203
+ * Query data for a single requirement (handles build_playlist, fetch_feed, and query_address)
204
+ *
205
+ * @param {Object} requirement - Requirement object
206
+ * @param {string} requirement.type - Requirement type (build_playlist, fetch_feed, or query_address)
207
+ * @param {string} [requirement.blockchain] - Blockchain network (for build_playlist)
208
+ * @param {string} [requirement.contractAddress] - Contract address (for build_playlist)
209
+ * @param {Array<string>} [requirement.tokenIds] - Token IDs (for build_playlist)
210
+ * @param {string} [requirement.ownerAddress] - Owner address (for query_address)
211
+ * @param {string} [requirement.playlistName] - Feed playlist name (for fetch_feed)
212
+ * @param {number} [requirement.quantity] - Number of items
213
+ * @param {number} duration - Duration per item in seconds
214
+ * @returns {Promise<Array>} Array of DP1 playlist items
215
+ */
216
+ async function queryRequirement(requirement, duration = 10) {
217
+ const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity } = requirement;
218
+ // Handle query_address type
219
+ if (type === 'query_address') {
220
+ // Check if ownerAddress is a domain name (.eth or .tez)
221
+ if (ownerAddress && (ownerAddress.endsWith('.eth') || ownerAddress.endsWith('.tez'))) {
222
+ logger.verbose(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
223
+ const resolution = await domainResolver.resolveDomain(ownerAddress);
224
+ if (resolution.resolved && resolution.address) {
225
+ console.log(chalk.green(`${resolution.domain} → ${resolution.address}`));
226
+ // Use resolved address instead of domain
227
+ return await queryTokensByAddress(resolution.address, quantity, duration);
228
+ }
229
+ else {
230
+ console.log(chalk.red(` Could not resolve domain ${ownerAddress}: ${resolution.error || 'Unknown error'}`));
231
+ return [];
232
+ }
233
+ }
234
+ else {
235
+ const isEvmAddress = isLikelyEvmAddress(ownerAddress);
236
+ const ownerItems = await queryTokensByAddress(ownerAddress, quantity, duration, {
237
+ suppressNotFoundGuidance: isEvmAddress,
238
+ });
239
+ if (ownerItems.length > 0) {
240
+ return ownerItems;
241
+ }
242
+ if (isEvmAddress) {
243
+ console.log(chalk.cyan(` No owned tokens found. Trying this address as a contract...`));
244
+ const contractItems = await queryTokensByContractAddress(ownerAddress, quantity, duration);
245
+ if (contractItems.length > 0) {
246
+ return contractItems;
247
+ }
248
+ console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
249
+ }
250
+ return [];
251
+ }
252
+ }
253
+ // Handle fetch_feed type
254
+ if (type === 'fetch_feed') {
255
+ console.log(chalk.cyan(`Getting items from "${playlistName}"...`));
256
+ const result = await feedFetcher.fetchFeedPlaylistDirect(playlistName, quantity, duration);
257
+ if (result.success && result.items) {
258
+ return result.items;
259
+ }
260
+ else {
261
+ console.log(chalk.yellow(` Could not fetch playlist: ${result.error || 'No items found'}\n`));
262
+ return [];
263
+ }
264
+ }
265
+ // Handle build_playlist type (original NFT querying logic)
266
+ console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
267
+ let items = [];
268
+ try {
269
+ // Check if we're querying by contract (no specific token IDs) or by specific token IDs
270
+ const isContractQuery = contractAddress && (!tokenIds || tokenIds.length === 0);
271
+ if (isContractQuery) {
272
+ const contractItems = await queryTokensByContractAddress(contractAddress, quantity, duration);
273
+ if (contractItems.length > 0) {
274
+ return contractItems;
275
+ }
276
+ console.log(chalk.cyan(` → Trying as owner address instead...`));
277
+ return await queryTokensByAddress(contractAddress, quantity, duration);
278
+ }
279
+ // Handle specific token IDs (original logic)
280
+ if (blockchain.toLowerCase() === 'tezos') {
281
+ // Tezos NFTs
282
+ if (tokenIds && tokenIds.length > 0) {
283
+ const tokens = tokenIds.map((tokenId) => ({
284
+ chain: 'tezos',
285
+ contractAddress,
286
+ tokenId,
287
+ }));
288
+ items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
289
+ }
290
+ else {
291
+ console.log(chalk.yellow(' No token IDs specified'));
292
+ }
293
+ }
294
+ else if (blockchain.toLowerCase() === 'ethereum' || blockchain.toLowerCase() === 'eth') {
295
+ // Ethereum NFTs (including Art Blocks, Feral File, etc.)
296
+ if (contractAddress && tokenIds && tokenIds.length > 0) {
297
+ const tokens = tokenIds.map((tokenId) => ({
298
+ chain: 'ethereum',
299
+ contractAddress,
300
+ tokenId,
301
+ }));
302
+ items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
303
+ }
304
+ else {
305
+ console.log(chalk.yellow(' Contract address and token IDs required'));
306
+ }
307
+ }
308
+ else {
309
+ console.log(chalk.yellow(` Unsupported blockchain: ${blockchain}`));
310
+ }
311
+ if (items.length > 0) {
312
+ console.log(chalk.green(`✓ Got ${items.length} item(s)`));
313
+ }
314
+ else {
315
+ console.log(chalk.yellow(` No items found. Check token IDs, contract address, or try querying by owner address.`));
316
+ }
317
+ // Apply quantity limit
318
+ if (quantity && items.length > quantity) {
319
+ items = items.slice(0, quantity);
320
+ }
321
+ }
322
+ catch (error) {
323
+ console.error(chalk.red(` Error: ${error.message}\n`));
324
+ throw error;
325
+ }
326
+ return items;
327
+ }
328
+ /**
329
+ * Build DP1 playlist from items
330
+ *
331
+ * Uses the core playlist-builder utility to create a DP1 v1.0.0 compliant playlist.
332
+ *
333
+ * @param {Array<Object>} items - Array of DP1 playlist items
334
+ * @param {string} [title] - Playlist title
335
+ * @param {string} [slug] - Playlist slug
336
+ * @returns {Promise<Object>} DP1 playlist
337
+ * @example
338
+ * const playlist = await buildDP1Playlist(items, 'My Playlist', 'my-playlist');
339
+ */
340
+ async function buildDP1Playlist(items, title, slug) {
341
+ return await playlistBuilder.buildDP1Playlist({ items, title, slug });
342
+ }
343
+ /**
344
+ * Send playlist to FF1 device
345
+ *
346
+ * @param {Object} playlist - DP1 playlist
347
+ * @param {string} [deviceName] - Device name
348
+ * @returns {Promise<Object>} Result
349
+ */
350
+ async function sendToDevice(playlist, deviceName) {
351
+ const { sendPlaylistToDevice } = require('./functions');
352
+ return await sendPlaylistToDevice({ playlist, deviceName });
353
+ }
354
+ /**
355
+ * Shuffle array using Fisher-Yates algorithm
356
+ *
357
+ * @param {Array} array - Array to shuffle
358
+ * @returns {Array} Shuffled array
359
+ */
360
+ function shuffleArray(array) {
361
+ for (let i = array.length - 1; i > 0; i--) {
362
+ const j = Math.floor(Math.random() * (i + 1));
363
+ [array[i], array[j]] = [array[j], array[i]];
364
+ }
365
+ return array;
366
+ }
367
+ /**
368
+ * Build playlist directly from requirements (deterministic, no AI)
369
+ *
370
+ * @param {Object} params - Playlist parameters
371
+ * @param {Array<Object>} params.requirements - Array of requirements
372
+ * @param {Object} params.playlistSettings - Playlist settings
373
+ * @param {Object} options - Build options
374
+ * @param {boolean} [options.verbose] - Verbose output
375
+ * @param {string} [options.outputPath] - Output file path
376
+ * @returns {Promise<Object>} Result with playlist
377
+ */
378
+ async function buildPlaylistDirect(params, options = {}) {
379
+ const { requirements, playlistSettings } = params;
380
+ const { verbose = false, outputPath = 'playlist.json' } = options;
381
+ const allItems = [];
382
+ const duration = playlistSettings.durationPerItem || 10;
383
+ console.log(chalk.cyan('\nBuilding playlist from your requirements...\n'));
384
+ // Process each requirement
385
+ for (let i = 0; i < requirements.length; i++) {
386
+ const requirement = requirements[i];
387
+ const reqNum = i + 1;
388
+ console.log(chalk.cyan(`[${reqNum}/${requirements.length}] ${requirement.blockchain || 'Source'}`));
389
+ try {
390
+ const items = await queryRequirement(requirement, duration);
391
+ allItems.push(...items);
392
+ }
393
+ catch (error) {
394
+ console.error(chalk.red(` Failed: ${error.message}`));
395
+ if (verbose) {
396
+ console.error(chalk.dim(error.stack));
397
+ }
398
+ }
399
+ }
400
+ if (allItems.length === 0) {
401
+ throw new Error('No items collected from any requirement');
402
+ }
403
+ // Apply ordering
404
+ let finalItems = allItems;
405
+ if (!playlistSettings.preserveOrder) {
406
+ console.log(chalk.cyan('Shuffling items...'));
407
+ finalItems = shuffleArray([...allItems]);
408
+ }
409
+ console.log(chalk.cyan(`Creating playlist with ${finalItems.length} items...`));
410
+ // Build DP1 playlist
411
+ const playlist = await buildDP1Playlist(finalItems, playlistSettings.title, playlistSettings.slug);
412
+ // Save playlist to file
413
+ const { savePlaylist } = require('../utils');
414
+ const savedPath = await savePlaylist(playlist, outputPath);
415
+ console.log(chalk.green(`✓ Playlist ready: ${savedPath}`));
416
+ // Send to device if requested
417
+ if (playlistSettings.deviceName !== undefined) {
418
+ console.log(chalk.cyan('\nSending to device...'));
419
+ await sendToDevice(playlist, playlistSettings.deviceName);
420
+ }
421
+ // Publish to feed server if requested
422
+ let publishResult = null;
423
+ if (playlistSettings.feedServer) {
424
+ console.log(chalk.cyan('\nPublishing to feed server...'));
425
+ try {
426
+ const { publishPlaylist } = require('./playlist-publisher');
427
+ publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl, playlistSettings.feedServer.apiKey);
428
+ if (publishResult.success) {
429
+ console.log(chalk.green(`✓ Published to feed server`));
430
+ if (publishResult.playlistId) {
431
+ console.log(chalk.dim(` Playlist ID: ${publishResult.playlistId}`));
432
+ }
433
+ if (publishResult.feedServer) {
434
+ console.log(chalk.dim(` Server: ${publishResult.feedServer}`));
435
+ }
436
+ }
437
+ else {
438
+ console.error(chalk.red(`Publish failed: ${publishResult.error}`));
439
+ if (publishResult.message) {
440
+ console.error(chalk.dim(` ${publishResult.message}`));
441
+ }
442
+ }
443
+ }
444
+ catch (error) {
445
+ console.error(chalk.red(`Publish failed: ${error.message}`));
446
+ if (verbose) {
447
+ console.error(chalk.dim(error.stack));
448
+ }
449
+ }
450
+ }
451
+ return {
452
+ playlist,
453
+ published: publishResult?.success || false,
454
+ publishResult,
455
+ };
456
+ }
457
+ module.exports = {
458
+ initializeUtilities,
459
+ queryRequirement,
460
+ queryTokensByAddress,
461
+ buildDP1Playlist,
462
+ sendToDevice,
463
+ resolveDomains: functions.resolveDomains,
464
+ shuffleArray,
465
+ buildPlaylistDirect,
466
+ feedFetcher,
467
+ // Export core playlist builder utilities
468
+ playlistBuilder,
469
+ };