@feralfile/cli 1.1.4 → 1.2.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 +26 -0
- package/dist/index.js +4 -2
- package/dist/package.json +84 -0
- package/dist/src/ai-orchestrator/index.js +14 -2
- package/dist/src/commands/build.js +3 -3
- package/dist/src/commands/chat.js +3 -3
- package/dist/src/commands/config.js +2 -2
- package/dist/src/commands/device.js +6 -6
- package/dist/src/commands/find.js +502 -0
- package/dist/src/commands/helpers/device-discovery.js +1 -1
- package/dist/src/commands/play.js +52 -11
- package/dist/src/commands/setup.js +1 -1
- package/dist/src/commands/status.js +2 -2
- package/dist/src/intent-parser/index.js +16 -5
- package/dist/src/intent-parser/utils.js +7 -2
- package/dist/src/main.js +61 -17
- package/dist/src/utilities/ab-marketplace.js +95 -0
- package/dist/src/utilities/feral-file-artwork.js +89 -0
- package/dist/src/utilities/ff-marketplace.js +115 -0
- package/dist/src/utilities/ff1-device.js +4 -4
- package/dist/src/utilities/functions.js +1 -1
- package/dist/src/utilities/fxhash-marketplace.js +146 -0
- package/dist/src/utilities/index.js +80 -1
- package/dist/src/utilities/marketplace-url.js +373 -0
- package/dist/src/utilities/neort-marketplace.js +102 -0
- package/dist/src/utilities/nft-indexer.js +22 -14
- package/dist/src/utilities/objkt-marketplace.js +77 -0
- package/dist/src/utilities/playlist-publisher.js +9 -42
- package/dist/src/utilities/raster-client.js +252 -0
- package/dist/src/utilities/user-agent.js +20 -0
- package/docs/CONFIGURATION.md +3 -1
- package/docs/EXAMPLES.md +23 -0
- package/docs/FUNCTION_CALLING.md +3 -1
- package/docs/README.md +11 -10
- package/docs/RELEASING.md +92 -22
- package/package.json +1 -1
|
@@ -121,6 +121,9 @@ REQUIREMENT TYPES (BUILD)
|
|
|
121
121
|
- "tokens 1, 2, 3 from contract 0x123" → build_playlist with tokenIds: ["1", "2", "3"]
|
|
122
122
|
- "100 items from ethereum contract 0xABC" → build_playlist with quantity: 100, NO tokenIds
|
|
123
123
|
- "50 random tokens from tezos contract KT1..." → build_playlist with quantity: 50, NO tokenIds
|
|
124
|
+
- feral_file_artwork: { type, artworkId } for Feral File artwork URLs or public artwork ids.
|
|
125
|
+
• USE THIS only for feralfile.com/exhibitions/artwork URLs or explicit Feral File public artwork ids
|
|
126
|
+
• Do not use this for ordinary blockchain contract prompts
|
|
124
127
|
- query_address: { type, ownerAddress: 0x…|tz…|domain.eth|domain.tez, quantity?: number | "all" }
|
|
125
128
|
• USE THIS for owner/wallet addresses WITHOUT the word "contract"
|
|
126
129
|
• Patterns: "N items from [address]", "NFTs from [address]", "tokens owned by [address]"
|
|
@@ -153,6 +156,10 @@ EXAMPLES (build_playlist - contract addresses)
|
|
|
153
156
|
- "100 random tokens from tezos contract KT1abc" → \`build_playlist\` { blockchain: "tezos", contractAddress: "KT1abc", quantity: 100 }
|
|
154
157
|
- "get 50 from contract 0xDEF" → \`build_playlist\` { blockchain: "ethereum", contractAddress: "0xDEF", quantity: 50 }
|
|
155
158
|
|
|
159
|
+
EXAMPLES (feral_file_artwork - public artwork URLs)
|
|
160
|
+
- "build a playlist from https://feralfile.com/exhibitions/artwork/abc123" → \`feral_file_artwork\` { artworkId: "abc123" }
|
|
161
|
+
- "play Feral File artwork f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf" → \`feral_file_artwork\` { artworkId: "f0240e04d64717e319584957f6a83954b029254ad1260b6320472ea8c0c5b1cf" }
|
|
162
|
+
|
|
156
163
|
EXAMPLES (fetch_feed)
|
|
157
164
|
- "Pick 3 artworks from Social Codes and 2 from a2p. Mix them up." → \`fetch_feed\` { playlistName: "Social Codes", quantity: 3 } + \`fetch_feed\` { playlistName: "a2p", quantity: 2 }, and set \`playlistSettings.preserveOrder\` = false
|
|
158
165
|
|
|
@@ -266,20 +273,20 @@ const intentParserFunctionSchemas = [
|
|
|
266
273
|
type: 'function',
|
|
267
274
|
function: {
|
|
268
275
|
name: 'parse_requirements',
|
|
269
|
-
description: 'Parse validated and complete requirements. Only call this when you have all required information for each requirement
|
|
276
|
+
description: 'Parse validated and complete requirements. Only call this when you have all required information for each requirement, such as blockchain/contract/token IDs, Feral File artwork id, feed playlist name, or owner address.',
|
|
270
277
|
parameters: {
|
|
271
278
|
type: 'object',
|
|
272
279
|
properties: {
|
|
273
280
|
requirements: {
|
|
274
281
|
type: 'array',
|
|
275
|
-
description: 'Array of parsed requirements. Each can be
|
|
282
|
+
description: 'Array of parsed requirements. Each can be build_playlist (specific NFTs), feral_file_artwork (Feral File public artwork id or URL), fetch_feed (feed playlist), or query_address (all NFTs from address)',
|
|
276
283
|
items: {
|
|
277
284
|
type: 'object',
|
|
278
285
|
properties: {
|
|
279
286
|
type: {
|
|
280
287
|
type: 'string',
|
|
281
|
-
enum: ['build_playlist', 'fetch_feed', 'query_address'],
|
|
282
|
-
description: 'Type of requirement: build_playlist (specific NFTs), fetch_feed (feed playlist), or query_address (all NFTs from address)',
|
|
288
|
+
enum: ['build_playlist', 'feral_file_artwork', 'fetch_feed', 'query_address'],
|
|
289
|
+
description: 'Type of requirement: build_playlist (specific NFTs), feral_file_artwork (Feral File artwork), fetch_feed (feed playlist), or query_address (all NFTs from address)',
|
|
283
290
|
},
|
|
284
291
|
blockchain: {
|
|
285
292
|
type: 'string',
|
|
@@ -296,6 +303,10 @@ const intentParserFunctionSchemas = [
|
|
|
296
303
|
type: 'string',
|
|
297
304
|
},
|
|
298
305
|
},
|
|
306
|
+
artworkId: {
|
|
307
|
+
type: 'string',
|
|
308
|
+
description: 'Feral File public artwork id or artwork URL - only for feral_file_artwork type',
|
|
309
|
+
},
|
|
299
310
|
ownerAddress: {
|
|
300
311
|
type: 'string',
|
|
301
312
|
description: 'Owner wallet address (0x... for Ethereum, tz... for Tezos) - only for query_address type',
|
|
@@ -1067,7 +1078,7 @@ async function processIntentParserRequest(userRequest, options = {}) {
|
|
|
1067
1078
|
const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-send')));
|
|
1068
1079
|
// Validate and confirm the playlist.
|
|
1069
1080
|
// args.deviceName may be null/undefined/"null" when the model omits it;
|
|
1070
|
-
// fall back to the CLI --device flag so `
|
|
1081
|
+
// fall back to the CLI --device flag so `ff-cli chat --device kitchen` works.
|
|
1071
1082
|
const resolvedDeviceName = args.deviceName && args.deviceName !== 'null' ? args.deviceName : defaultDeviceName;
|
|
1072
1083
|
const confirmation = await confirmPlaylistForSending(args.filePath, resolvedDeviceName);
|
|
1073
1084
|
if (!confirmation.success) {
|
|
@@ -51,6 +51,11 @@ function applyConstraints(params, config) {
|
|
|
51
51
|
throw new Error(`Requirement ${index + 1}: playlistName is required for fetch_feed`);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
else if (r.type === 'feral_file_artwork') {
|
|
55
|
+
if (!r.artworkId) {
|
|
56
|
+
throw new Error(`Requirement ${index + 1}: artworkId is required for feral_file_artwork`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
54
59
|
else {
|
|
55
60
|
throw new Error(`Requirement ${index + 1}: invalid type "${r.type}"`);
|
|
56
61
|
}
|
|
@@ -79,9 +84,9 @@ function applyConstraints(params, config) {
|
|
|
79
84
|
});
|
|
80
85
|
// Note: No cap needed - registry system handles large playlists efficiently
|
|
81
86
|
// Full items are stored in memory, only IDs are sent to AI model
|
|
82
|
-
const hasAllQuantity = params.requirements.some((r) => r.quantity === 'all');
|
|
87
|
+
const hasAllQuantity = params.requirements.some((r) => 'quantity' in r && r.quantity === 'all');
|
|
83
88
|
const totalRequested = params.requirements.reduce((sum, r) => {
|
|
84
|
-
if (typeof r.quantity === 'number') {
|
|
89
|
+
if ('quantity' in r && typeof r.quantity === 'number') {
|
|
85
90
|
return sum + r.quantity;
|
|
86
91
|
}
|
|
87
92
|
return sum;
|
package/dist/src/main.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.SEND_SHORTCUT_PATTERN = void 0;
|
|
|
44
44
|
exports.resolveSendShortcutDevice = resolveSendShortcutDevice;
|
|
45
45
|
exports.resolveEffectiveDeviceName = resolveEffectiveDeviceName;
|
|
46
46
|
exports.resolveSendPlaylistDeviceName = resolveSendPlaylistDeviceName;
|
|
47
|
+
exports.sendPreparedPlaylistToDevice = sendPreparedPlaylistToDevice;
|
|
47
48
|
exports.validateRequirements = validateRequirements;
|
|
48
49
|
exports.applyPlaylistDefaults = applyPlaylistDefaults;
|
|
49
50
|
exports.buildPlaylistDirect = buildPlaylistDirect;
|
|
@@ -106,6 +107,36 @@ function resolveSendPlaylistDeviceName(intentDeviceName, cliDeviceName) {
|
|
|
106
107
|
: intentDeviceName;
|
|
107
108
|
return resolveEffectiveDeviceName(sanitized, cliDeviceName);
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Verify a playlist for device delivery and send it using the supplied transport.
|
|
112
|
+
*
|
|
113
|
+
* This helper keeps the send branches in `buildPlaylist` testable without
|
|
114
|
+
* duplicating the verification and transport wiring in tests.
|
|
115
|
+
*/
|
|
116
|
+
async function sendPreparedPlaylistToDevice(playlist, deps) {
|
|
117
|
+
const verificationResult = await deps.verifyPlaylist(playlist);
|
|
118
|
+
if (!verificationResult.valid) {
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
error: verificationResult.error || 'Failed to verify playlist before sending',
|
|
122
|
+
playlist: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const sendResult = await deps.sendToDevice(playlist, deps.resolveDeviceName());
|
|
126
|
+
if (sendResult.success) {
|
|
127
|
+
return {
|
|
128
|
+
success: true,
|
|
129
|
+
playlist,
|
|
130
|
+
deviceName: sendResult.deviceName,
|
|
131
|
+
device: sendResult.device,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: sendResult.error || 'Failed to send playlist',
|
|
137
|
+
playlist: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
109
140
|
/**
|
|
110
141
|
* Validate and apply constraints to requirements
|
|
111
142
|
*
|
|
@@ -129,6 +160,12 @@ function validateRequirements(requirements) {
|
|
|
129
160
|
quantity,
|
|
130
161
|
};
|
|
131
162
|
}
|
|
163
|
+
if (req.type === 'feral_file_artwork') {
|
|
164
|
+
if (!req.artworkId) {
|
|
165
|
+
throw new Error(`Requirement ${index + 1}: artworkId is required for feral_file_artwork`);
|
|
166
|
+
}
|
|
167
|
+
return req;
|
|
168
|
+
}
|
|
132
169
|
// Query address requirement
|
|
133
170
|
if (req.type === 'query_address') {
|
|
134
171
|
// Query all NFTs from an owner address
|
|
@@ -249,6 +286,7 @@ async function buildPlaylist(userRequest, options = {}) {
|
|
|
249
286
|
if (sendMatch) {
|
|
250
287
|
const deviceName = sendMatch[1]?.trim() || defaultDeviceName;
|
|
251
288
|
const { confirmPlaylistForSending } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-send')));
|
|
289
|
+
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
|
|
252
290
|
const confirmation = await confirmPlaylistForSending(outputPath, deviceName);
|
|
253
291
|
if (!confirmation.success) {
|
|
254
292
|
if (confirmation.message) {
|
|
@@ -261,7 +299,11 @@ async function buildPlaylist(userRequest, options = {}) {
|
|
|
261
299
|
playlist: null,
|
|
262
300
|
};
|
|
263
301
|
}
|
|
264
|
-
const sendResult = await
|
|
302
|
+
const sendResult = await sendPreparedPlaylistToDevice(confirmation.playlist, {
|
|
303
|
+
verifyPlaylist,
|
|
304
|
+
sendToDevice: utilities.sendToDevice,
|
|
305
|
+
resolveDeviceName: () => confirmation.deviceName,
|
|
306
|
+
});
|
|
265
307
|
if (sendResult.success) {
|
|
266
308
|
console.log(chalk_1.default.green('\nPlaylist sent'));
|
|
267
309
|
if (sendResult.deviceName) {
|
|
@@ -270,7 +312,7 @@ async function buildPlaylist(userRequest, options = {}) {
|
|
|
270
312
|
console.log();
|
|
271
313
|
return {
|
|
272
314
|
success: true,
|
|
273
|
-
playlist:
|
|
315
|
+
playlist: sendResult.playlist,
|
|
274
316
|
action: 'send_playlist',
|
|
275
317
|
};
|
|
276
318
|
}
|
|
@@ -348,23 +390,15 @@ async function buildPlaylist(userRequest, options = {}) {
|
|
|
348
390
|
// Handle playlist sending directly
|
|
349
391
|
const sendParams = params;
|
|
350
392
|
const utilities = getUtilities();
|
|
393
|
+
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./utilities/playlist-verifier')));
|
|
351
394
|
console.log();
|
|
352
395
|
console.log(chalk_1.default.cyan('Sending to device'));
|
|
353
|
-
const sendResult = await
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
console.log();
|
|
360
|
-
return {
|
|
361
|
-
success: true,
|
|
362
|
-
playlist: sendParams.playlist,
|
|
363
|
-
action: 'send_playlist',
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
// Send failed - return error without showing the playlist summary
|
|
396
|
+
const sendResult = await sendPreparedPlaylistToDevice(sendParams.playlist, {
|
|
397
|
+
verifyPlaylist,
|
|
398
|
+
sendToDevice: utilities.sendToDevice,
|
|
399
|
+
resolveDeviceName: () => resolveSendPlaylistDeviceName(sendParams.deviceName, defaultDeviceName),
|
|
400
|
+
});
|
|
401
|
+
if (!sendResult.success) {
|
|
368
402
|
console.log();
|
|
369
403
|
console.error(chalk_1.default.red('Send failed'));
|
|
370
404
|
if (sendResult.error) {
|
|
@@ -377,6 +411,16 @@ async function buildPlaylist(userRequest, options = {}) {
|
|
|
377
411
|
action: 'send_playlist',
|
|
378
412
|
};
|
|
379
413
|
}
|
|
414
|
+
console.log(chalk_1.default.green('\nPlaylist sent'));
|
|
415
|
+
if (sendResult.deviceName) {
|
|
416
|
+
console.log(chalk_1.default.dim(` Device: ${sendResult.deviceName}`));
|
|
417
|
+
}
|
|
418
|
+
console.log();
|
|
419
|
+
return {
|
|
420
|
+
success: true,
|
|
421
|
+
playlist: sendResult.playlist,
|
|
422
|
+
action: 'send_playlist',
|
|
423
|
+
};
|
|
380
424
|
}
|
|
381
425
|
// Check if this is a publish_playlist action
|
|
382
426
|
if (params && params.action === 'publish_playlist') {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Art Blocks marketplace resolver.
|
|
4
|
+
*
|
|
5
|
+
* AB collection URLs (`artblocks.io/collection/{slug}`) point to a series,
|
|
6
|
+
* not a token. Their public Hasura GraphQL at `artblocks-mainnet.hasura.app`
|
|
7
|
+
* resolves the slug to a contract + a representative tokenId in one call.
|
|
8
|
+
* From there, the standard Raster reverse-lookup enumerates the rest of the
|
|
9
|
+
* series like any other marketplace.
|
|
10
|
+
*
|
|
11
|
+
* AB does not publish a first-party MCP server as of 2026-05-17, so the
|
|
12
|
+
* direct GraphQL path is the right shape.
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.resolveArtBlocksCollection = resolveArtBlocksCollection;
|
|
49
|
+
const logger = __importStar(require("../logger"));
|
|
50
|
+
const user_agent_1 = require("./user-agent");
|
|
51
|
+
const AB_GRAPHQL = 'https://artblocks-mainnet.hasura.app/v1/graphql';
|
|
52
|
+
/**
|
|
53
|
+
* Resolve an Art Blocks collection slug to its contract address + the first
|
|
54
|
+
* minted tokenId in the series.
|
|
55
|
+
*
|
|
56
|
+
* @throws When the slug is unknown, the project has no minted tokens, or
|
|
57
|
+
* the GraphQL call fails.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveArtBlocksCollection(slug) {
|
|
60
|
+
logger.debug(`[AB] Resolving collection slug "${slug}"`);
|
|
61
|
+
const query = 'query ($slug: String!) {' +
|
|
62
|
+
' projects_metadata(where: { slug: { _eq: $slug } }, limit: 1) {' +
|
|
63
|
+
' contract_address' +
|
|
64
|
+
' tokens(limit: 1, order_by: { invocation: asc }) { token_id }' +
|
|
65
|
+
' }' +
|
|
66
|
+
'}';
|
|
67
|
+
const response = await fetch(AB_GRAPHQL, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
|
|
70
|
+
body: JSON.stringify({ query, variables: { slug } }),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`Art Blocks GraphQL ${response.status} ${response.statusText}`);
|
|
74
|
+
}
|
|
75
|
+
const data = (await response.json());
|
|
76
|
+
if (data.errors && data.errors.length > 0) {
|
|
77
|
+
throw new Error(`Art Blocks GraphQL errors: ${data.errors.map((e) => e.message).join('; ')}`);
|
|
78
|
+
}
|
|
79
|
+
const project = data.data?.projects_metadata?.[0];
|
|
80
|
+
if (!project) {
|
|
81
|
+
throw new Error(`Art Blocks: no project found for slug "${slug}". ` +
|
|
82
|
+
'Check the URL — slugs come from artblocks.io/collection/{slug}. ' +
|
|
83
|
+
'AB Engine collaborations may live on a different subgraph and are not covered here yet.');
|
|
84
|
+
}
|
|
85
|
+
const firstToken = project.tokens?.[0];
|
|
86
|
+
if (!firstToken) {
|
|
87
|
+
throw new Error(`Art Blocks: project "${slug}" has no minted tokens yet. ` +
|
|
88
|
+
'Paste a specific token URL once at least one edition is minted.');
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
chain: 'ethereum',
|
|
92
|
+
contract: project.contract_address.toLowerCase(),
|
|
93
|
+
tokenId: firstToken.token_id,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Feral File public artwork API helpers.
|
|
4
|
+
*
|
|
5
|
+
* The exhibition API is the source of truth for public artwork IDs, including
|
|
6
|
+
* swapped artwork IDs where the public URL id differs from the on-chain token
|
|
7
|
+
* id. The CLI should consume the identity fields returned by the artwork
|
|
8
|
+
* endpoint instead of reconstructing contract identity from exhibition internals.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.resolveFeralFileArtwork = resolveFeralFileArtwork;
|
|
12
|
+
exports.parseFeralFileArtworkId = parseFeralFileArtworkId;
|
|
13
|
+
const user_agent_1 = require("./user-agent");
|
|
14
|
+
/**
|
|
15
|
+
* resolveFeralFileArtwork resolves a public Feral File artwork id into indexer
|
|
16
|
+
* token coordinates using the self-describing artwork API response.
|
|
17
|
+
*
|
|
18
|
+
* @param artworkId - Public artwork id from Feral File.
|
|
19
|
+
* @param fetchImpl - Fetch implementation, injected by tests.
|
|
20
|
+
* @returns Token coordinates suitable for ff-indexer-v2 lookup.
|
|
21
|
+
*/
|
|
22
|
+
async function resolveFeralFileArtwork(artworkId, fetchImpl = fetch) {
|
|
23
|
+
const normalizedArtworkId = artworkId.trim();
|
|
24
|
+
if (!normalizedArtworkId) {
|
|
25
|
+
throw new Error('Feral File artwork id is required');
|
|
26
|
+
}
|
|
27
|
+
const url = `https://feralfile.com/api/artworks/${encodeURIComponent(normalizedArtworkId)}`;
|
|
28
|
+
const response = await fetchImpl(url, {
|
|
29
|
+
headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
|
|
30
|
+
});
|
|
31
|
+
const body = (await response.json());
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(body.error?.message || `Feral File artwork lookup failed (${response.status})`);
|
|
34
|
+
}
|
|
35
|
+
const artwork = body.result;
|
|
36
|
+
if (!artwork) {
|
|
37
|
+
throw new Error('Feral File artwork lookup returned no result');
|
|
38
|
+
}
|
|
39
|
+
const chain = normalizeFeralFileChain(artwork.chain);
|
|
40
|
+
const contractAddress = stringField(artwork.contractAddress, 'contractAddress');
|
|
41
|
+
const tokenId = stringField(artwork.tokenID, 'tokenID');
|
|
42
|
+
return { chain, contractAddress, tokenId };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* parseFeralFileArtworkId extracts the public artwork id from supported Feral
|
|
46
|
+
* File artwork URLs. Non-URL strings are treated as raw artwork ids so
|
|
47
|
+
* structured build inputs can pass the id directly.
|
|
48
|
+
*
|
|
49
|
+
* @param input - Feral File artwork URL or raw public artwork id.
|
|
50
|
+
* @returns Public artwork id.
|
|
51
|
+
*/
|
|
52
|
+
function parseFeralFileArtworkId(input) {
|
|
53
|
+
const trimmed = input.trim();
|
|
54
|
+
if (!trimmed) {
|
|
55
|
+
throw new Error('Feral File artwork id is required');
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(trimmed);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return trimmed;
|
|
63
|
+
}
|
|
64
|
+
const host = parsed.hostname.replace(/^www\./, '').toLowerCase();
|
|
65
|
+
if (host !== 'feralfile.com') {
|
|
66
|
+
throw new Error(`Unsupported Feral File artwork URL host: ${parsed.hostname}`);
|
|
67
|
+
}
|
|
68
|
+
const match = parsed.pathname.match(/^\/exhibitions\/artwork\/([^/]+)\/?$/);
|
|
69
|
+
if (!match) {
|
|
70
|
+
throw new Error('Unsupported Feral File artwork URL path');
|
|
71
|
+
}
|
|
72
|
+
return decodeURIComponent(match[1]);
|
|
73
|
+
}
|
|
74
|
+
function normalizeFeralFileChain(value) {
|
|
75
|
+
if (typeof value !== 'string') {
|
|
76
|
+
throw new Error('Feral File artwork is missing chain');
|
|
77
|
+
}
|
|
78
|
+
const normalized = value.toLowerCase();
|
|
79
|
+
if (normalized === 'ethereum' || normalized === 'tezos') {
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Unsupported Feral File artwork chain: ${value}`);
|
|
83
|
+
}
|
|
84
|
+
function stringField(value, fieldName) {
|
|
85
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
86
|
+
throw new Error(`Feral File artwork is missing ${fieldName}`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveFeralFileToken = resolveFeralFileToken;
|
|
37
|
+
/**
|
|
38
|
+
* Feral File marketplace resolver.
|
|
39
|
+
*
|
|
40
|
+
* Both artwork and series URLs go through the self-describing
|
|
41
|
+
* `/api/artworks/{id}` endpoint (one GET returning chain, contract, and the
|
|
42
|
+
* on-chain tokenID — including the swapped tokenID case where the public
|
|
43
|
+
* URL id differs from the on-chain id). Series URLs add one extra hop:
|
|
44
|
+
* resolve the series slug, then look up its first artwork, then hand that
|
|
45
|
+
* artwork id to `resolveFeralFileArtwork`. The exhibition's contract list is
|
|
46
|
+
* not consulted — exhibitions can host multiple series across multiple
|
|
47
|
+
* contracts, so picking-first would silently route a Tezos series to an
|
|
48
|
+
* Ethereum sibling contract on the same exhibition.
|
|
49
|
+
*
|
|
50
|
+
* Show URLs are rejected: a single exhibition spans multiple series, which
|
|
51
|
+
* is wider than v1 supports.
|
|
52
|
+
*/
|
|
53
|
+
const logger = __importStar(require("../logger"));
|
|
54
|
+
const feral_file_artwork_1 = require("./feral-file-artwork");
|
|
55
|
+
const user_agent_1 = require("./user-agent");
|
|
56
|
+
const FF_API_BASE = 'https://feralfile.com/api';
|
|
57
|
+
async function ffFetch(path) {
|
|
58
|
+
const url = `${FF_API_BASE}${path}`;
|
|
59
|
+
logger.debug(`[FF API] GET ${url}`);
|
|
60
|
+
const response = await fetch(url, {
|
|
61
|
+
headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'application/json' },
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const body = await response.text();
|
|
65
|
+
throw new Error(`Feral File API ${response.status} ${response.statusText} for ${path}: ${body.slice(0, 200)}`);
|
|
66
|
+
}
|
|
67
|
+
return (await response.json());
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a parsed Feral File URL to on-chain token coordinates. For series
|
|
71
|
+
* URLs we return one representative token; the Raster reverse-lookup in the
|
|
72
|
+
* caller resolves to the series and enumerates the rest.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveFeralFileToken(parsed) {
|
|
75
|
+
switch (parsed.urlKind) {
|
|
76
|
+
case 'artwork':
|
|
77
|
+
return resolveFromArtworkID(parsed.identifier);
|
|
78
|
+
case 'series':
|
|
79
|
+
return resolveFromSeriesSlug(parsed.identifier);
|
|
80
|
+
case 'show':
|
|
81
|
+
throw new Error('Feral File `/exhibitions/shows/{slug}` URLs span multiple series; not supported in v1. ' +
|
|
82
|
+
'Paste a specific series (`/exhibitions/series/{slug}`) or artwork URL.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function resolveFromArtworkID(publicArtworkId) {
|
|
86
|
+
// /api/artworks/{id} returns chain + contractAddress + on-chain tokenID
|
|
87
|
+
// directly, including the swapped-id case where URL id != on-chain id.
|
|
88
|
+
const { chain, contractAddress, tokenId } = await (0, feral_file_artwork_1.resolveFeralFileArtwork)(publicArtworkId);
|
|
89
|
+
return {
|
|
90
|
+
chain,
|
|
91
|
+
contract: chain === 'ethereum' ? contractAddress.toLowerCase() : contractAddress,
|
|
92
|
+
tokenId,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function resolveFromSeriesSlug(slug) {
|
|
96
|
+
const series = await ffFetch(`/series?slug=${encodeURIComponent(slug)}`);
|
|
97
|
+
if (!series.result || series.result.length === 0) {
|
|
98
|
+
throw new Error(`Feral File: no series found for slug "${slug}".`);
|
|
99
|
+
}
|
|
100
|
+
// Slug collisions across exhibitions are documented in ff-exhibition#3039.
|
|
101
|
+
// Take the first record; the user can pass the artwork URL directly to disambiguate.
|
|
102
|
+
if (series.result.length > 1) {
|
|
103
|
+
logger.warn(`[FF API] Slug "${slug}" matched ${series.result.length} series across exhibitions; ` +
|
|
104
|
+
'taking the first. Pass /exhibitions/artwork/{tokenId} to disambiguate.');
|
|
105
|
+
}
|
|
106
|
+
const picked = series.result[0];
|
|
107
|
+
const artworks = await ffFetch(`/artworks?seriesID=${encodeURIComponent(picked.id)}`);
|
|
108
|
+
if (!artworks.result || artworks.result.length === 0) {
|
|
109
|
+
throw new Error(`Feral File: series "${slug}" has no artworks.`);
|
|
110
|
+
}
|
|
111
|
+
// Hand the representative artwork's public id to the canonical resolver so
|
|
112
|
+
// we get the right (chain, contract, on-chain tokenID) for THIS series —
|
|
113
|
+
// not whatever picking-first off the exhibition's contract list returned.
|
|
114
|
+
return resolveFromArtworkID(artworks.result[0].id);
|
|
115
|
+
}
|
|
@@ -117,13 +117,13 @@ function isTransientDeviceNetworkError(error) {
|
|
|
117
117
|
* @example
|
|
118
118
|
* // Send to first device
|
|
119
119
|
* const result = await sendPlaylistToDevice({
|
|
120
|
-
* playlist: { version: '1.
|
|
120
|
+
* playlist: { version: '1.1.0', title: 'My Collection', items: [...] }
|
|
121
121
|
* });
|
|
122
122
|
*
|
|
123
123
|
* @example
|
|
124
124
|
* // Send to specific device by name
|
|
125
125
|
* const result = await sendPlaylistToDevice({
|
|
126
|
-
* playlist: { version: '1.
|
|
126
|
+
* playlist: { version: '1.1.0', title: 'My Collection', items: [...] },
|
|
127
127
|
* deviceName: 'Living Room Display'
|
|
128
128
|
* });
|
|
129
129
|
*/
|
|
@@ -204,7 +204,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
204
204
|
success: false,
|
|
205
205
|
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
206
206
|
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
207
|
-
'If the device IP changed (e.g. after a factory reset), run:
|
|
207
|
+
'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
// Check response status
|
|
@@ -239,7 +239,7 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
239
239
|
success: false,
|
|
240
240
|
error: `Could not reach device "${deviceLabel}" at ${device.host}`,
|
|
241
241
|
details: 'Check that the device is powered on and reachable on your network. ' +
|
|
242
|
-
'If the device IP changed (e.g. after a factory reset), run:
|
|
242
|
+
'If the device IP changed (e.g. after a factory reset), run: ff-cli setup',
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
return {
|
|
@@ -41,7 +41,7 @@ const ff1Device = require('./ff1-device');
|
|
|
41
41
|
const domainResolver = require('./domain-resolver');
|
|
42
42
|
const logger = require('../logger');
|
|
43
43
|
/**
|
|
44
|
-
* Build DP1 v1.
|
|
44
|
+
* Build DP1 v1.1.0 compliant playlist
|
|
45
45
|
*
|
|
46
46
|
* This is the actual implementation called by AI orchestrator's function calling.
|
|
47
47
|
* Uses core playlist-builder utilities.
|