@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "CLI for building DP-1 playlists of digital art using AI (Claude, Grok, ChatGPT, Gemini)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,16 +68,19 @@ function inferToolCallFromContent(content, iterationCount) {
68
68
  typeof parsed === 'object' &&
69
69
  parsed.requirement &&
70
70
  typeof parsed.requirement === 'object' &&
71
- typeof parsed.duration === 'number') {
71
+ (parsed.duration === undefined || typeof parsed.duration === 'number')) {
72
+ // duration is optional: absent means auto timing (DP-1 §4.1), so the
73
+ // synthetic call must not fabricate one.
74
+ const syntheticArgs = { requirement: parsed.requirement };
75
+ if (typeof parsed.duration === 'number') {
76
+ syntheticArgs.duration = parsed.duration;
77
+ }
72
78
  return {
73
79
  id: `synthetic_query_requirement_${iterationCount}`,
74
80
  type: 'function',
75
81
  function: {
76
82
  name: 'query_requirement',
77
- arguments: JSON.stringify({
78
- requirement: parsed.requirement,
79
- duration: parsed.duration,
80
- }),
83
+ arguments: JSON.stringify(syntheticArgs),
81
84
  },
82
85
  };
83
86
  }
@@ -165,10 +168,10 @@ const functionSchemas = [
165
168
  },
166
169
  duration: {
167
170
  type: 'number',
168
- description: 'Display duration per item in seconds',
171
+ description: 'Display duration per item in seconds. Omit for auto timing: video/audio items then carry no duration and play their natural length (DP-1 §4.1); static items use the configured default.',
169
172
  },
170
173
  },
171
- required: ['requirement', 'duration'],
174
+ required: ['requirement'],
172
175
  },
173
176
  },
174
177
  },
@@ -207,10 +210,10 @@ const functionSchemas = [
207
210
  },
208
211
  duration: {
209
212
  type: 'number',
210
- description: 'Duration per item in seconds',
213
+ description: "Duration per item in seconds. Omit to keep each feed item's own timing.",
211
214
  },
212
215
  },
213
- required: ['playlistName', 'quantity', 'duration'],
216
+ required: ['playlistName', 'quantity'],
214
217
  },
215
218
  },
216
219
  },
@@ -474,6 +477,12 @@ function buildOrchestratorSystemPrompt(params) {
474
477
  }
475
478
  })
476
479
  .join('\n');
480
+ // Spell out the duration argument once: an explicit setting is passed
481
+ // through verbatim; otherwise the model must OMIT duration so item-build
482
+ // applies auto timing (video/audio natural length per DP-1 §4.1).
483
+ const durationArgText = typeof playlistSettings.durationPerItem === 'number'
484
+ ? `call query_requirement(requirement, duration=${playlistSettings.durationPerItem}).`
485
+ : 'call query_requirement(requirement) WITHOUT a duration argument (auto timing).';
477
486
  const hasDevice = playlistSettings.deviceName !== undefined;
478
487
  const sendStep = hasDevice
