@feralfile/cli 2.10.0 → 3.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.
- package/README.md +16 -11
- package/config.json.example +5 -0
- package/dist/package.json +2 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +1 -1
- package/dist/src/commands/enrich.js +16 -21
- package/dist/src/commands/find.js +94 -81
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +138 -73
- package/dist/src/commands/setup.js +81 -6
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +94 -0
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +10 -1
- package/dist/src/utilities/playlist-source.js +222 -33
- package/dist/src/utilities/raster-client.js +100 -13
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +63 -2
- package/docs/EXAMPLES.md +5 -2
- package/docs/PROJECT_SPEC.md +23 -17
- package/docs/README.md +41 -16
- package/package.json +2 -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
|
-
*
|
|
28
|
+
* Check if an address looks like an EVM address.
|
|
16
29
|
*
|
|
17
|
-
* @param {string}
|
|
18
|
-
* @returns {
|
|
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
|
|
21
|
-
if (
|
|
22
|
-
|
|
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
|
-
*
|
|
48
|
+
* pickResults applies a numeric quantity as a random selection.
|
|
28
49
|
*
|
|
29
|
-
* @param {Array<Object>}
|
|
30
|
-
* @param {number}
|
|
31
|
-
* @returns {
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
58
|
+
return results;
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
|
-
*
|
|
61
|
+
* reportSkips prints how many listed tokens produced no item, and why.
|
|
54
62
|
*
|
|
55
|
-
* @param {
|
|
56
|
-
* @
|
|
63
|
+
* @param {Array<Object>} results - Results that were converted
|
|
64
|
+
* @param {number} skippedUnsupported - Tokens dropped for an unsupported chain
|
|
57
65
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
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
|
|
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
|
-
* @
|
|
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 ||
|
|
71
|
-
const limit = Math.min(quantity
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (
|
|
105
|
+
if (listing.results.length === 0) {
|
|
78
106
|
console.log(chalk.yellow(` No tokens found in contract ${contractAddress}`));
|
|
79
107
|
return [];
|
|
80
108
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
const {
|
|
87
|
-
if (
|
|
88
|
-
console.log(chalk.
|
|
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
|
-
|
|
157
|
+
reportSkips(selected, listing.skippedUnsupported);
|
|
158
|
+
return itemsOf(selected);
|
|
91
159
|
}
|
|
92
160
|
/**
|
|
93
|
-
*
|
|
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}
|
|
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
|
|
101
|
-
|
|
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
|
-
*
|
|
176
|
+
* queryOwnerRequirement handles a query_address requirement.
|
|
105
177
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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 -
|
|
111
|
-
* @param {number|string} [quantity] -
|
|
112
|
-
* @param {number} [duration] - Explicit display seconds
|
|
113
|
-
* @
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (!(error instanceof TokenSourceCapabilityError)) {
|
|
213
|
+
throw error;
|
|
191
214
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
243
|
+
console.log(chalk.cyan(` → Trying as owner address instead...`));
|
|
244
|
+
return await queryTokensByAddress(contractAddress, quantity, duration, {}, source);
|
|
197
245
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 (
|
|
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
|
-
* @
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|