@feralfile/cli 1.4.0 → 1.6.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/dist/package.json
CHANGED
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.findCommand = void 0;
|
|
40
|
+
exports.buildRasterMediaItems = buildRasterMediaItems;
|
|
40
41
|
exports.parseLimitOption = parseLimitOption;
|
|
41
42
|
exports.decideActions = decideActions;
|
|
42
43
|
const commander_1 = require("commander");
|
|
@@ -68,7 +69,7 @@ const DP1_MAX_ITEMS = 1024;
|
|
|
68
69
|
const RASTER_PAGE_SIZE = 100;
|
|
69
70
|
exports.findCommand = new commander_1.Command('find')
|
|
70
71
|
.description('Find an artwork on the web and build a DP-1 playlist')
|
|
71
|
-
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
|
|
72
|
+
.argument('<input>', 'URL (Objkt / fxhash / Art Blocks / OpenSea / SuperRare / Feral File / Neort / Verse / Raster), `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, or a wallet address')
|
|
72
73
|
.option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
|
|
73
74
|
.option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
|
|
74
75
|
.option('-p, --play', 'Play the playlist on an FF1 device after building')
|
|
@@ -83,7 +84,7 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
83
84
|
const parsed = (0, marketplace_url_1.parseFindInput)(input);
|
|
84
85
|
if (!parsed) {
|
|
85
86
|
console.error(chalk_1.default.red('Could not understand input.'));
|
|
86
|
-
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse. ' +
|
|
87
|
+
console.error(chalk_1.default.dim('Supported URLs: Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse, Raster. ' +
|
|
87
88
|
'Or: `ethereum:{contract}:{tokenId}`, `tezos:{contract}:{tokenId}`, ' +
|
|
88
89
|
'or a wallet address (`0x...` / `tz1.../tz2.../tz3...`).'));
|
|
89
90
|
process.exit(1);
|
|
@@ -109,34 +110,64 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
109
110
|
}
|
|
110
111
|
console.log();
|
|
111
112
|
const userLimit = parseLimitOption(options.limit);
|
|
112
|
-
const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
|
|
113
113
|
// Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
|
|
114
114
|
// clear warning. Explicit `--limit > 1024` was already rejected by
|
|
115
115
|
// parseLimitOption, so reaching here means either capped-by-default or
|
|
116
116
|
// the user picked a value within spec.
|
|
117
117
|
const limit = Math.min(userLimit, DP1_MAX_ITEMS);
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// Raster's GraphQL API exposes no series token counts, so enumerate
|
|
119
|
+
// tokens first (up to the effective limit) and confirm with the real
|
|
120
|
+
// number afterward. `hasMore` means the series continues past `limit`.
|
|
121
|
+
let tokens;
|
|
122
|
+
let seriesHasMore = false;
|
|
123
|
+
if (target.kind === 'series') {
|
|
124
|
+
const fetched = await fetchTokens(target.summary, limit);
|
|
125
|
+
tokens = fetched.tokens;
|
|
126
|
+
seriesHasMore = fetched.hasMore;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
tokens = [{ ...target.coords, mediaUrl: null, mediaType: null }];
|
|
130
|
+
}
|
|
131
|
+
if (tokens.length === 0) {
|
|
132
|
+
console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
|
|
136
|
+
console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
|
|
120
137
|
`Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
|
|
121
138
|
console.log();
|
|
122
139
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
140
|
+
const shouldBuild = !!options.yes ||
|
|
141
|
+
!!options.output ||
|
|
142
|
+
(await confirmMakePlaylist(tokens.length, seriesHasMore));
|
|
125
143
|
if (!shouldBuild) {
|
|
126
144
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
127
145
|
return;
|
|
128
146
|
}
|
|
129
|
-
const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
|
|
130
147
|
console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
|
|
131
148
|
// Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
|
|
132
149
|
// display seconds), not concurrency — concurrency is hardcoded inside.
|
|
133
150
|
// Omit it for auto timing: video/audio items carry no duration and play
|
|
134
151
|
// their natural length (DP-1 §4.1); static items get the config default.
|
|
135
|
-
|
|
152
|
+
let items = await getNFTTokenInfoBatch(tokens.map((t) => ({
|
|
136
153
|
chain: t.chain,
|
|
137
154
|
contractAddress: t.contract,
|
|
138
155
|
tokenId: t.tokenId,
|
|
139
156
|
})));
|
|
157
|
+
// FF-indexer bypass for Raster-minted tokens. The indexer doesn't carry
|
|
158
|
+
// Raster's tokens, so it returns nothing — but Raster handed us each
|
|
159
|
+
// token's playable `media.contentUrl`. Build DP-1 items straight from
|
|
160
|
+
// that (off-chain provenance, same shape as the Neort path), so the
|
|
161
|
+
// series still builds into a signable, verifiable playlist instead of
|
|
162
|
+
// failing the way it did before. The indexer is tried first so tokens
|
|
163
|
+
// it *can* resolve keep their richer on-chain provenance.
|
|
164
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
165
|
+
const rasterItems = buildRasterMediaItems(tokens, target);
|
|
166
|
+
if (rasterItems.length > 0) {
|
|
167
|
+
console.log(chalk_1.default.dim(` FF indexer returned nothing; building ${rasterItems.length} item${rasterItems.length === 1 ? '' : 's'} directly from Raster media.`));
|
|
168
|
+
items = rasterItems;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
140
171
|
if (!Array.isArray(items) || items.length === 0) {
|
|
141
172
|
console.error(chalk_1.default.red('No tokens could be indexed; cannot build playlist.'));
|
|
142
173
|
console.error(chalk_1.default.dim(' The FF indexer may be under load when warming a series of previously-unseen tokens.'));
|
|
@@ -201,33 +232,42 @@ async function resolveTarget(parsed, skipPrompt) {
|
|
|
201
232
|
const coords = await (0, verse_marketplace_1.resolveVerseSeries)(parsed.slug);
|
|
202
233
|
return resolveCoords(coords);
|
|
203
234
|
}
|
|
235
|
+
if (parsed.kind === 'raster-artwork') {
|
|
236
|
+
const summary = await (0, raster_client_1.resolveSlugToArtwork)(parsed.slug);
|
|
237
|
+
if (summary === null) {
|
|
238
|
+
throw new Error(`No artwork found on Raster for slug "${parsed.slug}". ` +
|
|
239
|
+
'Check the URL — Raster artwork URLs are raster.art/artwork/{slug}.');
|
|
240
|
+
}
|
|
241
|
+
return { kind: 'series', summary };
|
|
242
|
+
}
|
|
204
243
|
if (parsed.kind === 'address') {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
244
|
+
const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
|
|
245
|
+
if (artist === null) {
|
|
207
246
|
throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
|
|
208
247
|
'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
|
|
209
248
|
}
|
|
210
|
-
const catalog = await (0, raster_client_1.listArtistArtworks)(
|
|
249
|
+
const catalog = await (0, raster_client_1.listArtistArtworks)(artist.id);
|
|
211
250
|
if (catalog.length === 0) {
|
|
212
251
|
throw new Error('Artist has no artworks indexed on Raster.');
|
|
213
252
|
}
|
|
214
253
|
const picked = await pickFromCatalog(catalog, skipPrompt);
|
|
215
|
-
|
|
216
|
-
|
|
254
|
+
return {
|
|
255
|
+
kind: 'series',
|
|
256
|
+
summary: { artworkId: picked.artworkId, title: picked.title, artists: [artist] },
|
|
257
|
+
};
|
|
217
258
|
}
|
|
218
259
|
// `unsupported` is handled upstream; this branch keeps the type narrowing honest.
|
|
219
260
|
throw new Error(`Unsupported parse result: ${parsed.kind}`);
|
|
220
261
|
}
|
|
221
262
|
/**
|
|
222
|
-
* Resolve on-chain coords to a target. If Raster doesn't index this token
|
|
223
|
-
*
|
|
263
|
+
* Resolve on-chain coords to a target. If Raster doesn't index this token,
|
|
264
|
+
* fall back to single-token mode so we still build something playable.
|
|
224
265
|
*/
|
|
225
266
|
async function resolveCoords(coords) {
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
267
|
+
const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
|
|
268
|
+
if (summary === null) {
|
|
228
269
|
return { kind: 'single', coords };
|
|
229
270
|
}
|
|
230
|
-
const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
|
|
231
271
|
return { kind: 'series', summary };
|
|
232
272
|
}
|
|
233
273
|
/**
|
|
@@ -242,6 +282,32 @@ function playlistTitleFor(target, items) {
|
|
|
242
282
|
}
|
|
243
283
|
return items[0]?.title ?? `Token ${target.coords.tokenId}`;
|
|
244
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Build DP-1 items directly from Raster's per-token media, bypassing the FF
|
|
287
|
+
* indexer. Used as a fallback when the indexer resolves nothing — tokens
|
|
288
|
+
* without a Raster `mediaUrl` are dropped (nothing playable to point at).
|
|
289
|
+
*
|
|
290
|
+
* Item shape matches the Neort off-chain path (`buildUrlItem` →
|
|
291
|
+
* `provenance.offChainURI`), so the resulting playlist signs and verifies
|
|
292
|
+
* normally. Titles are "<Series> #<tokenId>" for series, falling back to the
|
|
293
|
+
* series/coords title for a single token.
|
|
294
|
+
*/
|
|
295
|
+
function buildRasterMediaItems(tokens, target) {
|
|
296
|
+
const seriesTitle = target.kind === 'series' ? target.summary.title : undefined;
|
|
297
|
+
const items = [];
|
|
298
|
+
for (const t of tokens) {
|
|
299
|
+
if (!t.mediaUrl) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const title = seriesTitle ? `${seriesTitle} #${t.tokenId}` : `Token ${t.tokenId}`;
|
|
303
|
+
// No explicit duration: auto timing (video/audio play their natural
|
|
304
|
+
// length). `mediaType` carries Raster's type so an extensionless IPFS
|
|
305
|
+
// contentUrl is still classified correctly — otherwise a video would be
|
|
306
|
+
// stamped a fixed duration and play as a still.
|
|
307
|
+
items.push(buildUrlItem(t.mediaUrl, undefined, { title, mimeType: t.mediaType ?? undefined }));
|
|
308
|
+
}
|
|
309
|
+
return items;
|
|
310
|
+
}
|
|
245
311
|
function parseLimitOption(limitStr) {
|
|
246
312
|
if (limitStr === undefined) {
|
|
247
313
|
return Number.POSITIVE_INFINITY;
|
|
@@ -258,11 +324,17 @@ function parseLimitOption(limitStr) {
|
|
|
258
324
|
}
|
|
259
325
|
return n;
|
|
260
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Enumerate series tokens from Raster up to `limit`. `hasMore` reports
|
|
329
|
+
* whether the series continues past what was collected — the API exposes
|
|
330
|
+
* no total counts, so this is all the size information the flow gets.
|
|
331
|
+
*/
|
|
261
332
|
async function fetchTokens(summary, limit) {
|
|
262
333
|
const collected = [];
|
|
263
334
|
let cursor;
|
|
264
335
|
let skipped = 0;
|
|
265
|
-
|
|
336
|
+
let hasMore = false;
|
|
337
|
+
for (;;) {
|
|
266
338
|
const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
|
|
267
339
|
cursor,
|
|
268
340
|
pageSize: RASTER_PAGE_SIZE,
|
|
@@ -270,15 +342,22 @@ async function fetchTokens(summary, limit) {
|
|
|
270
342
|
skipped += page.skippedUnsupported;
|
|
271
343
|
for (const t of page.tokens) {
|
|
272
344
|
if (collected.length >= limit) {
|
|
345
|
+
hasMore = true;
|
|
273
346
|
break;
|
|
274
347
|
}
|
|
275
348
|
collected.push({
|
|
276
349
|
chain: t.chain,
|
|
277
350
|
contract: t.contractAddress,
|
|
278
351
|
tokenId: t.tokenId,
|
|
352
|
+
mediaUrl: t.mediaUrl,
|
|
353
|
+
mediaType: t.mediaType,
|
|
279
354
|
});
|
|
280
355
|
}
|
|
281
|
-
if (page.nextCursor === null) {
|
|
356
|
+
if (hasMore || page.nextCursor === null) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
if (collected.length >= limit) {
|
|
360
|
+
hasMore = true;
|
|
282
361
|
break;
|
|
283
362
|
}
|
|
284
363
|
cursor = page.nextCursor;
|
|
@@ -286,7 +365,7 @@ async function fetchTokens(summary, limit) {
|
|
|
286
365
|
if (skipped > 0) {
|
|
287
366
|
console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
|
|
288
367
|
}
|
|
289
|
-
return collected;
|
|
368
|
+
return { tokens: collected, hasMore };
|
|
290
369
|
}
|
|
291
370
|
async function pickFromCatalog(catalog, skipPrompt) {
|
|
292
371
|
if (catalog.length === 1 || skipPrompt) {
|
|
@@ -294,8 +373,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
294
373
|
}
|
|
295
374
|
console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
|
|
296
375
|
catalog.forEach((row, i) => {
|
|
297
|
-
|
|
298
|
-
console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
|
|
376
|
+
console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
|
|
299
377
|
});
|
|
300
378
|
console.log();
|
|
301
379
|
const prompt = (0, prompt_1.createPrompt)();
|
|
@@ -310,9 +388,9 @@ async function pickFromCatalog(catalog, skipPrompt) {
|
|
|
310
388
|
}
|
|
311
389
|
return catalog[index];
|
|
312
390
|
}
|
|
313
|
-
async function confirmMakePlaylist(count,
|
|
314
|
-
const noun =
|
|
315
|
-
const label =
|
|
391
|
+
async function confirmMakePlaylist(count, hasMore) {
|
|
392
|
+
const noun = count === 1 ? 'token' : 'tokens';
|
|
393
|
+
const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
|
|
316
394
|
const prompt = (0, prompt_1.createPrompt)();
|
|
317
395
|
const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
|
|
318
396
|
prompt.close();
|
|
@@ -488,7 +566,7 @@ async function runNeortFind(id, options) {
|
|
|
488
566
|
console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
|
|
489
567
|
console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
|
|
490
568
|
console.log();
|
|
491
|
-
const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1,
|
|
569
|
+
const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, false));
|
|
492
570
|
if (!shouldBuild) {
|
|
493
571
|
console.log(chalk_1.default.dim('Cancelled.'));
|
|
494
572
|
return;
|
|
@@ -26,6 +26,7 @@ exports.parseOpenSea = parseOpenSea;
|
|
|
26
26
|
exports.parseSuperRare = parseSuperRare;
|
|
27
27
|
exports.parseNeort = parseNeort;
|
|
28
28
|
exports.parseVerse = parseVerse;
|
|
29
|
+
exports.parseRaster = parseRaster;
|
|
29
30
|
exports.parseFeralFile = parseFeralFile;
|
|
30
31
|
const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
|
|
31
32
|
const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
|
|
@@ -101,6 +102,9 @@ function parseMarketplaceUrl(url) {
|
|
|
101
102
|
if (host === 'verse.works' || host.endsWith('.verse.works')) {
|
|
102
103
|
return parseVerse(url);
|
|
103
104
|
}
|
|
105
|
+
if (host === 'raster.art' || host.endsWith('.raster.art')) {
|
|
106
|
+
return parseRaster(url);
|
|
107
|
+
}
|
|
104
108
|
return null;
|
|
105
109
|
}
|
|
106
110
|
/**
|
|
@@ -382,6 +386,27 @@ function parseVerse(url) {
|
|
|
382
386
|
'/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
|
|
383
387
|
};
|
|
384
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Raster URL forms (raster.art):
|
|
391
|
+
* raster.art/artwork/{slug} — artwork (series) detail page
|
|
392
|
+
*
|
|
393
|
+
* Unlike the other marketplaces, Raster is already the find flow's backend
|
|
394
|
+
* resolver — so a Raster URL resolves to an artwork by slug via
|
|
395
|
+
* `artworkBySlug` and (crucially) builds DP-1 items straight from Raster's
|
|
396
|
+
* own `media.contentUrl`, bypassing the FF indexer the way Neort does. This
|
|
397
|
+
* is what lets Raster-minted tokens (which the FF indexer doesn't carry)
|
|
398
|
+
* build a verifiable playlist without `--skip-verify`.
|
|
399
|
+
*/
|
|
400
|
+
function parseRaster(url) {
|
|
401
|
+
const m = /^\/artwork\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
|
|
402
|
+
if (m) {
|
|
403
|
+
return { kind: 'raster-artwork', slug: m[1] };
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
kind: 'unsupported',
|
|
407
|
+
reason: `Raster URL not recognized: ${url.pathname}. Expected /artwork/{slug}.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
385
410
|
/**
|
|
386
411
|
* Feral File URL forms (source of truth: feralfile-client app-routing
|
|
387
412
|
* + collection-routing modules):
|
|
@@ -536,6 +536,10 @@ function detectMimeType(url) {
|
|
|
536
536
|
* (video/audio URLs play their natural length per DP-1 §4.1)
|
|
537
537
|
* @param {Object} [options] - Optional configuration
|
|
538
538
|
* @param {string} [options.title] - Optional item title override
|
|
539
|
+
* @param {string} [options.mimeType] - Source media type hint for timing
|
|
540
|
+
* (DP-1 §4.1). Needed when the URL has no file extension to detect from —
|
|
541
|
+
* e.g. extensionless IPFS gateway URLs, where without this hint a video
|
|
542
|
+
* would be misclassified as a static image and stamped a fixed duration.
|
|
539
543
|
* @returns {Object} DP1 playlist item
|
|
540
544
|
*/
|
|
541
545
|
function buildUrlItem(url, duration, options = {}) {
|
|
@@ -580,7 +584,7 @@ function buildUrlItem(url, duration, options = {}) {
|
|
|
580
584
|
margin: 0,
|
|
581
585
|
},
|
|
582
586
|
};
|
|
583
|
-
return applyItemTiming(item, { sourceUrl }, duration);
|
|
587
|
+
return applyItemTiming(item, { sourceUrl, mimeType: options.mimeType }, duration);
|
|
584
588
|
}
|
|
585
589
|
module.exports = {
|
|
586
590
|
convertTokenToDP1Item,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Raster API client (
|
|
3
|
+
* Raster API client (api.raster.art — public GraphQL API).
|
|
4
4
|
*
|
|
5
|
-
* Resolves on-chain coordinates and artist addresses into Raster
|
|
6
|
-
* (series) and
|
|
5
|
+
* Resolves on-chain coordinates and artist addresses into Raster artworks
|
|
6
|
+
* (series) and artists (catalog) — the inputs the `ff-cli find` flow needs
|
|
7
7
|
* before handing tokens to the FF GraphQL indexer for DP-1 conversion.
|
|
8
8
|
*
|
|
9
9
|
* This module is pure data: it does not construct DP-1 items, send anything
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* mainnet. Token lists are filtered here at the CAIP level and a
|
|
15
15
|
* `skippedUnsupported` count is surfaced so callers can warn-skip without
|
|
16
16
|
* walking every row themselves.
|
|
17
|
+
*
|
|
18
|
+
* API notes (v0.1.0, https://docs.api.raster.art/):
|
|
19
|
+
* - Auth: `x-api-key` header, sent when RASTER_API_KEY is set in the
|
|
20
|
+
* environment. The endpoint currently answers without a key; the docs
|
|
21
|
+
* declare one required, so the hook is wired in ahead of enforcement.
|
|
22
|
+
* - Not-found is a `null` query field, not an HTTP 404.
|
|
23
|
+
* - Connections expose no total counts — callers learn series size by
|
|
24
|
+
* paginating, so the find flow fetches before it prompts.
|
|
17
25
|
*/
|
|
18
26
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
27
|
if (k2 === undefined) k2 = k;
|
|
@@ -49,130 +57,137 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
49
57
|
};
|
|
50
58
|
})();
|
|
51
59
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
-
exports.RasterNotFoundError = void 0;
|
|
53
60
|
exports.resolveTokenToArtwork = resolveTokenToArtwork;
|
|
54
|
-
exports.
|
|
61
|
+
exports.resolveSlugToArtwork = resolveSlugToArtwork;
|
|
55
62
|
exports.resolveAddressToArtist = resolveAddressToArtist;
|
|
56
63
|
exports.listArtworkTokens = listArtworkTokens;
|
|
57
64
|
exports.listArtistArtworks = listArtistArtworks;
|
|
58
65
|
exports.formatSummaryLine = formatSummaryLine;
|
|
59
66
|
const logger = __importStar(require("../logger"));
|
|
60
67
|
const user_agent_1 = require("./user-agent");
|
|
61
|
-
const
|
|
68
|
+
const DEFAULT_RASTER_GRAPHQL_URL = 'https://api.raster.art/graphql';
|
|
62
69
|
// CAIP-2 chain IDs for the chains the FF indexer supports.
|
|
63
70
|
// Tezos mainnet's CAIP form uses the genesis block hash (NetXdQprcVkpaWU),
|
|
64
71
|
// not the human-readable "tezos:mainnet" — Raster returns the hash form on
|
|
65
|
-
// token rows and
|
|
72
|
+
// token rows and resolves nothing for "tezos:mainnet".
|
|
66
73
|
const CAIP_TO_INDEXER_CHAIN = {
|
|
67
74
|
'eip155:1': 'ethereum',
|
|
68
75
|
'tezos:NetXdQprcVkpaWU': 'tezos',
|
|
69
76
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
77
|
+
const INDEXER_CHAIN_TO_CAIP = {
|
|
78
|
+
ethereum: 'eip155:1',
|
|
79
|
+
tezos: 'tezos:NetXdQprcVkpaWU',
|
|
80
|
+
};
|
|
74
81
|
function caipToIndexerChain(caip) {
|
|
75
82
|
return CAIP_TO_INDEXER_CHAIN[caip] ?? null;
|
|
76
83
|
}
|
|
77
84
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
85
|
+
* POST one GraphQL operation to Raster and return `data`.
|
|
86
|
+
*
|
|
87
|
+
* Throws on HTTP errors and on GraphQL `errors` entries. "Not found" never
|
|
88
|
+
* lands here — Raster models it as a null query field with no error, so
|
|
89
|
+
* callers check their own field for null.
|
|
81
90
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
async function rasterQuery(query, variables) {
|
|
92
|
+
const endpoint = process.env.RASTER_API_URL ?? DEFAULT_RASTER_GRAPHQL_URL;
|
|
93
|
+
logger.debug(`[Raster] POST ${endpoint} ${JSON.stringify(variables)}`);
|
|
94
|
+
const headers = {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'User-Agent': user_agent_1.USER_AGENT,
|
|
97
|
+
};
|
|
98
|
+
const apiKey = process.env.RASTER_API_KEY;
|
|
99
|
+
if (apiKey) {
|
|
100
|
+
headers['x-api-key'] = apiKey;
|
|
87
101
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
logger.debug(`[Raster] GET ${url}`);
|
|
93
|
-
const response = await fetch(url, {
|
|
94
|
-
headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
|
|
102
|
+
const response = await fetch(endpoint, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers,
|
|
105
|
+
body: JSON.stringify({ query, variables }),
|
|
95
106
|
});
|
|
96
|
-
if (response.status === 404) {
|
|
97
|
-
throw new RasterNotFoundError(path);
|
|
98
|
-
}
|
|
99
107
|
if (!response.ok) {
|
|
100
108
|
const body = await response.text();
|
|
101
|
-
throw new Error(`Raster API ${response.status} ${response.statusText}
|
|
109
|
+
throw new Error(`Raster API ${response.status} ${response.statusText}: ${body.slice(0, 200)}`);
|
|
102
110
|
}
|
|
103
|
-
|
|
111
|
+
const payload = (await response.json());
|
|
112
|
+
if (payload.errors && payload.errors.length > 0) {
|
|
113
|
+
throw new Error(`Raster API error: ${payload.errors.map((e) => e.message).join('; ')}`);
|
|
114
|
+
}
|
|
115
|
+
if (payload.data === undefined) {
|
|
116
|
+
throw new Error('Raster API returned no data.');
|
|
117
|
+
}
|
|
118
|
+
return payload.data;
|
|
119
|
+
}
|
|
120
|
+
function toRasterArtist(raw) {
|
|
121
|
+
return { id: raw.id, name: raw.name ?? 'Unknown artist', slug: raw.slug };
|
|
104
122
|
}
|
|
105
123
|
/**
|
|
106
|
-
* Resolve a token's on-chain coordinates to its Raster artwork (series)
|
|
124
|
+
* Resolve a token's on-chain coordinates to its Raster artwork (series).
|
|
107
125
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
126
|
+
* One round trip: `tokenByRef` returns the artwork with artists inline, so
|
|
127
|
+
* this yields the full summary directly. Returns `null` when Raster doesn't
|
|
128
|
+
* index this token; callers fall back to a single-token playlist in that
|
|
129
|
+
* case. Tokens occasionally belong to multiple artworks; the first one is
|
|
130
|
+
* Raster's primary association.
|
|
112
131
|
*/
|
|
113
132
|
async function resolveTokenToArtwork(chain, contract, tokenId) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
throw error;
|
|
133
|
+
const data = await rasterQuery(`query ResolveToken($ref: TokenRefInput!) {
|
|
134
|
+
tokenByRef(ref: $ref) {
|
|
135
|
+
artworks { id title artists { id name slug } }
|
|
136
|
+
}
|
|
137
|
+
}`, {
|
|
138
|
+
ref: {
|
|
139
|
+
chainId: INDEXER_CHAIN_TO_CAIP[chain],
|
|
140
|
+
contractAddress: contract,
|
|
141
|
+
tokenId,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const artwork = data.tokenByRef?.artworks?.[0];
|
|
145
|
+
if (!artwork) {
|
|
146
|
+
return null;
|
|
129
147
|
}
|
|
148
|
+
return {
|
|
149
|
+
artworkId: artwork.id,
|
|
150
|
+
title: artwork.title,
|
|
151
|
+
artists: (artwork.artists ?? []).map(toRasterArtist),
|
|
152
|
+
};
|
|
130
153
|
}
|
|
131
154
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
155
|
+
* Resolve a `raster.art/artwork/{slug}` URL to its artwork (series) summary.
|
|
156
|
+
*
|
|
157
|
+
* One round trip via `artworkBySlug`. Returns `null` when the slug is
|
|
158
|
+
* unknown (Raster models not-found as a null query field, not an HTTP 404).
|
|
134
159
|
*/
|
|
135
|
-
async function
|
|
136
|
-
const data = await
|
|
160
|
+
async function resolveSlugToArtwork(slug) {
|
|
161
|
+
const data = await rasterQuery(`query ResolveSlug($slug: String!) {
|
|
162
|
+
artworkBySlug(slug: $slug) {
|
|
163
|
+
id
|
|
164
|
+
title
|
|
165
|
+
artists { id name slug }
|
|
166
|
+
}
|
|
167
|
+
}`, { slug });
|
|
168
|
+
const artwork = data.artworkBySlug;
|
|
169
|
+
if (!artwork) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
137
172
|
return {
|
|
138
|
-
artworkId:
|
|
139
|
-
title:
|
|
140
|
-
artists:
|
|
141
|
-
tokenCount: data.token_count,
|
|
142
|
-
editionSize: data.edition_size,
|
|
143
|
-
isEdition: data.is_edition,
|
|
144
|
-
hasOneOfOneTokens: data.has_one_of_one_tokens,
|
|
145
|
-
platforms: data.platforms ?? [],
|
|
146
|
-
contractAddresses: data.contract_addresses ?? [],
|
|
173
|
+
artworkId: artwork.id,
|
|
174
|
+
title: artwork.title,
|
|
175
|
+
artists: (artwork.artists ?? []).map(toRasterArtist),
|
|
147
176
|
};
|
|
148
177
|
}
|
|
149
178
|
/**
|
|
150
|
-
* Resolve
|
|
179
|
+
* Resolve a wallet address to the Raster artist who claims it.
|
|
151
180
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
181
|
+
* The `address` query carries artist associations directly (the old REST
|
|
182
|
+
* API needed a `/search` bridge). Returns `null` when no artist claims the
|
|
183
|
+
* address. Checksummed Ethereum input is normalized server-side.
|
|
155
184
|
*/
|
|
156
185
|
async function resolveAddressToArtist(address) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (doc.data_type !== 'artist') {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const addrs = (doc.addresses ?? []).map((a) => a.toLowerCase());
|
|
166
|
-
if (!addrs.includes(normalized)) {
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
const parsed = Number.parseInt(doc.data_pk, 10);
|
|
170
|
-
if (Number.isFinite(parsed)) {
|
|
171
|
-
return parsed;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return null;
|
|
186
|
+
const data = await rasterQuery(`query ResolveAddress($address: String!) {
|
|
187
|
+
address(address: $address) { artists { id name slug } }
|
|
188
|
+
}`, { address: address.trim() });
|
|
189
|
+
const artist = data.address?.artists?.[0];
|
|
190
|
+
return artist ? toRasterArtist(artist) : null;
|
|
176
191
|
}
|
|
177
192
|
/**
|
|
178
193
|
* List tokens in a Raster artwork (series), one page at a time.
|
|
@@ -183,70 +198,83 @@ async function resolveAddressToArtist(address) {
|
|
|
183
198
|
* warn-skip line instead of one per token.
|
|
184
199
|
*
|
|
185
200
|
* Pagination: pass the returned `nextCursor` back as `options.cursor`.
|
|
186
|
-
* `nextCursor` is `null` on the final page.
|
|
187
|
-
*
|
|
201
|
+
* `nextCursor` is `null` on the final page. The API exposes no total count;
|
|
202
|
+
* the only way to learn a series' size is to walk it.
|
|
188
203
|
*/
|
|
189
204
|
async function listArtworkTokens(artworkId, options = {}) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
const data = await rasterQuery(`query ArtworkTokens($id: ID!, $first: Int, $after: String) {
|
|
206
|
+
artwork(id: $id) {
|
|
207
|
+
tokens(first: $first, after: $after) {
|
|
208
|
+
nodes { chainId contractAddress tokenId media { contentUrl contentType previewType } }
|
|
209
|
+
pageInfo { hasNextPage endCursor }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}`, { id: artworkId, first: options.pageSize ?? null, after: options.cursor ?? null });
|
|
213
|
+
if (!data.artwork) {
|
|
214
|
+
throw new Error(`Raster: artwork ${artworkId} not found.`);
|
|
196
215
|
}
|
|
197
|
-
const qs = params.toString();
|
|
198
|
-
const path = `/artwork/${artworkId}/tokens${qs ? `?${qs}` : ''}`;
|
|
199
|
-
const data = await rasterFetch(path);
|
|
200
216
|
const tokens = [];
|
|
201
217
|
let skippedUnsupported = 0;
|
|
202
|
-
for (const raw of data.tokens
|
|
203
|
-
const chain = caipToIndexerChain(raw.
|
|
204
|
-
if (!chain) {
|
|
218
|
+
for (const raw of data.artwork.tokens.nodes) {
|
|
219
|
+
const chain = caipToIndexerChain(raw.chainId);
|
|
220
|
+
if (!chain || !raw.contractAddress) {
|
|
205
221
|
skippedUnsupported += 1;
|
|
206
222
|
continue;
|
|
207
223
|
}
|
|
208
224
|
tokens.push({
|
|
209
|
-
caipChain: raw.chain_id,
|
|
210
225
|
chain,
|
|
211
|
-
contractAddress:
|
|
212
|
-
tokenId: raw.
|
|
226
|
+
contractAddress: raw.contractAddress,
|
|
227
|
+
tokenId: raw.tokenId,
|
|
228
|
+
mediaUrl: raw.media?.contentUrl ?? null,
|
|
229
|
+
// Prefer a real MIME (`contentType`) when Raster populates it; today it
|
|
230
|
+
// is usually empty, so fall back to the category-prefixed `previewType`.
|
|
231
|
+
mediaType: raw.media?.contentType || raw.media?.previewType || null,
|
|
213
232
|
});
|
|
214
233
|
}
|
|
215
|
-
const
|
|
216
|
-
const nextCursor = rawCursor === null || rawCursor >= data.total_count ? null : rawCursor;
|
|
234
|
+
const { hasNextPage, endCursor } = data.artwork.tokens.pageInfo;
|
|
217
235
|
return {
|
|
218
236
|
tokens,
|
|
219
|
-
|
|
220
|
-
nextCursor,
|
|
237
|
+
nextCursor: hasNextPage && endCursor ? endCursor : null,
|
|
221
238
|
skippedUnsupported,
|
|
222
239
|
};
|
|
223
240
|
}
|
|
224
241
|
/**
|
|
225
|
-
* List an artist's full catalog as light artwork rows (id
|
|
226
|
-
*
|
|
227
|
-
* are needed for a specific row.
|
|
242
|
+
* List an artist's full catalog as light artwork rows (id + title — the
|
|
243
|
+
* API exposes no per-artwork token counts or mint dates yet).
|
|
228
244
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
245
|
+
* Walks the artist's `artworks` connection to the end so the caller sees
|
|
246
|
+
* the complete catalog, matching the old single-response REST behavior.
|
|
231
247
|
*/
|
|
232
248
|
async function listArtistArtworks(artistId) {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
const rows = [];
|
|
250
|
+
let cursor = null;
|
|
251
|
+
do {
|
|
252
|
+
const data = await rasterQuery(`query ArtistArtworks($id: ID!, $after: String) {
|
|
253
|
+
artist(id: $id) {
|
|
254
|
+
artworks(first: 100, after: $after) {
|
|
255
|
+
nodes { id title }
|
|
256
|
+
pageInfo { hasNextPage endCursor }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}`, { id: artistId, after: cursor });
|
|
260
|
+
if (!data.artist) {
|
|
261
|
+
throw new Error(`Raster: artist ${artistId} not found.`);
|
|
262
|
+
}
|
|
263
|
+
for (const node of data.artist.artworks.nodes) {
|
|
264
|
+
rows.push({ artworkId: node.id, title: node.title });
|
|
265
|
+
}
|
|
266
|
+
const { hasNextPage, endCursor } = data.artist.artworks.pageInfo;
|
|
267
|
+
cursor = hasNextPage && endCursor ? endCursor : null;
|
|
268
|
+
} while (cursor !== null);
|
|
269
|
+
return rows;
|
|
241
270
|
}
|
|
242
271
|
/**
|
|
243
272
|
* Format a one-line summary for the resolved artwork.
|
|
244
273
|
*
|
|
245
274
|
* @example
|
|
246
|
-
* formatSummaryLine(summary) // "Snowfro — send/receive
|
|
275
|
+
* formatSummaryLine(summary) // "Snowfro — send/receive"
|
|
247
276
|
*/
|
|
248
277
|
function formatSummaryLine(summary) {
|
|
249
278
|
const artistNames = summary.artists.map((a) => a.name).join(', ') || 'Unknown artist';
|
|
250
|
-
|
|
251
|
-
return `${artistNames} — ${summary.title} — ${summary.tokenCount} ${tokenWord}`;
|
|
279
|
+
return `${artistNames} — ${summary.title}`;
|
|
252
280
|
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -116,6 +116,19 @@ Environment variable alternative:
|
|
|
116
116
|
FEED_BASE_URLS=https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1,https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
## find (Raster API)
|
|
120
|
+
|
|
121
|
+
`ff-cli find` resolves tokens, series, and artist addresses through Raster's public GraphQL API ([docs.api.raster.art](https://docs.api.raster.art/)). No configuration is required; two environment variables adjust the integration:
|
|
122
|
+
|
|
123
|
+
```env
|
|
124
|
+
# API key sent as the x-api-key header. The API currently answers without
|
|
125
|
+
# one, but Raster's docs declare a key required — set this once enforced.
|
|
126
|
+
RASTER_API_KEY=rk_your_key_here
|
|
127
|
+
|
|
128
|
+
# Override the GraphQL endpoint (default: https://api.raster.art/graphql).
|
|
129
|
+
RASTER_API_URL=https://api.raster.art/graphql
|
|
130
|
+
```
|
|
131
|
+
|
|
119
132
|
## ff1Devices
|
|
120
133
|
|
|
121
134
|
Configure devices you want to play content on.
|