479
488
  ? `6) If verification passed → you MUST call send_to_device({ playlistId: <the_playlistId>, deviceName: "${playlistSettings.deviceName || 'first-device'}" }) before finishing.
@@ -488,7 +497,7 @@ REQUIREMENTS
488
497
  ${requirementsText}
489
498
 
490
499
  PLAYLIST SETTINGS
491
- - durationPerItem: ${playlistSettings.durationPerItem || 10}
500
+ - durationPerItem: ${typeof playlistSettings.durationPerItem === 'number' ? playlistSettings.durationPerItem : 'auto (omit duration: video/audio play natural length, static media uses configured default)'}
492
501
  - title: ${playlistSettings.title || 'auto'}
493
502
  - slug: ${playlistSettings.slug || 'auto'}
494
503
  - preserveOrder: ${playlistSettings.preserveOrder !== false ? 'true' : 'false'}
@@ -517,14 +526,14 @@ KEY RULES
517
526
 
518
527
  DECISION LOOP
519
528
  1) For each requirement in order:
520
- - build_playlist: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
529
+ - build_playlist: ${durationArgText}
521
530
  Returns array with minimal item data including id field. Collect the id values.
522
- - feral_file_artwork: call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
531
+ - feral_file_artwork: ${durationArgText}
523
532
  Returns array with minimal item data including id field. Collect the id values.
524
533
  - query_address:
525
534
  • if ownerAddress endsWith .eth/.tez → resolve_domains([domain]); if resolved → use returned address; if not → mark failed and continue.
526
- • if ownerAddress is 0x…/tz… → call query_requirement(requirement, duration=${playlistSettings.durationPerItem || 10}).
527
- - fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity, duration=${playlistSettings.durationPerItem || 10}).
535
+ • if ownerAddress is 0x…/tz… → ${durationArgText}
536
+ - fetch_feed: search_feed_playlist(name) → take bestMatch → fetch_feed_playlist_items(bestMatch, quantity${typeof playlistSettings.durationPerItem === 'number' ? `, duration=${playlistSettings.durationPerItem}` : ''}).
528
537
  - Collect item IDs across all steps in an array (let's call it collectedItemIds).
529
538
  2) If zero items → explain briefly and finish.
530
539
  3) If some requirements failed and interactive mode → ask user; otherwise proceed with available items.
@@ -47,6 +47,7 @@ const marketplace_url_1 = require("../utilities/marketplace-url");
47
47
  const ff_marketplace_1 = require("../utilities/ff-marketplace");
48
48
  const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
49
49
  const ab_marketplace_1 = require("../utilities/ab-marketplace");
50
+ const opensea_marketplace_1 = require("../utilities/opensea-marketplace");
50
51
  const fxhash_marketplace_1 = require("../utilities/fxhash-marketplace");
51
52
  const neort_marketplace_1 = require("../utilities/neort-marketplace");
52
53
  const verse_marketplace_1 = require("../utilities/verse-marketplace");
@@ -108,28 +109,45 @@ exports.findCommand = new commander_1.Command('find')
108
109
  }
109
110
  console.log();
110
111
  const userLimit = parseLimitOption(options.limit);
111
- const seriesTotal = target.kind === 'series' ? target.summary.tokenCount : 1;
112
112
  // Implicit cap: no --limit + series > 1024 → cap to DP-1 max with a
113
113
  // clear warning. Explicit `--limit > 1024` was already rejected by
114
114
  // parseLimitOption, so reaching here means either capped-by-default or
115
115
  // the user picked a value within spec.
116
116
  const limit = Math.min(userLimit, DP1_MAX_ITEMS);
117
- if (userLimit === Number.POSITIVE_INFINITY && seriesTotal > DP1_MAX_ITEMS) {
118
- console.log(chalk_1.default.yellow(`Series has ${seriesTotal} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
117
+ // Raster's GraphQL API exposes no series token counts, so enumerate
118
+ // tokens first (up to the effective limit) and confirm with the real
119
+ // number afterward. `hasMore` means the series continues past `limit`.
120
+ let tokens;
121
+ let seriesHasMore = false;
122
+ if (target.kind === 'series') {
123
+ const fetched = await fetchTokens(target.summary, limit);
124
+ tokens = fetched.tokens;
125
+ seriesHasMore = fetched.hasMore;
126
+ }
127
+ else {
128
+ tokens = [target.coords];
129
+ }
130
+ if (tokens.length === 0) {
131
+ console.error(chalk_1.default.red('Series has no tokens on supported chains (Ethereum + Tezos mainnet).'));
132
+ process.exit(1);
133
+ }
134
+ if (userLimit === Number.POSITIVE_INFINITY && seriesHasMore) {
135
+ console.log(chalk_1.default.yellow(`Series has more than ${DP1_MAX_ITEMS} tokens; DP-1 caps playlists at ${DP1_MAX_ITEMS}. ` +
119
136
  `Building with the first ${DP1_MAX_ITEMS} — pass \`--limit N\` (≤ ${DP1_MAX_ITEMS}) for fewer.`));
120
137
  console.log();
121
138
  }
122
- const willInclude = Math.min(seriesTotal, limit);
123
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(willInclude, seriesTotal));
139
+ const shouldBuild = !!options.yes ||
140
+ !!options.output ||
141
+ (await confirmMakePlaylist(tokens.length, seriesHasMore));
124
142
  if (!shouldBuild) {
125
143
  console.log(chalk_1.default.dim('Cancelled.'));
126
144
  return;
127
145
  }
128
- const tokens = target.kind === 'series' ? await fetchTokens(target.summary, limit) : [target.coords];
129
146
  console.log(chalk_1.default.dim(`Indexing ${tokens.length} token${tokens.length === 1 ? '' : 's'} via FF indexer...`));
