@feralfile/cli 2.10.1 → 4.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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -1,273 +1,310 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Utilities - Actual function implementations
4
- * Contains the real logic for querying NFTs and building playlists
4
+ * Contains the real logic for querying NFTs and building playlists.
5
+ *
6
+ * Token data comes from the configured token source (token-source.ts):
7
+ * the Feral File indexer by default, Raster when `tokenSource: "raster"` is
8
+ * set. This module never names a vendor except to
9
+ * phrase guidance that only makes sense for one of them.
5
10
  */
6
11
  const chalk = require('chalk');
7
- const nftIndexer = require('./nft-indexer');
8
12
  const feedFetcher = require('./feed-fetcher');
9
13
  const playlistBuilder = require('./playlist-builder');
10
14
  const functions = require('./functions');
11
15
  const domainResolver = require('./domain-resolver');
12
16
  const logger = require('../logger');
17
+ const { getTokenSource, itemsOf, failureMessages, TokenSourceCapabilityError, } = require('./token-source');
13
18
  const printedTokenCountKeys = new Set();
19
+ /** Per-request cap every bounded listing has always applied. */
20
+ const MAX_LISTING = 100;
21
+ /**
22
+ * The indexer only knows wallets someone added in the Feral File app, so an
23
+ * empty answer there means "not added yet". Raster is pre-indexed, so the
24
+ * same advice would be wrong for it.
25
+ */
26
+ const ADD_ADDRESS_GUIDANCE = ' → Add this address in the Feral File mobile app, then try again with the CLI.';
14
27
  /**
15
- * Detect blockchain from contract address format.
28
+ * Check if an address looks like an EVM address.
16
29
  *
17
- * @param {string} contractAddress - Contract address
18
- * @returns {string} Chain name
30
+ * @param {string} address - Address string
31
+ * @returns {boolean} True when address matches 0x + 40 hex chars
32
+ */
33
+ function isLikelyEvmAddress(address) {
34
+ return /^0x[a-fA-F0-9]{40}$/.test(String(address || ''));
35
+ }
36
+ /**
37
+ * printAddressGuidance prints the add-in-app hint, only where it applies.
38
+ *
39
+ * @param {Object} source - Active token source
40
+ * @param {boolean} suppress - Caller will try another lookup first
19
41
  */
