@feralfile/cli 2.10.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -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 playable asset URL plus title/artist metadata.
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 playable asset
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 playable asset (resourceFileName and thumbnail are both empty).`);
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
- * This module provides functions to interact with NFT indexing services
5
- * to retrieve comprehensive token information.
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 { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
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 `play` that isn't a recognized media file is treated as an
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
- applyTimingToItemBuilder(itemBuilder, { sourceUrl, mimeType: mimeHint }, duration, buildDefaultDisplay());
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 playback.
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 `play` and
30
- * `find --play`.
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 `play`, `find`, and the
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 playback: no playlist signing key is configured',
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 playback: ${error.message}`,
72
+ error: `Failed to sign playlist for display: ${error.message}`,
73
73
  };
74
74
  }
75
75
  }