130
147
  // Second positional arg on getNFTTokenInfoBatch is `duration` (DP-1 item
131
148
  // display seconds), not concurrency — concurrency is hardcoded inside.
132
- // Omit it so the default (10s) applies; no misleading constant on this side.
149
+ // Omit it for auto timing: video/audio items carry no duration and play
150
+ // their natural length (DP-1 §4.1); static items get the config default.
133
151
  const items = await getNFTTokenInfoBatch(tokens.map((t) => ({
134
152
  chain: t.chain,
135
153
  contractAddress: t.contract,
@@ -183,6 +201,10 @@ async function resolveTarget(parsed, skipPrompt) {
183
201
  const coords = await (0, ab_marketplace_1.resolveArtBlocksCollection)(parsed.slug);
184
202
  return resolveCoords(coords);
185
203
  }
204
+ if (parsed.kind === 'os-collection') {
205
+ const coords = await (0, opensea_marketplace_1.resolveOpenSeaCollection)(parsed.slug);
206
+ return resolveCoords(coords);
207
+ }
186
208
  if (parsed.kind === 'fxhash-iteration') {
187
209
  const coords = await (0, fxhash_marketplace_1.resolveFxhashIteration)(parsed.slug);
188
210
  return resolveCoords(coords);
@@ -196,32 +218,33 @@ async function resolveTarget(parsed, skipPrompt) {
196
218
  return resolveCoords(coords);
197
219
  }
198
220
  if (parsed.kind === 'address') {
199
- const artistId = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
200
- if (artistId === null) {
221
+ const artist = await (0, raster_client_1.resolveAddressToArtist)(parsed.address);
222
+ if (artist === null) {
201
223
  throw new Error(`No artist found on Raster for address ${parsed.address}. ` +
202
224
  'Raster indexes artists by their on-chain addresses; this address may not yet be claimed.');
203
225
  }
204
- const catalog = await (0, raster_client_1.listArtistArtworks)(artistId);
226
+ const catalog = await (0, raster_client_1.listArtistArtworks)(artist.id);
205
227
  if (catalog.length === 0) {
206
228
  throw new Error('Artist has no artworks indexed on Raster.');
207
229
  }
208
230
  const picked = await pickFromCatalog(catalog, skipPrompt);
209
- const summary = await (0, raster_client_1.getArtworkSummary)(picked.artworkId);
210
- return { kind: 'series', summary };
231
+ return {
232
+ kind: 'series',
233
+ summary: { artworkId: picked.artworkId, title: picked.title, artists: [artist] },
234
+ };
211
235
  }
212
236
  // `unsupported` is handled upstream; this branch keeps the type narrowing honest.
213
237
  throw new Error(`Unsupported parse result: ${parsed.kind}`);
214
238
  }
215
239
  /**
216
- * Resolve on-chain coords to a target. If Raster doesn't index this token
217
- * (404), fall back to single-token mode so we still build something playable.
240
+ * Resolve on-chain coords to a target. If Raster doesn't index this token,
241
+ * fall back to single-token mode so we still build something playable.
218
242
  */
219
243
  async function resolveCoords(coords) {
220
- const lookup = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
221
- if (lookup === null) {
244
+ const summary = await (0, raster_client_1.resolveTokenToArtwork)(coords.chain, coords.contract, coords.tokenId);
245
+ if (summary === null) {
222
246
  return { kind: 'single', coords };
223
247
  }
224
- const summary = await (0, raster_client_1.getArtworkSummary)(lookup.artworkId);
225
248
  return { kind: 'series', summary };
226
249
  }
227
250
  /**
@@ -252,11 +275,17 @@ function parseLimitOption(limitStr) {
252
275
  }
253
276
  return n;
254
277
  }
278
+ /**
279
+ * Enumerate series tokens from Raster up to `limit`. `hasMore` reports
280
+ * whether the series continues past what was collected — the API exposes
281
+ * no total counts, so this is all the size information the flow gets.
282
+ */
255
283
  async function fetchTokens(summary, limit) {
256
284
  const collected = [];
257
285
  let cursor;
258
286
  let skipped = 0;
259
- while (collected.length < limit) {
287
+ let hasMore = false;
288
+ for (;;) {
260
289
  const page = await (0, raster_client_1.listArtworkTokens)(summary.artworkId, {
261
290
  cursor,
262
291
  pageSize: RASTER_PAGE_SIZE,
@@ -264,6 +293,7 @@ async function fetchTokens(summary, limit) {
264
293
  skipped += page.skippedUnsupported;
265
294
  for (const t of page.tokens) {
266
295
  if (collected.length >= limit) {
296
+ hasMore = true;
267
297
  break;
268
298
  }
269
299
  collected.push({
@@ -272,7 +302,11 @@ async function fetchTokens(summary, limit) {
272
302
  tokenId: t.tokenId,
273
303
  });
274
304
  }
275
- if (page.nextCursor === null) {
305
+ if (hasMore || page.nextCursor === null) {
306
+ break;
307
+ }
308
+ if (collected.length >= limit) {
309
+ hasMore = true;
276
310
  break;
277
311
  }
278
312
  cursor = page.nextCursor;
@@ -280,7 +314,7 @@ async function fetchTokens(summary, limit) {
280
314
  if (skipped > 0) {
281
315
  console.log(chalk_1.default.yellow(` Skipped ${skipped} token${skipped === 1 ? '' : 's'} on unsupported chains (FF indexer covers eth + tezos mainnet only).`));
282
316
  }
283
- return collected;
317
+ return { tokens: collected, hasMore };
284
318
  }
285
319
  async function pickFromCatalog(catalog, skipPrompt) {
286
320
  if (catalog.length === 1 || skipPrompt) {
@@ -288,8 +322,7 @@ async function pickFromCatalog(catalog, skipPrompt) {
288
322
  }
289
323
  console.log(chalk_1.default.cyan(`Found ${catalog.length} artworks for this artist:`));
290
324
  catalog.forEach((row, i) => {
291
- const count = `${row.tokenCount} token${row.tokenCount === 1 ? '' : 's'}`;
292
- console.log(chalk_1.default.dim(` ${i}: ${row.title} (${count})`));
325
+ console.log(chalk_1.default.dim(` ${i}: ${row.title}`));
293
326
  });
294
327
  console.log();
295
328
  const prompt = (0, prompt_1.createPrompt)();
@@ -304,9 +337,9 @@ async function pickFromCatalog(catalog, skipPrompt) {
304
337
  }
305
338
  return catalog[index];
306
339
  }
307
- async function confirmMakePlaylist(count, total) {
308
- const noun = total === 1 ? 'token' : 'tokens';
309
- const label = count < total ? `${count} of ${total} ${noun}` : `${count} ${noun}`;
340
+ async function confirmMakePlaylist(count, hasMore) {
341
+ const noun = count === 1 ? 'token' : 'tokens';
342
+ const label = hasMore ? `the first ${count} ${noun}` : `${count} ${noun}`;
310
343
  const prompt = (0, prompt_1.createPrompt)();
311
344
  const yes = await (0, prompt_1.promptYesNo)(prompt.ask, `Build playlist with ${label}?`, true);
312
345
  prompt.close();
@@ -482,12 +515,13 @@ async function runNeortFind(id, options) {
482
515
  console.log(chalk_1.default.cyan(`Neort — ${artistLabel} — ${art.title}`));
483
516
  console.log(chalk_1.default.dim(' (Off-chain platform; building a one-item playlist directly from Neort.)'));
484
517
  console.log();
485
- const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, 1));
518
+ const shouldBuild = !!options.yes || !!options.output || (await confirmMakePlaylist(1, false));
486
519
  if (!shouldBuild) {
487
520
  console.log(chalk_1.default.dim('Cancelled.'));
488
521
  return;
489
522
  }
490
- const item = buildUrlItem(art.assetUrl, 10, { title: art.title });
523
+ // No explicit duration: auto timing (video/audio play natural length).
524
+ const item = buildUrlItem(art.assetUrl, undefined, { title: art.title });
491
525
  const playlistTitle = `${artistLabel} — ${art.title}`;
492
526
  const playlist = await buildDP1Playlist({ items: [item], title: playlistTitle });
493
527
  const outputPath = options.output ?? `${playlist.slug || 'playlist'}.json`;
@@ -55,7 +55,9 @@ exports.playCommand = new commander_1.Command('play')
55
55
  .action(async (source, options) => {
56
56
  try {
57
57
  const config = (0, config_1.getConfig)();
58
- const resolved = await (0, playlist_source_1.resolvePlaySource)(source, config.defaultDuration || 10);
58
+ // No explicit duration: a direct video/audio URL plays its natural
59
+ // length (DP-1 §4.1); static media uses config.defaultDuration.
60
+ const resolved = await (0, playlist_source_1.resolvePlaySource)(source);
59
61
  const isPlaylistSource = resolved.kind === 'playlist';
60
62
  const sourceLabel = isPlaylistSource
61
63
  ? `${resolved.sourceType}: ${resolved.source}`
@@ -164,7 +164,7 @@ EXAMPLES (fetch_feed)
164
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
165
165
 
166
166
  PLAYLIST SETTINGS EXTRACTION
167
- - durationPerItem: parse phrases (e.g., "6 seconds each" → 6)
167
+ - durationPerItem: parse phrases (e.g., "6 seconds each" → 6); OMIT entirely when the user gives no per-item time — omitted means auto timing (video/audio play their natural length, static media uses the configured default). Never invent a number.
168
168
  - preserveOrder: default true; synonyms ("shuffle", "randomize", "mix", "mix them up", "scramble") → false
169
169
  - title/slug: optional; include only if provided by the user
170
170
  - deviceName: from phrases like "send to", "display on", "play on", "push to"${hasDevices ? '\n- available devices:\n' + deviceInfo.replace('\n\nAVAILABLE FF1 DEVICES:\n', '') : ''}
@@ -341,7 +341,7 @@ const intentParserFunctionSchemas = [
341
341
  },
342
342
  durationPerItem: {
343
343
  type: 'number',
344
- description: 'Duration per item in seconds (e.g., 5 for "5 seconds each")',
344
+ description: 'Duration per item in seconds (e.g., 5 for "5 seconds each"). Omit when the user gives no per-item time: omitted = auto timing (video/audio play natural length per DP-1 §4.1).',
345
345
  },
346
346
  totalDuration: {
347
347
  type: 'number',
@@ -15,10 +15,12 @@ const chalk_1 = __importDefault(require("chalk"));
15
15
  * @param {Object} params - Parsed parameters
16
16
  * @param {Array<Object>} params.requirements - Array of requirements
17
17
  * @param {Object} [params.playlistSettings] - Playlist settings
18
- * @param {Object} config - Application config
18
+ * @param {Object} _config - Application config (kept in the signature for
19
+ * call-site stability; no constraint reads it since durationPerItem
20
+ * pre-filling was removed in favor of DP-1 §4.1 auto timing)
19
21
  * @returns {Object} Validated parameters
20
22
  */
21
- function applyConstraints(params, config) {
23
+ function applyConstraints(params, _config) {
22
24
  // Validate requirements array
23
25
  if (!params.requirements || !Array.isArray(params.requirements)) {
24
26
  throw new Error('Requirements must be an array');
@@ -101,10 +103,11 @@ function applyConstraints(params, config) {
101
103
  if (!params.playlistSettings) {
102
104
  params.playlistSettings = {};
103
105
  }
104
- // Only set durationPerItem if not already specified
105
- if (params.playlistSettings.durationPerItem === undefined) {
106
- params.playlistSettings.durationPerItem = config.defaultDuration || 10;
107
- }
106
+ // durationPerItem stays undefined unless the user asked for a per-item
107
+ // time: absence means auto timing downstream (video/audio omit duration
108
+ // and play their natural length per DP-1 §4.1; static media falls back to
109
+ // config.defaultDuration at item-build time). Pre-filling a number here
110
+ // would force-cut every video item.
108
111
  // Only set preserveOrder if not already specified
109
112
  if (params.playlistSettings.preserveOrder === undefined) {
110
113
  params.playlistSettings.preserveOrder = true;
package/dist/src/main.js CHANGED
@@ -228,11 +228,14 @@ function validateRequirements(requirements) {
228
228
  * @returns {Object} Settings with defaults
229
229
  */
230
230
  function applyPlaylistDefaults(settings = {}) {
231
- const config = (0, config_1.getConfig)();
232
231
  return {
233
232
  title: settings.title || null,
234
233
  slug: settings.slug || null,
235
- durationPerItem: settings.durationPerItem || config.defaultDuration || 10,
234
+ // Leave durationPerItem undefined when not requested: absence means auto
235
+ // timing downstream (video/audio omit duration and play their natural
236
+ // length per DP-1 §4.1; static media falls back to config.defaultDuration
237
+ // at item-build time). Filling a number here would force-cut video items.
238
+ durationPerItem: settings.durationPerItem,
236
239
  preserveOrder: settings.preserveOrder !== false,
237
240
  deviceName: settings.deviceName,
238
241
  };
@@ -333,7 +333,7 @@ function shuffleArray(array) {
333
333
  *
334
334
  * @param {Object} playlist - DP1 playlist object
335
335
  * @param {number} quantity - Number of items to extract
336
- * @param {number} duration - Duration per item in seconds
336
+ * @param {number} [duration] - Explicit display seconds; omit to keep item timing
337
337
  * @param {boolean} shuffle - Whether to shuffle and randomly select items
338
338
  * @returns {Array<Object>} Array of DP1 playlist items
339
339
  */
@@ -348,12 +348,19 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
348
348
  }
349
349
  // Take requested quantity
350
350
  items = items.slice(0, quantity);
351
- // Override duration and ensure created field exists
352
- items = items.map((item) => ({
353
- ...item,
354
- duration: duration || item.duration,
355
- created: item.created || new Date().toISOString(), // Ensure created field exists
356
- }));
351
+ // Apply an explicit duration override when one was requested; otherwise
352
+ // keep each item's own timing (including intentionally absent duration,
353
+ // which DP-1 §4.1 uses for natural-length playback of video/audio).
354
+ items = items.map((item) => {
355
+ const next = {
356
+ ...item,
357
+ created: item.created || new Date().toISOString(), // Ensure created field exists
358
+ };
359
+ if (typeof duration === 'number') {
360
+ next.duration = duration;
361
+ }
362
+ return next;
363
+ });
357
364
  return items;
358
365
  }
359
366
  /**
@@ -361,10 +368,10 @@ function extractPlaylistItems(playlist, quantity, duration, shuffle = true) {
361
368
  *
362
369
  * @param {string} playlistName - Exact playlist name
363
370
  * @param {number} quantity - Number of items to fetch
364
- * @param {number} duration - Duration per item
371
+ * @param {number} [duration] - Explicit display seconds; omit to keep item timing
365
372
  * @returns {Promise<Object>} Result with items
366
373
  */
367
- async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration = 10) {
374
+ async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration) {
368
375
  const feedUrls = getFeedApiUrls();
369
376
  console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
370
377
  const result = await searchExactPlaylist(playlistName);
@@ -395,10 +402,10 @@ async function fetchFeedPlaylistDirect(playlistName, quantity = 5, duration = 10
395
402
  *
396
403
  * @param {string} playlistName - Playlist name (can be fuzzy)
397
404
  * @param {number} quantity - Number of items to fetch
398
- * @param {number} duration - Duration per item
405
+ * @param {number} [duration] - Explicit display seconds; omit to keep item timing
399
406
  * @returns {Promise<Object>} Result with best match and map for lookup
400
407
  */
401
- async function searchFeedPlaylists(playlistName, quantity = 5, duration = 10) {
408
+ async function searchFeedPlaylists(playlistName, quantity = 5, duration) {
402
409
  const feedUrls = getFeedApiUrls();
403
410
  console.log(chalk.cyan(`Searching for playlist "${playlistName}" in ${feedUrls.length} source(s)...`));
404
411
  const result = await findBestMatchingPlaylist(playlistName);
@@ -429,12 +436,12 @@ async function searchFeedPlaylists(playlistName, quantity = 5, duration = 10) {
429
436
  *
430
437
  * @param {string} playlistIdOrName - Playlist ID, slug, or exact name
431
438
  * @param {number} quantity - Number of items to fetch
432
- * @param {number} duration - Duration per item
439
+ * @param {number} [duration] - Explicit display seconds; omit to keep item timing
433
440
  * @param {Object} playlistMap - Optional map of names to IDs for lookup
434
441
  * @param {boolean} shuffle - Whether to shuffle and randomly select items
435
442
  * @returns {Promise<Object>} Result with items
436
443
  */
437
- async function fetchPlaylistItems(playlistIdOrName, quantity = 5, duration = 10, playlistMap = null, shuffle = true) {
444
+ async function fetchPlaylistItems(playlistIdOrName, quantity = 5, duration, playlistMap = null, shuffle = true) {
438
445
  try {
439
446
  let playlistId = playlistIdOrName;
440
447
  let feedUrl = null;
@@ -95,10 +95,10 @@ function isLikelyEvmAddress(address) {
95
95
  *
96
96
  * @param {string} contractAddress - Contract address
97
97
  * @param {number|string} [quantity] - Number of random tokens to select
98
- * @param {number} duration - Duration per item in seconds
98
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
99
99
  * @returns {Promise<Array<Object>>} Array of DP1 items
100
100
  */
101
- async function queryTokensByContractAddress(contractAddress, quantity, duration = 10) {
101
+ async function queryTokensByContractAddress(contractAddress, quantity, duration) {
102
102
  console.log(chalk.cyan(` Fetching ${quantity || 100} random token(s) from contract ${contractAddress.substring(0, 10)}...`));
103
103
  const limit = Math.min(quantity || 100, 100);
104
104
  const result = await nftIndexer.queryTokensByContract(contractAddress, limit);
@@ -141,10 +141,10 @@ function initializeUtilities(_config) {
141
141
  *
142
142
  * @param {string} ownerAddress - Owner wallet address
143
143
  * @param {number|string} [quantity] - Number of random tokens to select, or "all" to fetch all tokens
144
- * @param {number} duration - Duration per item in seconds
144
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
145
145
  * @returns {Promise<Array>} Array of DP1 playlist items
146
146
  */
147
- async function queryTokensByAddress(ownerAddress, quantity, duration = 10, options = {}) {
147
+ async function queryTokensByAddress(ownerAddress, quantity, duration, options = {}) {
148
148
  try {
149
149
  const { suppressNotFoundGuidance = false } = options;
150
150
  const shouldFetchAll = quantity === 'all' || quantity === undefined || quantity === null;
@@ -216,7 +216,7 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
216
216
  if (typeof quantity === 'number' && selectedTokens.length > quantity) {
217
217
  selectedTokens = shuffleArray([...selectedTokens]).slice(0, quantity);
218
218
  }
219
- const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration}`;
219
+ const tokenCountKey = `${ownerAddress}|${quantity ?? 'all'}|${duration ?? 'auto'}`;
220
220
  if (!printedTokenCountKeys.has(tokenCountKey)) {
221
221
  console.log(chalk.dim(`Got ${selectedTokens.length} token(s)`));
222
222
  printedTokenCountKeys.add(tokenCountKey);
@@ -243,10 +243,10 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
243
243
  * @param {string} [requirement.ownerAddress] - Owner address (for query_address)
244
244
  * @param {string} [requirement.playlistName] - Feed playlist name (for fetch_feed)
245
245
  * @param {number} [requirement.quantity] - Number of items
246
- * @param {number} duration - Duration per item in seconds
246
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
247
247
  * @returns {Promise<Array>} Array of DP1 playlist items
248
248
  */
249
- async function queryRequirement(requirement, duration = 10) {
249
+ async function queryRequirement(requirement, duration) {
250
250
  const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity, artworkId, } = requirement;
251
251
  // Handle query_address type
252
252
  if (type === 'query_address') {
@@ -458,7 +458,9 @@ async function buildPlaylistDirect(params, options = {}) {
458
458
  const { requirements, playlistSettings } = params;
459
459
  const { verbose = false, outputPath = 'playlist.json' } = options;
460
460
  const allItems = [];
461
- const duration = playlistSettings.durationPerItem || 10;
461
+ // Undefined means auto timing: video/audio items omit duration and play
462
+ // their natural length (DP-1 §4.1); static items get the configured default.
463
+ const duration = playlistSettings.durationPerItem;
462
464
  console.log(chalk.cyan('\nBuilding playlist from your requirements...\n'));
463
465
  // Process each requirement
464
466
  for (let i = 0; i < requirements.length; i++) {
@@ -246,7 +246,7 @@ function parseFxhash(url) {
246
246
  * OpenSea URL forms:
247
247
  * opensea.io/assets/{chain}/{contract}/{tokenId}
248
248
  * opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
249
- * opensea.io/collection/{slug} — series slug (not yet)
249
+ * opensea.io/collection/{slug} — collection slug (series)
250
250
  *
251
251
  * Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
252
252
  * fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
@@ -270,11 +270,15 @@ function parseOpenSea(url) {
270
270
  coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
271
271
  };
272
272
  }
273
+ const collectionMatch = /^\/collection\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
274
+ if (collectionMatch) {
275
+ return { kind: 'os-collection', slug: collectionMatch[1] };
276
+ }
273
277
  if (url.pathname.startsWith('/collection/')) {
274
278
  return {
275
279
  kind: 'unsupported',
276
- reason: 'OpenSea `/collection/{slug}` URLs are not yet supported in v1. Paste a specific token ' +
277
- 'URL (opensea.io/assets/ethereum/{contract}/{tokenId}) or use `ethereum:{contract}:{tokenId}`.',
280
+ reason: `OpenSea collection URL not recognized: ${url.pathname}. ` +
281
+ 'Expected opensea.io/collection/{slug} with no extra path segments.',
278
282
  };
279
283
  }
280
284
  return {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  const GRAPHQL_ENDPOINT = 'https://indexer.feralfile.com/graphql';
7
7
  const logger = require('../logger');
8
+ const { applyItemTiming } = require('./playlist-builder');
8
9
  // Polling configuration (in milliseconds)
9
10
  const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
10
11
  const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
@@ -354,10 +355,11 @@ function mapIndexerDataToStandardFormat(indexerData, chain) {
354
355
  * animation_url > image.url, ensuring the best quality media is used.
355
356
  *
356
357
  * @param {Object} tokenData - Token data in standard format
357
- * @param {number} duration - Display duration in seconds
358
+ * @param {number} [duration] - Explicit display seconds; omit for auto timing
359
+ * (video/audio play natural length per DP-1 §4.1)
358
360
  * @returns {Object} DP1 item with source URL from indexer
359
361
  */
360
- function convertToDP1Item(tokenData, duration = 10) {
362
+ function convertToDP1Item(tokenData, duration) {
361
363
  const { token } = tokenData;
362
364
  if (!token) {
363
365
  return {
@@ -435,7 +437,6 @@ function convertToDP1Item(tokenData, duration = 10) {
435
437
  const dp1Item = {
436
438
  id: itemId,
437
439
  source: sourceUrl,
438
- duration: duration,
439
440
  license: 'open',
440
441
  created: new Date().toISOString(),
441
442
  provenance: {
@@ -452,6 +453,7 @@ function convertToDP1Item(tokenData, duration = 10) {
452
453
  if (token.name) {
453
454
  dp1Item.title = token.name;
454
455
  }
456
+ applyItemTiming(dp1Item, { mimeType: token.image?.mimeType, sourceUrl }, duration);
455
457
  logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
456
458
  title: token.name,
457
459
  source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
@@ -464,11 +466,11 @@ function convertToDP1Item(tokenData, duration = 10) {
464
466
  /**
465
467
  * Get NFT token information from indexer and return as DP1 item (supports single or batch)
466
468
  * @param {Object|Array} params - Token parameters (single object or array)
467
- * @param {number} duration - Display duration in seconds (default: 10)
469
+ * @param {number} [params.duration] - Explicit display seconds; omit for auto timing
468
470
  * @returns {Promise<Object>} DP1 item(s)
469
471
  */
470
472
  async function getNFTTokenInfo(params) {
471
- const duration = params.duration || 10;
473
+ const duration = params.duration;
472
474
  // Handle array input for batch processing
473
475
  if (Array.isArray(params.tokens)) {
474
476
  return await getNFTTokenInfoBatch(params.tokens, duration);
@@ -495,7 +497,7 @@ async function getNFTTokenInfo(params) {
495
497
  * @param {Object} [options.mediaPoll] - Passed to pollForMediaAssets
496
498
  * @returns {Promise<Object>} DP1 item with success/error status
497
499
  */
498
- async function getNFTTokenInfoSingle(params, duration = 10, options = {}) {
500
+ async function getNFTTokenInfoSingle(params, duration, options = {}) {
499
501
  let chain = params.chain;
500
502
  const { contractAddress, tokenId } = params;
501
503
  // DEFENSIVE: Auto-detect and correct chain based on contract address format
@@ -596,7 +598,7 @@ async function getNFTTokenInfoSingle(params, duration = 10, options = {}) {
596
598
  * @param {number} duration - Display duration in seconds
597
599
  * @returns {Promise<Array>} Array of DP1 items
598
600
  */
599
- async function getNFTTokenInfoBatch(tokens, duration = 10) {
601
+ async function getNFTTokenInfoBatch(tokens, duration) {
600
602
  logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
601
603
  logger.debug('[NFT Indexer] Batch tokens:', tokens);
602
604
  const results = [];