@feralfile/cli 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/ai-orchestrator/index.js +23 -14
- package/dist/src/commands/find.js +61 -27
- package/dist/src/commands/play.js +3 -1
- package/dist/src/intent-parser/index.js +2 -2
- package/dist/src/intent-parser/utils.js +9 -6
- package/dist/src/main.js +5 -2
- package/dist/src/utilities/feed-fetcher.js +20 -13
- package/dist/src/utilities/index.js +10 -8
- package/dist/src/utilities/marketplace-url.js +7 -3
- package/dist/src/utilities/nft-indexer.js +9 -7
- package/dist/src/utilities/opensea-marketplace.js +123 -0
- package/dist/src/utilities/playlist-builder.js +92 -19
- package/dist/src/utilities/playlist-source.js +3 -1
- package/dist/src/utilities/raster-client.js +126 -127
- package/docs/CONFIGURATION.md +15 -1
- package/docs/FUNCTION_CALLING.md +3 -1
- package/docs/README.md +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenSea marketplace resolver.
|
|
4
|
+
*
|
|
5
|
+
* OpenSea collection URLs (`opensea.io/collection/{slug}`) point to a series,
|
|
6
|
+
* not a token. OpenSea's REST API requires an API key, so this resolver reads
|
|
7
|
+
* the public collection page instead: the server-rendered HTML embeds the
|
|
8
|
+
* first page of collection items as relay-style JSON where each item carries
|
|
9
|
+
* `"chain":{"identifier":...},"contractAddress":"0x...","tokenId":"..."`
|
|
10
|
+
* adjacently. One extracted (contract, tokenId) pair is enough — the standard
|
|
11
|
+
* Raster reverse-lookup then enumerates the rest of the series like any other
|
|
12
|
+
* marketplace.
|
|
13
|
+
*
|
|
14
|
+
* Extraction invariants (what must not change):
|
|
15
|
+
* - Match only the adjacent `contractAddress` + `tokenId` pattern. The page
|
|
16
|
+
* also embeds payment-token contracts (WETH etc.) in other JSON shapes that
|
|
17
|
+
* never carry an adjacent tokenId, so adjacency is the noise filter.
|
|
18
|
+
* - The most frequent contract among matches wins (collection items dominate
|
|
19
|
+
* any stray adjacency), and its lowest tokenId is the deterministic seed.
|
|
20
|
+
* - tokenIds are compared as BigInt: ERC-721 ids can exceed Number range.
|
|
21
|
+
*
|
|
22
|
+
* Trade-off: scraping page HTML is less stable than a documented API, but the
|
|
23
|
+
* alternative (requiring every user to provision an OpenSea API key) defeats
|
|
24
|
+
* the paste-a-URL UX. Errors below name the fallback (`/item/` token URL) so
|
|
25
|
+
* a layout change degrades to a one-step workaround, not a dead end.
|
|
26
|
+
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
+
var ownKeys = function(o) {
|
|
45
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
+
var ar = [];
|
|
47
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
+
return ar;
|
|
49
|
+
};
|
|
50
|
+
return ownKeys(o);
|
|
51
|
+
};
|
|
52
|
+
return function (mod) {
|
|
53
|
+
if (mod && mod.__esModule) return mod;
|
|
54
|
+
var result = {};
|
|
55
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
+
__setModuleDefault(result, mod);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
})();
|
|
60
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
|
+
exports.resolveOpenSeaCollection = resolveOpenSeaCollection;
|
|
62
|
+
const logger = __importStar(require("../logger"));
|
|
63
|
+
const user_agent_1 = require("./user-agent");
|
|
64
|
+
/**
|
|
65
|
+
* One embedded collection item: chain identifier, contract, tokenId. The
|
|
66
|
+
* `[^{}]*` keeps the chain object match from swallowing unrelated JSON when
|
|
67
|
+
* OpenSea reorders fields inside `chain`.
|
|
68
|
+
*/
|
|
69
|
+
const ITEM_PATTERN = /"chain":\{"identifier":"([a-z0-9_-]+)"[^{}]*\},"contractAddress":"(0x[a-fA-F0-9]{40})","tokenId":"(\d+)"/g;
|
|
70
|
+
const FALLBACK_HINT = 'Paste a specific token URL (opensea.io/item/ethereum/{contract}/{tokenId}) ' +
|
|
71
|
+
'or use `ethereum:{contract}:{tokenId}`.';
|
|
72
|
+
/**
|
|
73
|
+
* Resolve an OpenSea collection slug to its contract address + the lowest
|
|
74
|
+
* tokenId visible on the collection page (a representative seed for the
|
|
75
|
+
* Raster series lookup).
|
|
76
|
+
*
|
|
77
|
+
* @throws When the page cannot be fetched, embeds no recognizable items
|
|
78
|
+
* (layout change or empty collection), or the collection is not on
|
|
79
|
+
* Ethereum mainnet (outside FF indexer coverage).
|
|
80
|
+
*/
|
|
81
|
+
async function resolveOpenSeaCollection(slug) {
|
|
82
|
+
logger.debug(`[OpenSea] Resolving collection slug "${slug}"`);
|
|
83
|
+
const response = await fetch(`https://opensea.io/collection/${encodeURIComponent(slug)}`, {
|
|
84
|
+
headers: { 'User-Agent': user_agent_1.USER_AGENT, Accept: 'text/html' },
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(`OpenSea collection page returned ${response.status} ${response.statusText}. ` + FALLBACK_HINT);
|
|
88
|
+
}
|
|
89
|
+
const html = await response.text();
|
|
90
|
+
// Tally (contract → tokenIds) per chain so the dominant-contract rule and
|
|
91
|
+
// the ethereum-only guard can both give precise errors.
|
|
92
|
+
const byContract = new Map();
|
|
93
|
+
for (const match of html.matchAll(ITEM_PATTERN)) {
|
|
94
|
+
const [, chain, contract, tokenId] = match;
|
|
95
|
+
const key = contract.toLowerCase();
|
|
96
|
+
const entry = byContract.get(key) ?? { chain, tokenIds: [] };
|
|
97
|
+
entry.tokenIds.push(BigInt(tokenId));
|
|
98
|
+
byContract.set(key, entry);
|
|
99
|
+
}
|
|
100
|
+
if (byContract.size === 0) {
|
|
101
|
+
throw new Error(`OpenSea: no items found on the collection page for "${slug}". ` +
|
|
102
|
+
'The collection may be empty, or the page layout changed. ' +
|
|
103
|
+
FALLBACK_HINT);
|
|
104
|
+
}
|
|
105
|
+
let best = null;
|
|
106
|
+
for (const [contract, entry] of byContract) {
|
|
107
|
+
if (!best || entry.tokenIds.length > best.tokenIds.length) {
|
|
108
|
+
best = { contract, ...entry };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// byContract.size > 0 guarantees best is set; the check keeps TS honest.
|
|
112
|
+
if (!best) {
|
|
113
|
+
throw new Error(`OpenSea: no items found for "${slug}". ${FALLBACK_HINT}`);
|
|
114
|
+
}
|
|
115
|
+
if (best.chain !== 'ethereum') {
|
|
116
|
+
throw new Error(`OpenSea collection "${slug}" is on ${best.chain} — ff-cli covers Ethereum and ` +
|
|
117
|
+
'Tezos mainnet only. Paste an Ethereum-chain source instead.');
|
|
118
|
+
}
|
|
119
|
+
const seed = best.tokenIds.reduce((min, id) => (id < min ? id : min));
|
|
120
|
+
logger.debug(`[OpenSea] Resolved "${slug}" → ${best.contract} (seed tokenId ${seed}, ` +
|
|
121
|
+
`${best.tokenIds.length} items sampled)`);
|
|
122
|
+
return { chain: 'ethereum', contract: best.contract, tokenId: seed.toString() };
|
|
123
|
+
}
|
|
@@ -2,8 +2,72 @@
|
|
|
2
2
|
* Playlist Builder Utilities
|
|
3
3
|
* Core functions for building and validating DP1 playlists
|
|
4
4
|
*/
|
|
5
|
-
const { getPlaylistConfig } = require('../config');
|
|
5
|
+
const { getPlaylistConfig, getConfig } = require('../config');
|
|
6
6
|
const { signPlaylist } = require('./playlist-signer');
|
|
7
|
+
/**
|
|
8
|
+
* isTimeBasedMedia reports whether an item's media has an intrinsic runtime
|
|
9
|
+
* (video or audio) per DP-1 §4.1 "time-based sources".
|
|
10
|
+
*
|
|
11
|
+
* Two signals are consulted because neither is reliable alone: indexer
|
|
12
|
+
* `display.mime_type` is authoritative when present but is defaulted to
|
|
13
|
+
* 'image/png' upstream when missing, and URL extensions only cover direct
|
|
14
|
+
* media links. Either signal saying video/audio wins.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} [mimeType] - MIME type reported by the indexer, if any
|
|
17
|
+
* @param {string} [sourceUrl] - Item source URL, used as an extension fallback
|
|
18
|
+
* @returns {boolean} True when the media is video or audio
|
|
19
|
+
*/
|
|
20
|
+
function isTimeBasedMedia(mimeType, sourceUrl) {
|
|
21
|
+
const candidates = [String(mimeType || '').toLowerCase(), detectMimeType(sourceUrl)];
|
|
22
|
+
return candidates.some((mime) => mime.startsWith('video/') || mime.startsWith('audio/'));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* applyItemTiming sets a DP1 item's playback timing per DP-1 §4.1.
|
|
26
|
+
*
|
|
27
|
+
* Invariant this function owns: an explicit numeric `duration` always wins
|
|
28
|
+
* and is stamped as-is. When `duration` is undefined/null ("auto"), a
|
|
29
|
+
* time-based source (video/audio) gets NO duration and `display.loop: false`,
|
|
30
|
+
* so a conformant player MUST advance at end-of-stream — the media plays its
|
|
31
|
+
* natural length. Static and code-based sources fall back to the configured
|
|
32
|
+
* `defaultDuration` because they have no intrinsic runtime to play out.
|
|
33
|
+
*
|
|
34
|
+
* Do not re-introduce an unconditional duration here: stamping a default on
|
|
35
|
+
* video items silently truncates or pads them (the bug this fixed).
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} item - DP1 playlist item (mutated in place)
|
|
38
|
+
* @param {Object} mediaHints - Media signals for the time-based check
|
|
39
|
+
* @param {string} [mediaHints.mimeType] - Indexer-reported MIME type
|
|
40
|
+
* @param {string} [mediaHints.sourceUrl] - Item source URL
|
|
41
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto
|
|
42
|
+
* @returns {Object} The same item, for chaining
|
|
43
|
+
*/
|
|
44
|
+
function applyItemTiming(item, mediaHints = {}, duration) {
|
|
45
|
+
if (typeof duration === 'number') {
|
|
46
|
+
item.duration = duration;
|
|
47
|
+
return item;
|
|
48
|
+
}
|
|
49
|
+
if (isTimeBasedMedia(mediaHints.mimeType, mediaHints.sourceUrl)) {
|
|
50
|
+
item.display = { ...(item.display || {}), loop: false };
|
|
51
|
+
return item;
|
|
52
|
+
}
|
|
53
|
+
item.duration = getDefaultStaticDuration();
|
|
54
|
+
return item;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* getDefaultStaticDuration returns the configured per-item display seconds
|
|
58
|
+
* for media without an intrinsic runtime. Falls back to 10 when config is
|
|
59
|
+
* unavailable (e.g. unit tests without a config file).
|
|
60
|
+
*
|
|
61
|
+
* @returns {number} Display duration in seconds
|
|
62
|
+
*/
|
|
63
|
+
function getDefaultStaticDuration() {
|
|
64
|
+
try {
|
|
65
|
+
return getConfig().defaultDuration || 10;
|
|
66
|
+
}
|
|
67
|
+
catch (_error) {
|
|
68
|
+
return 10;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
7
71
|
/**
|
|
8
72
|
* Convert a string to a URL-friendly slug
|
|
9
73
|
*
|
|
@@ -31,14 +95,16 @@ function slugify(value) {
|
|
|
31
95
|
* Convert single NFT token info to DP1 playlist item
|
|
32
96
|
*
|
|
33
97
|
* @param {Object} tokenInfo - Token information from NFT indexer
|
|
34
|
-
* @param {number} duration -
|
|
98
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
99
|
+
* (video/audio play natural length per DP-1 §4.1, static media uses the
|
|
100
|
+
* configured default — see applyItemTiming)
|
|
35
101
|
* @returns {Object} DP1 playlist item
|
|
36
102
|
* @throws {Error} When token data is missing or source is a data URI
|
|
37
103
|
* @example
|
|
38
104
|
* const item = convertTokenToDP1ItemSingle(tokenInfo, 10);
|
|
39
105
|
* // Returns: { title, source, duration, license, provenance, ... }
|
|
40
106
|
*/
|
|
41
|
-
function convertTokenToDP1ItemSingle(tokenInfo, duration
|
|
107
|
+
function convertTokenToDP1ItemSingle(tokenInfo, duration) {
|
|
42
108
|
const { token } = tokenInfo;
|
|
43
109
|
if (!token) {
|
|
44
110
|
throw new Error('Invalid token info: missing token data');
|
|
@@ -75,7 +141,6 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
|
|
|
75
141
|
id: itemId,
|
|
76
142
|
title: token.name || `Token #${token.tokenId}`,
|
|
77
143
|
source: sourceUrl,
|
|
78
|
-
duration: duration,
|
|
79
144
|
license: 'token', // NFTs are token-gated by default
|
|
80
145
|
created: new Date().toISOString(),
|
|
81
146
|
provenance: {
|
|
@@ -102,7 +167,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
|
|
|
102
167
|
if ((token.animation_url || token.animationUrl) && (token.image?.url || token.image)) {
|
|
103
168
|
dp1Item.ref = token.image?.url || token.image;
|
|
104
169
|
}
|
|
105
|
-
return dp1Item;
|
|
170
|
+
return applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
|
|
106
171
|
}
|
|
107
172
|
/**
|
|
108
173
|
* Convert NFT token info(s) to DP1 playlist item(s)
|
|
@@ -111,7 +176,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
|
|
|
111
176
|
* For collections, returns a map of token key to DP1 item.
|
|
112
177
|
*
|
|
113
178
|
* @param {Object|Array} tokenInfo - Token information (single object or map of tokens)
|
|
114
|
-
* @param {number} duration -
|
|
179
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
115
180
|
* @returns {Object} Map of token key to DP1 playlist item, or single item
|
|
116
181
|
* @example
|
|
117
182
|
* // Single token
|
|
@@ -120,7 +185,7 @@ function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
|
|
|
120
185
|
* // Multiple tokens
|
|
121
186
|
* const items = convertTokenToDP1Item({ token1: info1, token2: info2 }, 10);
|
|
122
187
|
*/
|
|
123
|
-
function convertTokenToDP1Item(tokenInfo, duration
|
|
188
|
+
function convertTokenToDP1Item(tokenInfo, duration) {
|
|
124
189
|
// Handle array or map of tokens
|
|
125
190
|
if (typeof tokenInfo === 'object' && !tokenInfo.token) {
|
|
126
191
|
const results = {};
|
|
@@ -163,12 +228,12 @@ function convertTokenToDP1Item(tokenInfo, duration = 10) {
|
|
|
163
228
|
* Excludes items with data URIs in their source field.
|
|
164
229
|
*
|
|
165
230
|
* @param {Array} tokensInfo - Array of token information
|
|
166
|
-
* @param {number} duration -
|
|
231
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
167
232
|
* @returns {Array} Array of DP1 playlist items
|
|
168
233
|
* @example
|
|
169
234
|
* const items = convertTokensToDP1Items(tokensInfoArray, 10);
|
|
170
235
|
*/
|
|
171
|
-
function convertTokensToDP1Items(tokensInfo, duration
|
|
236
|
+
function convertTokensToDP1Items(tokensInfo, duration) {
|
|
172
237
|
return tokensInfo
|
|
173
238
|
.filter((info) => info.success && info.token)
|
|
174
239
|
.map((info) => {
|
|
@@ -313,7 +378,10 @@ async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
|
313
378
|
margin: 0,
|
|
314
379
|
},
|
|
315
380
|
license: 'token',
|
|
316
|
-
duration:
|
|
381
|
+
// No defaults.duration: items that should be timed carry an explicit
|
|
382
|
+
// duration already, and a playlist-level default would make conformant
|
|
383
|
+
// players time-cut items that intentionally omit duration to play
|
|
384
|
+
// their natural length (DP-1 §4.1 end-of-stream advance).
|
|
317
385
|
},
|
|
318
386
|
};
|
|
319
387
|
// Always include slug (auto-generated when missing)
|
|
@@ -384,11 +452,14 @@ function validateDP1Playlist(playlist) {
|
|
|
384
452
|
else if (typeof item.source !== 'string') {
|
|
385
453
|
errors.push(`Item ${index}: Field "source" must be a string (URI)`);
|
|
386
454
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
455
|
+
// Duration is OPTIONAL per the DP-1 v1.1.0 schema (PlaylistItem requires
|
|
456
|
+
// only `source`; duration has `minimum: 0`). Absent duration is meaningful:
|
|
457
|
+
// time-based sources advance at end-of-stream (§4.1). Do not tighten this
|
|
458
|
+
// back to required — it would reject spec-valid playlists.
|
|
459
|
+
if (item.duration !== undefined && item.duration !== null) {
|
|
460
|
+
if (typeof item.duration !== 'number' || item.duration < 0) {
|
|
461
|
+
errors.push(`Item ${index}: Field "duration" must be a number >= 0`);
|
|
462
|
+
}
|
|
392
463
|
}
|
|
393
464
|
if (!item.license) {
|
|
394
465
|
errors.push(`Item ${index}: Missing required field "license"`);
|
|
@@ -461,12 +532,13 @@ function detectMimeType(url) {
|
|
|
461
532
|
* Build a single DP1 playlist item from a URL
|
|
462
533
|
*
|
|
463
534
|
* @param {string} url - Media URL
|
|
464
|
-
* @param {number} duration -
|
|
535
|
+
* @param {number} [duration] - Explicit display seconds; omit for auto timing
|
|
536
|
+
* (video/audio URLs play their natural length per DP-1 §4.1)
|
|
465
537
|
* @param {Object} [options] - Optional configuration
|
|
466
538
|
* @param {string} [options.title] - Optional item title override
|
|
467
539
|
* @returns {Object} DP1 playlist item
|
|
468
540
|
*/
|
|
469
|
-
function buildUrlItem(url, duration
|
|
541
|
+
function buildUrlItem(url, duration, options = {}) {
|
|
470
542
|
const sourceUrl = String(url || '').trim();
|
|
471
543
|
if (!sourceUrl) {
|
|
472
544
|
throw new Error('URL is required to build a playlist item');
|
|
@@ -496,7 +568,6 @@ function buildUrlItem(url, duration = 10, options = {}) {
|
|
|
496
568
|
id: itemId,
|
|
497
569
|
title,
|
|
498
570
|
source: sourceUrl,
|
|
499
|
-
duration: duration,
|
|
500
571
|
license: 'open',
|
|
501
572
|
created: new Date().toISOString(),
|
|
502
573
|
provenance: {
|
|
@@ -509,7 +580,7 @@ function buildUrlItem(url, duration = 10, options = {}) {
|
|
|
509
580
|
margin: 0,
|
|
510
581
|
},
|
|
511
582
|
};
|
|
512
|
-
return item;
|
|
583
|
+
return applyItemTiming(item, { sourceUrl }, duration);
|
|
513
584
|
}
|
|
514
585
|
module.exports = {
|
|
515
586
|
convertTokenToDP1Item,
|
|
@@ -519,5 +590,7 @@ module.exports = {
|
|
|
519
590
|
buildDP1Playlist,
|
|
520
591
|
validateDP1Playlist,
|
|
521
592
|
detectMimeType,
|
|
593
|
+
isTimeBasedMedia,
|
|
594
|
+
applyItemTiming,
|
|
522
595
|
buildUrlItem,
|
|
523
596
|
};
|
|
@@ -93,7 +93,9 @@ async function loadPlaylistSource(source) {
|
|
|
93
93
|
* `loadedAsPlaylist` boolean dance.
|
|
94
94
|
*
|
|
95
95
|
* @param source - User-supplied path or URL
|
|
96
|
-
* @param defaultDuration -
|
|
96
|
+
* @param defaultDuration - Explicit duration (seconds) for the synthesized
|
|
97
|
+
* media item; omit for auto timing (video/audio URLs play their natural
|
|
98
|
+
* length per DP-1 §4.1, static media uses the configured default)
|
|
97
99
|
* @returns Resolved playlist + metadata describing how it was loaded
|
|
98
100
|
* @throws Error When `source` is empty, or is a non-URL file path that cannot be loaded
|
|
99
101
|
*/
|