@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,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
|
+
};
|