@feralfile/cli 2.10.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -23
- package/config.json.example +6 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +85 -40
- package/dist/src/commands/enrich.js +18 -23
- package/dist/src/commands/find.js +144 -106
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +162 -84
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +82 -7
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +256 -6
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +11 -2
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +223 -34
- package/dist/src/utilities/raster-client.js +101 -14
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- 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 +331 -28
- package/docs/EXAMPLES.md +33 -33
- package/docs/PROJECT_SPEC.md +55 -36
- package/docs/README.md +145 -43
- package/package.json +5 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Media URL fix-ups for token sources whose URLs the FF1 cannot load as given.
|
|
4
|
+
*
|
|
5
|
+
* Raster stores token metadata as a snapshot. Two consequences matter for
|
|
6
|
+
* playback, both measured on feral-file#3546 (2026-10-20):
|
|
7
|
+
* - most Feral File works carry a bare `ipfs://` CID, which no browser or
|
|
8
|
+
* FF1 resolves without a gateway;
|
|
9
|
+
* - works whose metadata predates Feral File's CDN move still point at
|
|
10
|
+
* `cdn.feralfileassets.com`, a host that no longer resolves. The same
|
|
11
|
+
* paths are served by `cdn.artworks.feralfile.io`.
|
|
12
|
+
* - some Feral File works carry an https URL on `ipfs.bitmark.com`, Feral
|
|
13
|
+
* File's legacy gateway, instead of a bare CID. The owner asked for those
|
|
14
|
+
* to go through the Feral File gateway like every other Feral File CID
|
|
15
|
+
* (2026-10-22), so one gateway setting governs all Feral File media.
|
|
16
|
+
*
|
|
17
|
+
* The rewrite is applied here rather than asked of Raster because it is the
|
|
18
|
+
* one fix ff-cli can make alone. Remove a DEAD_HOST_REWRITES entry only once
|
|
19
|
+
* Raster re-reads `tokenURI` for the affected contracts (ask 2 on #3546).
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.LEGACY_FERAL_FILE_GATEWAYS = exports.DEAD_HOST_REWRITES = exports.FERAL_FILE_PLATFORM_ID = void 0;
|
|
23
|
+
exports.fixUpMediaUrl = fixUpMediaUrl;
|
|
24
|
+
/**
|
|
25
|
+
* FERAL_FILE_PLATFORM_ID is Raster's `artwork.platform.id` for Feral File.
|
|
26
|
+
* Feral File works use Feral File's own gateway, which pins them.
|
|
27
|
+
*/
|
|
28
|
+
exports.FERAL_FILE_PLATFORM_ID = 'feralfile';
|
|
29
|
+
/** DEAD_HOST_REWRITES maps retired asset hosts to the hosts serving the same paths. */
|
|
30
|
+
exports.DEAD_HOST_REWRITES = {
|
|
31
|
+
'cdn.feralfileassets.com': 'cdn.artworks.feralfile.io',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* LEGACY_FERAL_FILE_GATEWAYS are retired Feral File-operated IPFS gateway
|
|
35
|
+
* hosts. A `/ipfs/<cid>` URL on one is re-pointed at the configured Feral
|
|
36
|
+
* File gateway whatever the platform id says, because the host itself proves
|
|
37
|
+
* the CID is Feral File media. A URL without an `/ipfs/` path is left alone.
|
|
38
|
+
*/
|
|
39
|
+
exports.LEGACY_FERAL_FILE_GATEWAYS = new Set(['ipfs.bitmark.com']);
|
|
40
|
+
/**
|
|
41
|
+
* legacyGatewayToFeralFile returns the URL re-pointed at the Feral File
|
|
42
|
+
* gateway, or null when the URL is not a `/ipfs/` path on a legacy host.
|
|
43
|
+
* An empty query (`?` with nothing after it, as Raster emits) is dropped.
|
|
44
|
+
*/
|
|
45
|
+
function legacyGatewayToFeralFile(parsed, gateways) {
|
|
46
|
+
if (!exports.LEGACY_FERAL_FILE_GATEWAYS.has(parsed.hostname.toLowerCase())) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const match = /^\/ipfs\/(.+)$/i.exec(parsed.pathname);
|
|
50
|
+
if (!match) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return `${gateways.feralFile}${match[1]}${parsed.search}${parsed.hash}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* ipfsToGateway turns `ipfs://<cid>[/path]` into a gateway URL, or '' when the
|
|
57
|
+
* URI names no CID. An `ipfs://ipfs/<cid>` form appears in the wild and is
|
|
58
|
+
* treated the same way the source-resolver package treats it.
|
|
59
|
+
*/
|
|
60
|
+
function ipfsToGateway(uri, platformId, gateways) {
|
|
61
|
+
const path = uri
|
|
62
|
+
.slice('ipfs://'.length)
|
|
63
|
+
.replace(/^ipfs\//i, '')
|
|
64
|
+
.replace(/^\/+/, '');
|
|
65
|
+
if (!path) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
const gateway = platformId === exports.FERAL_FILE_PLATFORM_ID ? gateways.feralFile : gateways.default;
|
|
69
|
+
return `${gateway}${path}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* fixUpMediaUrl returns a URL the FF1 display path can load, or ''.
|
|
73
|
+
*
|
|
74
|
+
* Order: map `ipfs://` through the platform's gateway, then move a legacy
|
|
75
|
+
* Feral File gateway URL onto the Feral File gateway, then swap a retired
|
|
76
|
+
* host for its replacement. Data URIs pass through unchanged so
|
|
77
|
+
* convertToDP1Item can report them as data URIs instead of as missing media.
|
|
78
|
+
* Any other non-HTTP(S) scheme becomes '' because nothing downstream can load
|
|
79
|
+
* it, and an empty source is reported as "No source URL available".
|
|
80
|
+
*
|
|
81
|
+
* @param url - Source URL from the token source
|
|
82
|
+
* @param platformId - Raster `artwork.platform.id`, or null when unknown
|
|
83
|
+
* @param gateways - Resolved IPFS gateways from config
|
|
84
|
+
* @returns Loadable URL, the data URI unchanged, or ''
|
|
85
|
+
*/
|
|
86
|
+
function fixUpMediaUrl(url, platformId, gateways) {
|
|
87
|
+
const trimmed = typeof url === 'string' ? url.trim() : '';
|
|
88
|
+
if (!trimmed || trimmed.startsWith('data:')) {
|
|
89
|
+
return trimmed;
|
|
90
|
+
}
|
|
91
|
+
const candidate = /^ipfs:\/\//i.test(trimmed)
|
|
92
|
+
? ipfsToGateway(trimmed, platformId, gateways)
|
|
93
|
+
: trimmed;
|
|
94
|
+
if (!candidate) {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = new URL(candidate);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
const regatewayed = legacyGatewayToFeralFile(parsed, gateways);
|
|
108
|
+
if (regatewayed) {
|
|
109
|
+
return regatewayed;
|
|
110
|
+
}
|
|
111
|
+
const replacement = exports.DEAD_HOST_REWRITES[parsed.hostname.toLowerCase()];
|
|
112
|
+
if (replacement) {
|
|
113
|
+
parsed.hostname = replacement;
|
|
114
|
+
return parsed.toString();
|
|
115
|
+
}
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
@@ -64,9 +64,9 @@ const user_agent_1 = require("./user-agent");
|
|
|
64
64
|
const NEORT_API_BASE = 'https://api.neort.io/v1';
|
|
65
65
|
const NEORT_CDN_BASE = 'https://d32h66pp7fue57.cloudfront.net';
|
|
66
66
|
/**
|
|
67
|
-
* Resolve a Neort art id to a
|
|
67
|
+
* Resolve a Neort art id to a displayable asset URL plus title/artist metadata.
|
|
68
68
|
*
|
|
69
|
-
* @throws When the id is unknown, the art is private, or no
|
|
69
|
+
* @throws When the id is unknown, the art is private, or no displayable asset
|
|
70
70
|
* can be constructed from the response.
|
|
71
71
|
*/
|
|
72
72
|
async function resolveNeortArt(id) {
|
|
@@ -90,7 +90,7 @@ async function resolveNeortArt(id) {
|
|
|
90
90
|
? `${NEORT_CDN_BASE}/artPreview/${data.resourceFileName}`
|
|
91
91
|
: thumbnailUrl;
|
|
92
92
|
if (!assetUrl) {
|
|
93
|
-
throw new Error(`Neort: art "${id}" has no
|
|
93
|
+
throw new Error(`Neort: art "${id}" has no displayable asset (resourceFileName and thumbnail are both empty).`);
|
|
94
94
|
}
|
|
95
95
|
return {
|
|
96
96
|
id: data.id,
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* NFT Indexer Client
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* NFT Indexer Client (ff-indexer-v2).
|
|
4
|
+
*
|
|
5
|
+
* The Feral File indexer is the default token source. Raster
|
|
6
|
+
* (src/utilities/token-source-raster.ts) answers instead when
|
|
7
|
+
* `tokenSource: "raster"` is configured (feral-file#3576). This module is
|
|
8
|
+
* reached exclusively through src/utilities/token-source-indexer.ts; do not
|
|
9
|
+
* add callers that bypass the TokenSource contract.
|
|
10
|
+
*
|
|
11
|
+
* Vendor-neutral item construction (convertToDP1Item and the standard/chain
|
|
12
|
+
* helpers) lives in src/utilities/token-item.ts and is re-exported here only
|
|
13
|
+
* so existing tests keep their import paths.
|
|
6
14
|
*/
|
|
7
|
-
const { PlaylistItemBuilder, ProvenanceBuilder, ContractBuilder } = require('dp1-js');
|
|
8
15
|
// The production endpoint is hardcoded, as PROJECT_SPEC states, and stays
|
|
9
16
|
// that way. INDEXER_API_URL is a test hook, not configuration: the
|
|
10
17
|
// find-command suite spawns the CLI as a child process, so the only way to
|
|
@@ -20,8 +27,7 @@ const GRAPHQL_ENDPOINT = process.env.NODE_ENV === 'test' && process.env.INDEXER_
|
|
|
20
27
|
: 'https://indexer.feralfile.com/graphql';
|
|
21
28
|
const { fetchWithTimeout } = require('./http');
|
|
22
29
|
const logger = require('../logger');
|
|
23
|
-
const {
|
|
24
|
-
const { buildInlineManifestForToken } = require('./ref-manifest');
|
|
30
|
+
const { convertToDP1Item, detectTokenStandard, extractArtistName, getBestMediaUrl, normalizeCidStandard, resolveTokenStandard, } = require('./token-item');
|
|
25
31
|
// Polling configuration (in milliseconds)
|
|
26
32
|
const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
|
|
27
33
|
const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
|
|
@@ -103,158 +109,6 @@ function buildTokensListQuery(params = {}) {
|
|
|
103
109
|
}
|
|
104
110
|
`;
|
|
105
111
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Initialize indexer (no-op for compatibility)
|
|
108
|
-
*
|
|
109
|
-
* The indexer endpoint is now hardcoded to the Feral File production endpoint.
|
|
110
|
-
* This function is kept for backwards compatibility but does nothing.
|
|
111
|
-
*
|
|
112
|
-
* @deprecated This function is no longer needed as the endpoint is hardcoded
|
|
113
|
-
* @param {Object} _config - Unused config parameter
|
|
114
|
-
*/
|
|
115
|
-
function initializeIndexer(_config) {
|
|
116
|
-
logger.debug('[NFT Indexer] Using endpoint:', GRAPHQL_ENDPOINT);
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* detectTokenStandard guesses a token standard from a chain name and an address.
|
|
120
|
-
*
|
|
121
|
-
* This is the last-resort fallback, not the answer. It sees no token — only a
|
|
122
|
-
* chain name and an address format — so it cannot tell ERC-721 from ERC-1155
|
|
123
|
-
* and answers `erc721` for every EVM contract. It is still needed in two
|
|
124
|
-
* places: a lookup by coordinate has to build *some* CID before the indexer has
|
|
125
|
-
* been asked anything (see buildTokenCID), and a token row that arrived without
|
|
126
|
-
* a `standard` field has nothing better to offer.
|
|
127
|
-
*
|
|
128
|
-
* Prefer, in order: the indexer row's own `standard`, a caller's assertion, and
|
|
129
|
-
* only then this. On-chain detection is deliberately not attempted here — the
|
|
130
|
-
* indexer already stores the answer and selecting it costs nothing.
|
|
131
|
-
*
|
|
132
|
-
* @param {string} chain - Blockchain network
|
|
133
|
-
* @param {string} contractAddress - Contract address
|
|
134
|
-
* @returns {string} Token standard (erc721, erc1155, fa2, or other)
|
|
135
|
-
*/
|
|
136
|
-
function detectTokenStandard(chain, contractAddress) {
|
|
137
|
-
const lowerChain = chain.toLowerCase();
|
|
138
|
-
// Tezos contracts use FA2 standard
|
|
139
|
-
if (lowerChain === 'tezos' || contractAddress.startsWith('KT')) {
|
|
140
|
-
return 'fa2';
|
|
141
|
-
}
|
|
142
|
-
// Ethereum: erc721 is a guess, not a reading. Only the indexer row or a
|
|
143
|
-
// caller's assertion can distinguish an ERC-1155 token from an ERC-721 one.
|
|
144
|
-
if (lowerChain === 'ethereum') {
|
|
145
|
-
return 'erc721';
|
|
146
|
-
}
|
|
147
|
-
return 'other';
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Token standards the indexer keys CIDs on. A standard outside this set —
|
|
151
|
-
* asserted by a caller or arriving on a row — says nothing the CID can carry,
|
|
152
|
-
* so detection decides instead.
|
|
153
|
-
*/
|
|
154
|
-
const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
|
|
155
|
-
/**
|
|
156
|
-
* normalizeCidStandard returns a standard the CID format can carry, or ''.
|
|
157
|
-
*
|
|
158
|
-
* Both the indexer row and a caller's assertion are untrusted strings from
|
|
159
|
-
* outside this module ('ERC1155', ' fa2 ', 'ERC-1155', undefined). Anything not
|
|
160
|
-
* in CID_STANDARDS is treated as absent rather than passed through, so a
|
|
161
|
-
* misspelled standard never reaches a CID or a DP-1 `provenance.contract`.
|
|
162
|
-
*
|
|
163
|
-
* @param {unknown} value - Candidate standard
|
|
164
|
-
* @returns {string} Normalized standard, or '' when it is not one we can use
|
|
165
|
-
*/
|
|
166
|
-
function normalizeCidStandard(value) {
|
|
167
|
-
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
168
|
-
return CID_STANDARDS.has(normalized) ? normalized : '';
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* resolveTokenStandard picks the standard a CID is built with.
|
|
172
|
-
*
|
|
173
|
-
* detectTokenStandard cannot tell ERC-721 from ERC-1155 — it has only a chain
|
|
174
|
-
* and an address, and answers erc721 for every EVM contract. The indexer keys
|
|
175
|
-
* the two differently (`eip155:1:erc721:…` and `eip155:1:erc1155:…`), so an
|
|
176
|
-
* ERC-1155 token looked up by detection alone is simply never found. When the
|
|
177
|
-
* caller knows the standard — a DP-1 playlist carries it in
|
|
178
|
-
* `provenance.contract.standard` — that assertion wins.
|
|
179
|
-
*
|
|
180
|
-
* @param {string} chain - Blockchain network
|
|
181
|
-
* @param {string} contractAddress - Contract address
|
|
182
|
-
* @param {string} [asserted] - Standard the caller vouches for, if any
|
|
183
|
-
* @returns {string} Token standard for the CID
|
|
184
|
-
*/
|
|
185
|
-
function resolveTokenStandard(chain, contractAddress, asserted) {
|
|
186
|
-
return normalizeCidStandard(asserted) || detectTokenStandard(chain, contractAddress);
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* CAIP-2 namespaces mapped to the chain values DP-1 `provenance.contract` allows.
|
|
190
|
-
*
|
|
191
|
-
* The indexer names a token's chain in CAIP-2 (`eip155:1`, `tezos:mainnet`),
|
|
192
|
-
* which is authoritative: it comes from the row rather than from the caller's
|
|
193
|
-
* guess at which network an address belongs to. Namespaces outside this map
|
|
194
|
-
* have no DP-1 equivalent, so they fall back to the caller's chain name.
|
|
195
|
-
*/
|
|
196
|
-
const CAIP2_NAMESPACE_TO_DP1_CHAIN = {
|
|
197
|
-
eip155: 'evm',
|
|
198
|
-
tezos: 'tezos',
|
|
199
|
-
bitmark: 'bitmark',
|
|
200
|
-
};
|
|
201
|
-
/**
|
|
202
|
-
* dp1ChainFromCaip2 derives a DP-1 chain from a CAIP-2 chain id.
|
|
203
|
-
*
|
|
204
|
-
* @param {unknown} caip2Chain - CAIP-2 chain id from an indexer row, e.g. `eip155:1`
|
|
205
|
-
* @returns {string} DP-1 chain (`evm`, `tezos`, `bitmark`), or '' when unknown or absent
|
|
206
|
-
*/
|
|
207
|
-
function dp1ChainFromCaip2(caip2Chain) {
|
|
208
|
-
if (typeof caip2Chain !== 'string') {
|
|
209
|
-
return '';
|
|
210
|
-
}
|
|
211
|
-
const namespace = caip2Chain.trim().toLowerCase().split(':')[0];
|
|
212
|
-
return CAIP2_NAMESPACE_TO_DP1_CHAIN[namespace] || '';
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Client chain names mapped to the chain values DP-1 `provenance.contract` allows.
|
|
216
|
-
*
|
|
217
|
-
* This is for DP-1 provenance output, NOT for indexer queries: the indexer uses
|
|
218
|
-
* `eth`/`tez`/`bmk` while DP-1 uses `evm`/`tezos`/`bitmark`. It answers only for
|
|
219
|
-
* tokens that reached us without an indexer row — a caller-supplied coordinate.
|
|
220
|
-
*/
|
|
221
|
-
const CHAIN_NAME_TO_DP1_CHAIN = {
|
|
222
|
-
ethereum: 'evm',
|
|
223
|
-
polygon: 'evm',
|
|
224
|
-
arbitrum: 'evm',
|
|
225
|
-
optimism: 'evm',
|
|
226
|
-
base: 'evm',
|
|
227
|
-
zora: 'evm',
|
|
228
|
-
tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
|
|
229
|
-
bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
|
|
230
|
-
};
|
|
231
|
-
/**
|
|
232
|
-
* resolveDp1Chain picks the DP-1 chain for a token's `provenance.contract`.
|
|
233
|
-
*
|
|
234
|
-
* The row's CAIP-2 chain and the caller's chain name are not two guesses to try
|
|
235
|
-
* in turn — they are different claims, and the row's is the network the indexer
|
|
236
|
-
* recorded. So a token carrying a row chain is answered from that chain alone:
|
|
237
|
-
* a namespace with no DP-1 equivalent becomes `other`, never the caller's name.
|
|
238
|
-
* Falling through would be actively wrong, because the owner and contract paths
|
|
239
|
-
* label every non-KT address `ethereum` before mapping — a Solana row would
|
|
240
|
-
* inherit that label and be published as an EVM contract. `other` is a true
|
|
241
|
-
* statement about a chain DP-1 cannot name; `evm` is a false one.
|
|
242
|
-
*
|
|
243
|
-
* The name map answers only when the token has no row chain at all, which is a
|
|
244
|
-
* token assembled from a caller-supplied coordinate rather than an indexer row.
|
|
245
|
-
*
|
|
246
|
-
* @param {Object} token - Token in the internal standard format
|
|
247
|
-
* @param {string} [token.caip2Chain] - CAIP-2 chain from the indexer row, when it came from one
|
|
248
|
-
* @param {string} [token.chain] - Client chain name the caller inferred
|
|
249
|
-
* @returns {string} DP-1 chain (`evm`, `tezos`, `bitmark`, or `other`)
|
|
250
|
-
*/
|
|
251
|
-
function resolveDp1Chain(token) {
|
|
252
|
-
const rowChain = typeof token.caip2Chain === 'string' ? token.caip2Chain.trim() : '';
|
|
253
|
-
if (rowChain) {
|
|
254
|
-
return dp1ChainFromCaip2(rowChain) || 'other';
|
|
255
|
-
}
|
|
256
|
-
return CHAIN_NAME_TO_DP1_CHAIN[String(token.chain || '').toLowerCase()] || 'other';
|
|
257
|
-
}
|
|
258
112
|
/**
|
|
259
113
|
* Build CAIP-2 token CID for indexer v2
|
|
260
114
|
*
|
|
@@ -369,78 +223,6 @@ async function queryTokenDataFromIndexer(tokenCID) {
|
|
|
369
223
|
return null;
|
|
370
224
|
}
|
|
371
225
|
}
|
|
372
|
-
/**
|
|
373
|
-
* Extract artist name from artists array
|
|
374
|
-
*
|
|
375
|
-
* Converts the new artists array format to a single artist name string.
|
|
376
|
-
*
|
|
377
|
-
* @param {Array} artists - Array of artist objects with did and name
|
|
378
|
-
* @returns {string} Artist name or empty string
|
|
379
|
-
*/
|
|
380
|
-
function extractArtistName(artists) {
|
|
381
|
-
if (!Array.isArray(artists) || artists.length === 0) {
|
|
382
|
-
return '';
|
|
383
|
-
}
|
|
384
|
-
// Use first artist's name, or join multiple if needed
|
|
385
|
-
return artists[0]?.name || '';
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Check whether a media URL can be used as DP1 item source.
|
|
389
|
-
*
|
|
390
|
-
* @param {string} url - Candidate media URL
|
|
391
|
-
* @returns {boolean} True when URL is usable in DP1 source
|
|
392
|
-
*/
|
|
393
|
-
function isUsableSourceUrl(url) {
|
|
394
|
-
if (!url || typeof url !== 'string') {
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
if (url.startsWith('data:')) {
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
if (url.length > 1024) {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
return true;
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* getBestMediaUrl picks a DP1-usable URL from indexer `display` and `media_assets`.
|
|
407
|
-
*
|
|
408
|
-
* Order: display.animation_url, transcoded asset URLs (source + variants), display.image_url.
|
|
409
|
-
*
|
|
410
|
-
* @param {Object} display - Token `display` field (merged presentation from indexer)
|
|
411
|
-
* @param {Array<Object>} mediaAssets - Token `media_assets` rows
|
|
412
|
-
* @returns {Object} Object with url and thumbnail properties
|
|
413
|
-
*/
|
|
414
|
-
function getBestMediaUrl(display = {}, mediaAssets = []) {
|
|
415
|
-
const urlsFromAssets = [];
|
|
416
|
-
for (const asset of Array.isArray(mediaAssets) ? mediaAssets : []) {
|
|
417
|
-
if (asset?.source_url) {
|
|
418
|
-
urlsFromAssets.push(asset.source_url);
|
|
419
|
-
}
|
|
420
|
-
const v = asset?.variants;
|
|
421
|
-
if (v && typeof v === 'object') {
|
|
422
|
-
for (const val of Object.values(v)) {
|
|
423
|
-
if (typeof val === 'string') {
|
|
424
|
-
urlsFromAssets.push(val);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const candidates = [display?.animation_url, ...urlsFromAssets, display?.image_url];
|
|
430
|
-
for (const candidate of candidates) {
|
|
431
|
-
if (isUsableSourceUrl(candidate)) {
|
|
432
|
-
return {
|
|
433
|
-
url: candidate,
|
|
434
|
-
thumbnail: display.image_url || '',
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
const imageUrl = display.image_url || '';
|
|
439
|
-
return {
|
|
440
|
-
url: imageUrl,
|
|
441
|
-
thumbnail: imageUrl,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
226
|
/**
|
|
445
227
|
* Map indexer token row (GraphQL `display` + `media_assets`) to internal standard format.
|
|
446
228
|
*
|
|
@@ -505,150 +287,6 @@ function mapIndexerDataToStandardFormat(indexerData, chain, standard) {
|
|
|
505
287
|
},
|
|
506
288
|
};
|
|
507
289
|
}
|
|
508
|
-
/**
|
|
509
|
-
* Convert token data to DP1 item format
|
|
510
|
-
*
|
|
511
|
-
* Extracts source URL from indexer data with proper priority:
|
|
512
|
-
* animation_url > image.url, ensuring the best quality media is used.
|
|
513
|
-
*
|
|
514
|
-
* When the token carries description, artist, or a still image distinct from
|
|
515
|
-
* the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
|
|
516
|
-
*
|
|
517
|
-
* @param {Object} tokenData - Token data in standard format
|
|
518
|
-
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
519
|
-
* (video/audio play natural length per DP-1 §4.1)
|
|
520
|
-
* @returns {Object} DP1 item with source URL from indexer
|
|
521
|
-
*/
|
|
522
|
-
function convertToDP1Item(tokenData, duration) {
|
|
523
|
-
const { token } = tokenData;
|
|
524
|
-
if (!token) {
|
|
525
|
-
return {
|
|
526
|
-
success: false,
|
|
527
|
-
error: tokenData.error || 'Invalid token data',
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
// Generate deterministic ID for this item based on contract + tokenId
|
|
531
|
-
// Use a simple hash to create a consistent ID
|
|
532
|
-
const crypto = require('crypto');
|
|
533
|
-
const idSource = `${token.contractAddress}-${token.tokenId}`;
|
|
534
|
-
const hash = crypto.createHash('sha256').update(idSource).digest('hex');
|
|
535
|
-
// Format as UUID-like string for consistency
|
|
536
|
-
const itemId = `${hash.substr(0, 8)}-${hash.substr(8, 4)}-${hash.substr(12, 4)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
|
|
537
|
-
// Get source URL from indexer data
|
|
538
|
-
// Priority: animation_url > image.url (from getBestMediaUrl)
|
|
539
|
-
const candidateSourceUrls = [
|
|
540
|
-
token.animation_url || token.animationUrl,
|
|
541
|
-
token.image && typeof token.image === 'object' ? token.image.url : '',
|
|
542
|
-
]
|
|
543
|
-
.map((value) => String(value || ''))
|
|
544
|
-
.filter(Boolean);
|
|
545
|
-
let sourceUrl = candidateSourceUrls.find((url) => isUsableSourceUrl(url)) || '';
|
|
546
|
-
if (!sourceUrl) {
|
|
547
|
-
sourceUrl = candidateSourceUrls[0] || '';
|
|
548
|
-
}
|
|
549
|
-
// Validate source URL
|
|
550
|
-
if (!sourceUrl) {
|
|
551
|
-
logger.warn('[NFT Indexer] No source URL found for token:', {
|
|
552
|
-
contractAddress: token.contractAddress,
|
|
553
|
-
tokenId: token.tokenId,
|
|
554
|
-
});
|
|
555
|
-
return {
|
|
556
|
-
success: false,
|
|
557
|
-
error: 'No source URL available',
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
// Skip data URIs (base64-encoded content)
|
|
561
|
-
if (sourceUrl.startsWith('data:')) {
|
|
562
|
-
logger.debug('[NFT Indexer] Skipping token with data URI:', {
|
|
563
|
-
contractAddress: token.contractAddress,
|
|
564
|
-
tokenId: token.tokenId,
|
|
565
|
-
});
|
|
566
|
-
return {
|
|
567
|
-
success: false,
|
|
568
|
-
error: 'Source is a data URI (not supported)',
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
// Skip URLs that exceed DP1 spec limit (1024 characters)
|
|
572
|
-
if (sourceUrl.length > 1024) {
|
|
573
|
-
logger.debug('[NFT Indexer] Skipping token with source URL too long:', {
|
|
574
|
-
contractAddress: token.contractAddress,
|
|
575
|
-
tokenId: token.tokenId,
|
|
576
|
-
urlLength: sourceUrl.length,
|
|
577
|
-
});
|
|
578
|
-
return {
|
|
579
|
-
success: false,
|
|
580
|
-
error: `Source URL too long (${sourceUrl.length} chars, max 1024)`,
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
// Build via dp1-js PlaylistItemBuilder so leaf blocks match DP-1 AJV schema.
|
|
584
|
-
const itemBuilder = new PlaylistItemBuilder()
|
|
585
|
-
.id(itemId)
|
|
586
|
-
.source(sourceUrl)
|
|
587
|
-
.license('open')
|
|
588
|
-
.provenance(new ProvenanceBuilder().type('onChain').contract(new ContractBuilder()
|
|
589
|
-
.chain(resolveDp1Chain(token))
|
|
590
|
-
.standard(token.standard || detectTokenStandard(token.chain, token.contractAddress))
|
|
591
|
-
.address(token.contractAddress)
|
|
592
|
-
.tokenId(String(token.tokenId))));
|
|
593
|
-
// Add title if available (valid DP1 field)
|
|
594
|
-
if (token.name) {
|
|
595
|
-
itemBuilder.title(token.name);
|
|
596
|
-
}
|
|
597
|
-
// Carry description, artist, and still image inline (DP-1 Playlist Extension
|
|
598
|
-
// §3.6). These are the fields the indexer returns that have no PlaylistItem
|
|
599
|
-
// home; before inline manifests existed they were mapped and then dropped on
|
|
600
|
-
// the floor here. The helper returns undefined when the token has nothing
|
|
601
|
-
// beyond its title, so thin tokens stay thin.
|
|
602
|
-
const inlineManifest = buildInlineManifestForToken(token, { sourceUrl });
|
|
603
|
-
if (inlineManifest) {
|
|
604
|
-
itemBuilder.inlineManifest(inlineManifest);
|
|
605
|
-
}
|
|
606
|
-
applyTimingToItemBuilder(itemBuilder, { mimeType: token.image?.mimeType, sourceUrl }, duration, buildDefaultDisplay());
|
|
607
|
-
let dp1Item;
|
|
608
|
-
try {
|
|
609
|
-
dp1Item = itemBuilder.build();
|
|
610
|
-
}
|
|
611
|
-
catch (error) {
|
|
612
|
-
return {
|
|
613
|
-
success: false,
|
|
614
|
-
error: error instanceof Error ? error.message : String(error),
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
|
|
618
|
-
title: token.name,
|
|
619
|
-
source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
|
|
620
|
-
});
|
|
621
|
-
return {
|
|
622
|
-
success: true,
|
|
623
|
-
item: dp1Item,
|
|
624
|
-
still: stillOf(token),
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
/**
|
|
628
|
-
* stillOf names the token's still image as the indexer identifies it, or ''.
|
|
629
|
-
*
|
|
630
|
-
* This is `display.image_url`, the field the indexer reserves for a still and
|
|
631
|
-
* the same one `buildInlineManifestForToken` writes into `thumbnails.default`.
|
|
632
|
-
* It is returned beside the DP-1 item rather than inside it because the item
|
|
633
|
-
* only carries the still when it differs from the item's own source, and a
|
|
634
|
-
* consumer building a manifest against a *different* source (see
|
|
635
|
-
* enrich-playlist.ts) needs the still regardless. Nothing about the URL's
|
|
636
|
-
* shape is inspected: the indexer's word that it is an image is the evidence,
|
|
637
|
-
* and a suffix check would reject the extensionless CDN URLs it commonly
|
|
638
|
-
* returns.
|
|
639
|
-
*
|
|
640
|
-
* @param {Object} token - Token in the internal standard format
|
|
641
|
-
* @returns {string} http(s) still URL, or '' when the indexer named none
|
|
642
|
-
*/
|
|
643
|
-
function stillOf(token) {
|
|
644
|
-
const image = token.image;
|
|
645
|
-
// Only `thumbnail` qualifies. `image.url` is the media getBestMediaUrl
|
|
646
|
-
// selected as the source, which is the live rendition whenever one exists —
|
|
647
|
-
// exactly what must never land in a thumbnail slot.
|
|
648
|
-
const candidate = typeof image === 'string' ? image : typeof image === 'object' && image ? image.thumbnail : '';
|
|
649
|
-
const trimmed = String(candidate || '').trim();
|
|
650
|
-
return trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : '';
|
|
651
|
-
}
|
|
652
290
|
/**
|
|
653
291
|
* Get NFT token information from indexer and return as DP1 item (supports single or batch)
|
|
654
292
|
* @param {Object|Array} params - Token parameters (single object or array)
|
|
@@ -1222,8 +860,6 @@ async function queryTokensByContract(contractAddress, limit = 100, offset = 0) {
|
|
|
1222
860
|
}
|
|
1223
861
|
}
|
|
1224
862
|
module.exports = {
|
|
1225
|
-
// Initialization
|
|
1226
|
-
initializeIndexer,
|
|
1227
863
|
// Primary functions (return DP1 items)
|
|
1228
864
|
getNFTTokenInfo,
|
|
1229
865
|
// Batch processing
|
|
@@ -628,6 +628,8 @@ function detectMimeType(url) {
|
|
|
628
628
|
* (DP-1 §4.1). Needed when the URL has no file extension to detect from —
|
|
629
629
|
* e.g. extensionless IPFS gateway URLs, where without this hint a video
|
|
630
630
|
* would be misclassified as a static image and stamped a fixed duration.
|
|
631
|
+
* @param {boolean} [options.asWebPage] - Time the item as a web page whatever
|
|
632
|
+
* the URL's extension says (`display --web`)
|
|
631
633
|
* @returns {Object} DP1 playlist item
|
|
632
634
|
*/
|
|
633
635
|
function buildUrlItem(url, duration, options = {}) {
|
|
@@ -667,13 +669,20 @@ function buildUrlItem(url, duration, options = {}) {
|
|
|
667
669
|
.license('open')
|
|
668
670
|
// offChainURI has no provenance.uri in the DP-1 schema — only type.
|
|
669
671
|
.provenance(new ProvenanceBuilder().type('offChainURI'));
|
|
670
|
-
// A URL handed to `
|
|
672
|
+
// A URL handed to `display` that isn't a recognized media file is treated as an
|
|
671
673
|
// interactive web page: the FF1 renders unknown URLs in a sandboxed iframe,
|
|
672
674
|
// so an explicit caller hint aside, "unknown" means web, not static image.
|
|
673
675
|
// This routes such items through the open-ended (no-duration) timing path
|
|
674
676
|
// instead of the static-image default.
|
|
675
677
|
const mimeHint = options.mimeType || (detectMimeType(sourceUrl) === '' ? 'text/html' : undefined);
|
|
676
|
-
|
|
678
|
+
// `display --web` says the URL is a page whatever its path looks like. Timing
|
|
679
|
+
// checks the URL extension before the MIME hint, so a page served at
|
|
680
|
+
// /page.mp4 would otherwise be timed as video (no duration, loop false);
|
|
681
|
+
// leaving the URL out of the timing hints makes the page timing win.
|
|
682
|
+
const timingHints = options.asWebPage
|
|
683
|
+
? { mimeType: 'text/html' }
|
|
684
|
+
: { sourceUrl, mimeType: mimeHint };
|
|
685
|
+
applyTimingToItemBuilder(itemBuilder, timingHints, duration, buildDefaultDisplay());
|
|
677
686
|
return itemBuilder.build();
|
|
678
687
|
}
|
|
679
688
|
module.exports = {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.castPlaylist = castPlaylist;
|
|
4
4
|
/**
|
|
5
|
-
* DP-1 signature role used when signing a playlist for
|
|
5
|
+
* DP-1 signature role used when signing a playlist for display.
|
|
6
6
|
*
|
|
7
7
|
* A cast never passes through a feed, so a document signed here carries no `feed` signature and its own
|
|
8
8
|
* entry is the only one a player could judge. DP-1 §7.1.1 rule 1 has players verify a `feed` or `curator`
|
|
@@ -26,15 +26,15 @@ const { signPlaylist } = require('./playlist-signer');
|
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
27
|
const { sendPlaylistToDevice } = require('./ff1-device');
|
|
28
28
|
/**
|
|
29
|
-
* Shared "(sign →) verify → send to device" sequence used by `
|
|
30
|
-
* `find --
|
|
29
|
+
* Shared "(sign →) verify → send to device" sequence used by `display` and
|
|
30
|
+
* `find --display`.
|
|
31
31
|
*
|
|
32
32
|
* This is the single source of truth for getting a playlist onto an FF1: the
|
|
33
33
|
* verification gate, the synthesized-media auto-sign (rebuilding the
|
|
34
34
|
* `signatures[]` envelope), and the device transport. Callers own their
|
|
35
35
|
* console output and map the returned `stage`/`error` to their own messages —
|
|
36
36
|
* keeping delivery behavior consistent without coupling the two commands'
|
|
37
|
-
* UX. Previously this logic was duplicated across `
|
|
37
|
+
* UX. Previously this logic was duplicated across `display`, `find`, and the
|
|
38
38
|
* (now-removed) chat send path, and drifted between them.
|
|
39
39
|
*/
|
|
40
40
|
async function castPlaylist(playlist, options = {}) {
|
|
@@ -50,7 +50,7 @@ async function castPlaylist(playlist, options = {}) {
|
|
|
50
50
|
signed: false,
|
|
51
51
|
verified: false,
|
|
52
52
|
stage: 'sign',
|
|
53
|
-
error: 'Cannot sign playlist for
|
|
53
|
+
error: 'Cannot sign playlist for display: no playlist signing key is configured',
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
try {
|
|
@@ -69,7 +69,7 @@ async function castPlaylist(playlist, options = {}) {
|
|
|
69
69
|
signed: false,
|
|
70
70
|
verified: false,
|
|
71
71
|
stage: 'sign',
|
|
72
|
-
error: `Failed to sign playlist for
|
|
72
|
+
error: `Failed to sign playlist for display: ${error.message}`,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
}
|