20
- function detectChainFromContractAddress(contractAddress) {
21
- if (String(contractAddress || '').startsWith('KT')) {
22
- return 'tezos';
42
+ function printAddressGuidance(source, suppress) {
43
+ if (!suppress && source.name === 'indexer') {
44
+ console.log(chalk.cyan(ADD_ADDRESS_GUIDANCE));
23
45
  }
24
- return 'ethereum';
25
46
  }
26
47
  /**
27
- * Convert indexer token rows to DP1 items.
48
+ * pickResults applies a numeric quantity as a random selection.
28
49
  *
29
- * @param {Array<Object>} tokens - Raw indexer token rows
30
- * @param {number} duration - Duration per item in seconds
31
- * @returns {{ items: Array<Object>, skippedCount: number }} Converted items and skipped count
50
+ * @param {Array<Object>} results - Listing results
51
+ * @param {number|string} [quantity] - Count, or "all"/undefined for everything
52
+ * @returns {Array<Object>} Selected results
32
53
  */
33
- function convertIndexerTokensToDP1Items(tokens, duration) {
34
- const items = [];
35
- let skippedCount = 0;
36
- for (const token of tokens) {
37
- const contractAddr = token.contract_address || token.contractAddress || '';
38
- const chain = detectChainFromContractAddress(contractAddr);
39
- const tokenData = nftIndexer.mapIndexerDataToStandardFormat(token, chain);
40
- if (tokenData.success) {
41
- const dp1Result = nftIndexer.convertToDP1Item(tokenData, duration);
42
- if (dp1Result.success && dp1Result.item) {
43
- items.push(dp1Result.item);
44
- }
45
- else if (!dp1Result.success) {
46
- skippedCount++;
47
- }
48
- }
54
+ function pickResults(results, quantity) {
55
+ if (typeof quantity === 'number' && results.length > quantity) {
56
+ return shuffleArray([...results]).slice(0, quantity);
49
57
  }
50
- return { items, skippedCount };
58
+ return results;
51
59
  }
52
60
  /**
53
- * Check if an address looks like an EVM address.
61
+ * reportSkips prints how many listed tokens produced no item, and why.
54
62
  *
55
- * @param {string} address - Address string
56
- * @returns {boolean} True when address matches 0x + 40 hex chars
63
+ * @param {Array<Object>} results - Results that were converted
64
+ * @param {number} skippedUnsupported - Tokens dropped for an unsupported chain
57
65
  */
58
- function isLikelyEvmAddress(address) {
59
- return /^0x[a-fA-F0-9]{40}$/.test(String(address || ''));
66
+ function reportSkips(results, skippedUnsupported) {
67
+ const failed = results.filter((result) => !result.success).length;
68
+ if (failed > 0) {
69
+ console.log(chalk.yellow(` Skipped ${failed} token(s) that could not be built into items`));
70
+ for (const message of failureMessages(results)) {
71
+ console.log(chalk.dim(` ${message}`));
72
+ }
73
+ }
74
+ if (skippedUnsupported > 0) {
75
+ console.log(chalk.yellow(` Skipped ${skippedUnsupported} token(s) on chains other than Ethereum and Tezos mainnet`));
76
+ }
60
77
  }
61
78
  /**
62
- * Query random tokens from a contract and convert to DP1 items.
79
+ * Query random tokens from a contract and convert to DP-1 items.
80
+ *
81
+ * A source that cannot list contracts (Raster) throws
82
+ * TokenSourceCapabilityError, which is deliberately not caught here: the
83
+ * requirement fails with that message instead of quietly building nothing.
63
84
  *
64
85
  * @param {string} contractAddress - Contract address
65
86
  * @param {number|string} [quantity] - Number of random tokens to select
66
87
  * @param {number} [duration] - Explicit display seconds; omit for auto timing
67
- * @returns {Promise<Array<Object>>} Array of DP1 items
88
+ * @param {Object} source - Active token source
89
+ * @returns {Promise<Array<Object>>} Array of DP-1 items
68
90
  */
69
- async function queryTokensByContractAddress(contractAddress, quantity, duration) {
70
- console.log(chalk.cyan(` Fetching ${quantity || 100} random token(s) from contract ${contractAddress.substring(0, 10)}...`));
71
- const limit = Math.min(quantity || 100, 100);
72
- const result = await nftIndexer.queryTokensByContract(contractAddress, limit);
73
- if (!result.success) {
74
- console.log(chalk.yellow(` Could not fetch tokens from contract`));
91
+ async function queryTokensByContractAddress(contractAddress, quantity, duration, source) {
92
+ console.log(chalk.cyan(` Fetching ${quantity || MAX_LISTING} random token(s) from contract ${contractAddress.substring(0, 10)}...`));
93
+ const limit = Math.min(typeof quantity === 'number' ? quantity : MAX_LISTING, MAX_LISTING);
94
+ let listing;
95
+ try {
96
+ listing = await source.listTokensByContract(contractAddress, { limit, duration });
97
+ }
98
+ catch (error) {
99
+ if (error instanceof TokenSourceCapabilityError) {
100
+ throw error;
101
+ }
102
+ console.log(chalk.yellow(` Could not fetch tokens from contract: ${error.message}`));
75
103
  return [];
76
104
  }
77
- if (result.tokens.length === 0) {
105
+ if (listing.results.length === 0) {
78
106
  console.log(chalk.yellow(` No tokens found in contract ${contractAddress}`));
79
107
  return [];
80
108
  }
81
- let selectedTokens = result.tokens;
82
- if (typeof quantity === 'number' && selectedTokens.length > quantity) {
83
- selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
109
+ const selected = pickResults(listing.results, quantity);
110
+ console.log(chalk.dim(` Got ${selected.length} token(s)`));
111
+ reportSkips(selected, listing.skippedUnsupported);
112
+ return itemsOf(selected);
113
+ }
114
+ /**
115
+ * Query tokens from an owner address.
116
+ *
117
+ * Fetches the tokens an address holds through the active token source, with
118
+ * optional random selection. `quantity` of "all" (or none) walks the whole
119
+ * wallet; a number stops early and samples from what was fetched.
120
+ *
121
+ * @param {string} ownerAddress - Owner wallet address
122
+ * @param {number|string} [quantity] - Number of random tokens to select, or "all"
123
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
124
+ * @param {Object} [options]
125
+ * @param {boolean} [options.suppressNotFoundGuidance] - Caller will try another lookup
126
+ * @param {Object} [source] - Token source; defaults to the configured one
127
+ * @returns {Promise<Array>} Array of DP-1 playlist items
128
+ */
129
+ async function queryTokensByAddress(ownerAddress, quantity, duration, options = {}, source = getTokenSource()) {
130
+ const { suppressNotFoundGuidance = false } = options;
131
+ const fetchAll = quantity === 'all' || quantity === undefined || quantity === null;
132
+ const limit = fetchAll ? 'all' : Math.min(quantity, MAX_LISTING);
133
+ if (fetchAll) {
134
+ console.log(chalk.cyan(` Fetching all tokens from ${ownerAddress}...`));
135
+ }
136
+ let listing;
137
+ try {
138
+ listing = await source.listTokensByOwner(ownerAddress, { limit, duration });
139
+ }
140
+ catch (error) {
141
+ console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}: ${error.message}`));
142
+ printAddressGuidance(source, suppressNotFoundGuidance);
143
+ return [];
144
+ }
145
+ if (listing.results.length === 0) {
146
+ console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
147
+ reportSkips([], listing.skippedUnsupported);
148
+ printAddressGuidance(source, suppressNotFoundGuidance);
149
+ return [];
84
150
  }
85
- console.log(chalk.dim(` Got ${selectedTokens.length} token(s)`));
86
- const { items, skippedCount } = convertIndexerTokensToDP1Items(selectedTokens, duration);
87
- if (skippedCount > 0) {
88
- console.log(chalk.yellow(` Skipped ${skippedCount} token(s) with invalid data (data URIs or URLs too long)`));
151
+ const selected = pickResults(listing.results, quantity);
152
+ const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration ?? 'auto'}`;
153
+ if (!printedTokenCountKeys.has(tokenCountKey)) {
154
+ console.log(chalk.dim(`Got ${selected.length} token(s)`));
155
+ printedTokenCountKeys.add(tokenCountKey);
89
156
  }
90
- return items;
157
+ reportSkips(selected, listing.skippedUnsupported);
158
+ return itemsOf(selected);
91
159
  }
92
160
  /**
93
- * Initialize utilities with configuration
94
- *
95
- * The indexer now uses a hardcoded production endpoint, so no configuration is needed.
96
- * This function is kept for backwards compatibility.
161
+ * resolveCoordinates builds items for explicit coordinates and reports misses.
97
162
  *
98
- * @param {Object} _config - Unused config parameter
163
+ * @param {Array<Object>} coords - Token coordinates
164
+ * @param {number} [duration] - Explicit display seconds
165
+ * @param {Object} source - Active token source
166
+ * @returns {Promise<Array<Object>>} DP-1 items for the resolved coordinates
99
167
  */
100
- function initializeUtilities(_config) {
101
- nftIndexer.initializeIndexer();
168
+ async function resolveCoordinates(coords, duration, source) {
169
+ const results = await source.resolveTokens(coords, { duration });
170
+ for (const message of failureMessages(results)) {
171
+ console.log(chalk.dim(` ${message}`));
172
+ }
173
+ return itemsOf(results);
102
174
  }
103
175
  /**
104
- * Query tokens from an owner address
176
+ * queryOwnerRequirement handles a query_address requirement.
105
177
  *
106
- * Fetches all tokens owned by an address, with optional random selection.
107
- * If no tokens found, instructs user to add address via Feral File mobile app.
108
- * Supports pagination to fetch all tokens when quantity is "all".
178
+ * An EVM address that holds nothing may be a contract, so the contract
179
+ * listing is tried next. On a source that cannot list contracts the user is
180
+ * told so, rather than shown an unexplained empty result.
109
181
  *
110
- * @param {string} ownerAddress - Owner wallet address
111
- * @param {number|string} [quantity] - Number of random tokens to select, or "all" to fetch all tokens
112
- * @param {number} [duration] - Explicit display seconds; omit for auto timing
113
- * @returns {Promise<Array>} Array of DP1 playlist items
182
+ * @param {string} ownerAddress - Address or .eth/.tez domain
183
+ * @param {number|string} [quantity] - Count or "all"
184
+ * @param {number} [duration] - Explicit display seconds
185
+ * @param {Object} source - Active token source
186
+ * @returns {Promise<Array<Object>>} DP-1 items
114
187
  */
115
- async function queryTokensByAddress(ownerAddress, quantity, duration, options = {}) {
116
- try {
117
- const { suppressNotFoundGuidance = false } = options;
118
- const shouldFetchAll = quantity === 'all' || quantity === undefined || quantity === null;
119
- const batchSize = 50; // Fetch 50 tokens per page
120
- let allTokens = [];
121
- let offset = 0;
122
- let hasMore = true;
123
- // Fetch tokens with pagination if "all" is requested
124
- if (shouldFetchAll) {
125
- console.log(chalk.cyan(` Fetching all tokens from ${ownerAddress}...`));
126
- while (hasMore) {
127
- const result = await nftIndexer.queryTokensByOwner(ownerAddress, batchSize, offset);
128
- if (!result.success) {
129
- if (offset === 0) {
130
- // First page failed
131
- console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
132
- if (!suppressNotFoundGuidance) {
133
- console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
134
- }
135
- return [];
136
- }
137
- // Subsequent pages failed - stop pagination but keep what we have
138
- hasMore = false;
139
- break;
140
- }
141
- if (result.tokens.length === 0) {
142
- // No more tokens
143
- hasMore = false;
144
- break;
145
- }
146
- allTokens = allTokens.concat(result.tokens);
147
- offset += batchSize;
148
- console.log(chalk.dim(` → Fetched ${allTokens.length} tokens so far...`));
149
- // If we got fewer tokens than the batch size, we've reached the end
150
- if (result.tokens.length < batchSize) {
151
- hasMore = false;
152
- }
153
- }
154
- if (allTokens.length === 0 && offset === 0) {
155
- console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
156
- if (!suppressNotFoundGuidance) {
157
- console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
158
- }
159
- return [];
160
- }
161
- }
162
- else {
163
- // Fetch specific quantity with single request
164
- const limit = Math.min(quantity, 100); // Cap at 100 per request
165
- const result = await nftIndexer.queryTokensByOwner(ownerAddress, limit);
166
- if (!result.success) {
167
- console.log(chalk.yellow(` Could not fetch tokens for ${ownerAddress}`));
168
- if (!suppressNotFoundGuidance) {
169
- console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
170
- }
171
- return [];
172
- }
173
- if (result.tokens.length === 0) {
174
- console.log(chalk.yellow(` No tokens found for ${ownerAddress}`));
175
- if (!suppressNotFoundGuidance) {
176
- console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
177
- }
178
- return [];
179
- }
180
- allTokens = result.tokens;
188
+ async function queryOwnerRequirement(ownerAddress, quantity, duration, source) {
189
+ if (ownerAddress && (ownerAddress.endsWith('.eth') || ownerAddress.endsWith('.tez'))) {
190
+ logger.verbose(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
191
+ const resolution = await domainResolver.resolveDomain(ownerAddress);
192
+ if (!resolution.resolved || !resolution.address) {
193
+ console.log(chalk.red(` Could not resolve domain ${ownerAddress}: ${resolution.error || 'Unknown error'}`));
194
+ return [];
181
195
  }
182
- let selectedTokens = allTokens;
183
- // Apply quantity limit with random selection (only if numeric quantity specified)
184
- if (typeof quantity === 'number' && selectedTokens.length > quantity) {
185
- selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
196
+ console.log(chalk.green(`${resolution.domain} → ${resolution.address}`));
197
+ return await queryTokensByAddress(resolution.address, quantity, duration, {}, source);
198
+ }
199
+ const isEvmAddress = isLikelyEvmAddress(ownerAddress);
200
+ const ownerItems = await queryTokensByAddress(ownerAddress, quantity, duration, { suppressNotFoundGuidance: isEvmAddress }, source);
201
+ if (ownerItems.length > 0 || !isEvmAddress) {
202
+ return ownerItems;
203
+ }
204
+ console.log(chalk.cyan(` No owned tokens found. Trying this address as a contract...`));
205
+ try {
206
+ const contractItems = await queryTokensByContractAddress(ownerAddress, quantity, duration, source);
207
+ if (contractItems.length > 0) {
208
+ return contractItems;
186
209
  }
187
- const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration ?? 'auto'}`;
188
- if (!printedTokenCountKeys.has(tokenCountKey)) {
189
- console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
190
- printedTokenCountKeys.add(tokenCountKey);
210
+ }
211
+ catch (error) {
212
+ if (!(error instanceof TokenSourceCapabilityError)) {
213
+ throw error;
191
214
  }
192
- const { items, skippedCount } = convertIndexerTokensToDP1Items(selectedTokens, duration);
193
- if (skippedCount > 0) {
194
- console.log(chalk.yellow(` Skipped ${skippedCount} token(s) with invalid data (data URIs or URLs too long)`));
215
+ console.log(chalk.yellow(` ${error.message}`));
216
+ return [];
217
+ }
218
+ printAddressGuidance(source, false);
219
+ return [];
220
+ }
221
+ /**
222
+ * queryTokenIdsRequirement handles a build_playlist requirement.
223
+ *
224
+ * With token ids, the coordinates are resolved directly. Without them the
225
+ * contract is listed; a source that cannot list contracts fails the
226
+ * requirement with its reason (no owner-address guess on that path).
227
+ *
228
+ * @param {Object} requirement - build_playlist requirement
229
+ * @param {number} [duration] - Explicit display seconds
230
+ * @param {Object} source - Active token source
231
+ * @param {Object} [report] - Filled with `missing` and `wanted` when named
232
+ * token ids do not all resolve
233
+ * @returns {Promise<Array<Object>>} DP-1 items
234
+ */
235
+ async function queryTokenIdsRequirement(requirement, duration, source, report = {}) {
236
+ const { blockchain, contractAddress, tokenIds, quantity } = requirement;
237
+ console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
238
+ if (contractAddress && (!tokenIds || tokenIds.length === 0)) {
239
+ const contractItems = await queryTokensByContractAddress(contractAddress, quantity, duration, source);
240
+ if (contractItems.length > 0) {
241
+ return contractItems;
195
242
  }
196
- return items;
243
+ console.log(chalk.cyan(` → Trying as owner address instead...`));
244
+ return await queryTokensByAddress(contractAddress, quantity, duration, {}, source);
197
245
  }
198
- catch (error) {
199
- console.error(chalk.red(` Error: ${error.message}\n`));
200
- throw error;
246
+ const chain = String(blockchain || '').toLowerCase();
247
+ const knownChain = chain === 'tezos' ? 'tezos' : chain === 'ethereum' || chain === 'eth' ? 'ethereum' : '';
248
+ if (!knownChain) {
249
+ console.log(chalk.yellow(` Unsupported blockchain: ${blockchain}`));
250
+ return [];
251
+ }
252
+ if (!contractAddress || !tokenIds || tokenIds.length === 0) {
253
+ console.log(chalk.yellow(' Contract address and token IDs required'));
254
+ return [];
255
+ }
256
+ const coords = tokenIds.map((tokenId) => ({ chain: knownChain, contractAddress, tokenId }));
257
+ let items = await resolveCoordinates(coords, duration, source);
258
+ // Named token ids are an exact request: each one that does not resolve is
259
+ // something the caller asked for and did not get. The build loop turns this
260
+ // into a failure, so a playlist missing a named work never exits 0. Count
261
+ // before the quantity cap below, which would otherwise hide the miss.
262
+ if (items.length < coords.length) {
263
+ report.missing = coords.length - items.length;
264
+ report.wanted = coords.length;
201
265
  }
266
+ if (items.length > 0) {
267
+ console.log(chalk.green(`✓ Got ${items.length} item(s)`));
268
+ }
269
+ else {
270
+ console.log(chalk.yellow(` No items found. Check token IDs, contract address, or try querying by owner address.`));
271
+ }
272
+ if (quantity && items.length > quantity) {
273
+ items = items.slice(0, quantity);
274
+ }
275
+ return items;
202
276
  }
203
277
  /**
204
- * Query data for a single requirement (handles build_playlist, fetch_feed, and query_address)
278
+ * Query data for a single requirement (build_playlist, fetch_feed,
279
+ * query_address, or feral_file_artwork).
205
280
  *
206
281
  * @param {Object} requirement - Requirement object
207
- * @param {string} requirement.type - Requirement type (build_playlist, fetch_feed, or query_address)
208
- * @param {string} [requirement.blockchain] - Blockchain network (for build_playlist)
209
- * @param {string} [requirement.contractAddress] - Contract address (for build_playlist)
210
- * @param {Array<string>} [requirement.tokenIds] - Token IDs (for build_playlist)
211
- * @param {string} [requirement.ownerAddress] - Owner address (for query_address)
212
- * @param {string} [requirement.playlistName] - Feed playlist name (for fetch_feed)
213
- * @param {number} [requirement.quantity] - Number of items
214
282
  * @param {number} [duration] - Explicit display seconds; omit for auto timing
215
- * @returns {Promise<Array>} Array of DP1 playlist items
283
+ * @param {Object} [source] - Token source; defaults to the configured one.
284
+ * buildPlaylistDirect passes one so a whole build uses one vendor.
285
+ * @param {Object} [report] - Collects partial misses for named token ids
286
+ * (see queryTokenIdsRequirement)
287
+ * @returns {Promise<Array>} Array of DP-1 playlist items
216
288
  */
217
- async function queryRequirement(requirement, duration) {
218
- const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity, artworkId, } = requirement;
219
- // Handle query_address type
289
+ async function queryRequirement(requirement, duration, source = getTokenSource(), report = {}) {
290
+ const { type, ownerAddress, playlistName, quantity, artworkId } = requirement;
220
291
  if (type === 'query_address') {
221
- // Check if ownerAddress is a domain name (.eth or .tez)
222
- if (ownerAddress && (ownerAddress.endsWith('.eth') || ownerAddress.endsWith('.tez'))) {
223
- logger.verbose(chalk.cyan(`\nResolving domain ${ownerAddress}...`));
224
- const resolution = await domainResolver.resolveDomain(ownerAddress);
225
- if (resolution.resolved && resolution.address) {
226
- console.log(chalk.green(`${resolution.domain} → ${resolution.address}`));
227
- // Use resolved address instead of domain
228
- return await queryTokensByAddress(resolution.address, quantity, duration);
229
- }
230
- else {
231
- console.log(chalk.red(` Could not resolve domain ${ownerAddress}: ${resolution.error || 'Unknown error'}`));
232
- return [];
233
- }
234
- }
235
- else {
236
- const isEvmAddress = isLikelyEvmAddress(ownerAddress);
237
- const ownerItems = await queryTokensByAddress(ownerAddress, quantity, duration, {
238
- suppressNotFoundGuidance: isEvmAddress,
239
- });
240
- if (ownerItems.length > 0) {
241
- return ownerItems;
242
- }
243
- if (isEvmAddress) {
244
- console.log(chalk.cyan(` No owned tokens found. Trying this address as a contract...`));
245
- const contractItems = await queryTokensByContractAddress(ownerAddress, quantity, duration);
246
- if (contractItems.length > 0) {
247
- return contractItems;
248
- }
249
- console.log(chalk.cyan(` → Add this address in the Feral File mobile app, then try again with the CLI.`));
250
- }
251
- return [];
252
- }
292
+ return await queryOwnerRequirement(ownerAddress, quantity, duration, source);
253
293
  }
254
- // Handle fetch_feed type
255
294
  if (type === 'fetch_feed') {
256
295
  console.log(chalk.cyan(`Getting items from "${playlistName}"...`));
257
296
  const result = await feedFetcher.fetchFeedPlaylistDirect(playlistName, quantity, duration);
258
297
  if (result.success && result.items) {
259
298
  return result.items;
260
299
  }
261
- else {
262
- console.log(chalk.yellow(` Could not fetch playlist: ${result.error || 'No items found'}\n`));
263
- return [];
264
- }
300
+ console.log(chalk.yellow(` Could not fetch playlist: ${result.error || 'No items found'}\n`));
301
+ return [];
265
302
  }
266
303
  if (type === 'feral_file_artwork') {
267
304
  console.log(chalk.cyan(`Resolving Feral File artwork ${artworkId}...`));
268
305
  const { parseFeralFileArtworkId, resolveFeralFileArtwork } = require('./feral-file-artwork');
269
306
  const coords = await resolveFeralFileArtwork(parseFeralFileArtworkId(artworkId));
270
- const items = await nftIndexer.getNFTTokenInfoBatch([coords], duration);
307
+ const items = await resolveCoordinates([coords], duration, source);
271
308
  if (items.length > 0) {
272
309
  console.log(chalk.green(`✓ Got ${items.length} item(s)`));
273
310
  }
@@ -276,68 +313,17 @@ async function queryRequirement(requirement, duration) {
276
313
  }
277
314
  return items;
278
315
  }
279
- // Handle build_playlist type (original NFT querying logic)
280
- console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
281
- let items = [];
282
316
  try {
283
- // Check if we're querying by contract (no specific token IDs) or by specific token IDs
284
- const isContractQuery = contractAddress && (!tokenIds || tokenIds.length === 0);
285
- if (isContractQuery) {
286
- const contractItems = await queryTokensByContractAddress(contractAddress, quantity, duration);
287
- if (contractItems.length > 0) {
288
- return contractItems;
289
- }
290
- console.log(chalk.cyan(` → Trying as owner address instead...`));
291
- return await queryTokensByAddress(contractAddress, quantity, duration);
292
- }
293
- // Handle specific token IDs (original logic)
294
- if (blockchain.toLowerCase() === 'tezos') {
295
- // Tezos NFTs
296
- if (tokenIds && tokenIds.length > 0) {
297
- const tokens = tokenIds.map((tokenId) => ({
298
- chain: 'tezos',
299
- contractAddress,
300
- tokenId,
301
- }));
302
- items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
303
- }
304
- else {
305
- console.log(chalk.yellow(' No token IDs specified'));
306
- }
307
- }
308
- else if (blockchain.toLowerCase() === 'ethereum' || blockchain.toLowerCase() === 'eth') {
309
- // Ethereum NFTs (including Art Blocks, Feral File, etc.)
310
- if (contractAddress && tokenIds && tokenIds.length > 0) {
311
- const tokens = tokenIds.map((tokenId) => ({
312
- chain: 'ethereum',
313
- contractAddress,
314
- tokenId,
315
- }));
316
- items = await nftIndexer.getNFTTokenInfoBatch(tokens, duration);
317
- }
318
- else {
319
- console.log(chalk.yellow(' Contract address and token IDs required'));
320
- }
321
- }
322
- else {
323
- console.log(chalk.yellow(` Unsupported blockchain: ${blockchain}`));
324
- }
325
- if (items.length > 0) {
326
- console.log(chalk.green(`✓ Got ${items.length} item(s)`));
327
- }
328
- else {
329
- console.log(chalk.yellow(` No items found. Check token IDs, contract address, or try querying by owner address.`));
330
- }
331
- // Apply quantity limit
332
- if (quantity && items.length > quantity) {
333
- items = items.slice(0, quantity);
334
- }
317
+ return await queryTokenIdsRequirement(requirement, duration, source, report);
335
318
  }
336
319
  catch (error) {
337
- console.error(chalk.red(` Error: ${error.message}\n`));
320
+ // A capability error is already a complete sentence that the build loop
321
+ // prints as the requirement's failure; printing it here too doubles it.
322
+ if (!(error instanceof TokenSourceCapabilityError)) {
323
+ console.error(chalk.red(` Error: ${error.message}\n`));
324
+ }
338
325
  throw error;
339
326
  }
340
- return items;
341
327
  }
342
328
  /**
343
329
  * Build DP1 playlist from items
@@ -420,27 +406,47 @@ function shuffleArray(array) {
420
406
  * @param {Object} options - Build options
421
407
  * @param {boolean} [options.verbose] - Verbose output
422
408
  * @param {string} [options.outputPath] - Output file path
423
- * @returns {Promise<Object>} Result with playlist
409
+ * @returns {Promise<Object>} Result with playlist, publish outcome, and
410
+ * `failures`: one line per requirement, send, or publish that did not happen
411
+ * (empty when everything asked for succeeded)
424
412
  */
425
413
  async function buildPlaylistDirect(params, options = {}) {
426
414
  const { requirements, playlistSettings } = params;
427
415
  const { verbose = false, outputPath = 'playlist.json' } = options;
428
416
  const allItems = [];
417
+ // Everything the caller asked for that did not happen. The playlist is still
418
+ // saved when some of it is missing, but the command must not report a clean
419
+ // success: an agent trusting the exit code would otherwise hand over a
420
+ // partial playlist, or claim a playlist is on the wall when the send failed.
421
+ const failures = [];
429
422
  // Undefined means auto timing: video/audio items omit duration and play
430
423
  // their natural length (DP-1 §4.1); static items get the configured default.
431
424
  const duration = playlistSettings.durationPerItem;
432
425
  console.log(chalk.cyan('\nBuilding playlist from your requirements...\n'));
426
+ // One provider for the whole build, so every requirement comes from the
427
+ // same vendor.
428
+ const source = getTokenSource();
433
429
  // Process each requirement
434
430
  for (let i = 0; i < requirements.length; i++) {
435
431
  const requirement = requirements[i];
436
432
  const reqNum = i + 1;
437
433
  console.log(chalk.cyan(`[${reqNum}/${requirements.length}] ${requirement.blockchain || 'Source'}`));
438
434
  try {
439
- const items = await queryRequirement(requirement, duration);
435
+ const report = {};
436
+ const items = await queryRequirement(requirement, duration, source, report);
440
437
  allItems.push(...items);
438
+ // Some requirement types report "nothing found" by returning no items
439
+ // rather than throwing (fetch_feed does). It is the same miss.
440
+ if (items.length === 0) {
441
+ failures.push(`Requirement ${reqNum}: no items found`);
442
+ }
443
+ else if (report.missing) {
444
+ failures.push(`Requirement ${reqNum}: ${report.missing} of ${report.wanted} named tokens could not be resolved`);
445
+ }
441
446
  }
442
447
  catch (error) {
443
448
  console.error(chalk.red(` Failed: ${error.message}`));
449
+ failures.push(`Requirement ${reqNum}: ${error.message}`);
444
450
  if (verbose) {
445
451
  console.error(chalk.dim(error.stack));
446
452
  }
@@ -465,7 +471,10 @@ async function buildPlaylistDirect(params, options = {}) {
465
471
  // Send to device if requested
466
472
  if (playlistSettings.deviceName !== undefined) {
467
473
  console.log(chalk.cyan('\nSending to device...'));
468
- await sendToDevice(playlist, playlistSettings.deviceName);
474
+ const sendResult = await sendToDevice(playlist, playlistSettings.deviceName);
475
+ if (!sendResult || !sendResult.success) {
476
+ failures.push(`Send to device: ${sendResult?.error || 'failed'}`);
477
+ }
469
478
  }
470
479
  // Publish to feed server if requested
471
480
  let publishResult = null;
@@ -485,6 +494,7 @@ async function buildPlaylistDirect(params, options = {}) {
485
494
  }
486
495
  else {
487
496
  console.error(chalk.red(`Publish failed: ${publishResult.error}`));
497
+ failures.push(`Publish: ${publishResult.error}`);
488
498
  if (publishResult.message) {
489
499
  console.error(chalk.dim(` ${publishResult.message}`));
490
500
  }
@@ -492,6 +502,7 @@ async function buildPlaylistDirect(params, options = {}) {
492
502
  }
493
503
  catch (error) {
494
504
  console.error(chalk.red(`Publish failed: ${error.message}`));
505
+ failures.push(`Publish: ${error.message}`);
495
506
  if (verbose) {
496
507
  console.error(chalk.dim(error.stack));
497
508
  }
@@ -501,10 +512,10 @@ async function buildPlaylistDirect(params, options = {}) {
501
512
  playlist,
502
513
  published: publishResult?.success || false,
503
514
  publishResult,
515
+ failures,
504
516
  };
505
517
  }
506
518
  module.exports = {
507
- initializeUtilities,
508
519
  queryRequirement,
509
520
  queryTokensByAddress,
510
521
  buildDP1Playlist,