ff1-cli 1.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/LICENSE +21 -0
- package/README.md +65 -0
- package/config.json.example +78 -0
- package/dist/index.js +627 -0
- package/dist/src/ai-orchestrator/index.js +870 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/config.js +352 -0
- package/dist/src/intent-parser/index.js +1342 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +72 -0
- package/dist/src/main.js +393 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/feed-fetcher.js +387 -0
- package/dist/src/utilities/ff1-device.js +176 -0
- package/dist/src/utilities/functions.js +325 -0
- package/dist/src/utilities/index.js +372 -0
- package/dist/src/utilities/nft-indexer.js +1013 -0
- package/dist/src/utilities/playlist-builder.js +522 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +241 -0
- package/dist/src/utilities/playlist-signer.js +171 -0
- package/dist/src/utilities/playlist-verifier.js +156 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +178 -0
- package/docs/EXAMPLES.md +331 -0
- package/docs/FUNCTION_CALLING.md +92 -0
- package/docs/README.md +267 -0
- package/docs/RELEASING.md +22 -0
- package/package.json +75 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playlist Builder Utilities
|
|
3
|
+
* Core functions for building and validating DP1 playlists
|
|
4
|
+
*/
|
|
5
|
+
const { getPlaylistConfig } = require('../config');
|
|
6
|
+
const { signPlaylist } = require('./playlist-signer');
|
|
7
|
+
/**
|
|
8
|
+
* Convert a string to a URL-friendly slug
|
|
9
|
+
*
|
|
10
|
+
* Lowercases, trims, replaces whitespace with dashes, and strips invalid chars.
|
|
11
|
+
* Falls back to a short id when input is empty.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} value - Source string to slugify
|
|
14
|
+
* @returns {string} Slugified string
|
|
15
|
+
*/
|
|
16
|
+
function slugify(value) {
|
|
17
|
+
const base = (value || '').toString().trim().toLowerCase();
|
|
18
|
+
if (!base) {
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
return `playlist-${crypto.randomUUID().split('-')[0]}`;
|
|
21
|
+
}
|
|
22
|
+
return base
|
|
23
|
+
.normalize('NFD')
|
|
24
|
+
.replace(/[\u0300-\u036f]/g, '') // strip diacritics
|
|
25
|
+
.replace(/[^a-z0-9\s-]/g, '') // remove invalid chars
|
|
26
|
+
.replace(/\s+/g, '-') // spaces -> dashes
|
|
27
|
+
.replace(/-+/g, '-') // collapse dashes
|
|
28
|
+
.replace(/^-|-$/g, ''); // trim dashes
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convert single NFT token info to DP1 playlist item
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} tokenInfo - Token information from NFT indexer
|
|
34
|
+
* @param {number} duration - Display duration in seconds
|
|
35
|
+
* @returns {Object} DP1 playlist item
|
|
36
|
+
* @throws {Error} When token data is missing or source is a data URI
|
|
37
|
+
* @example
|
|
38
|
+
* const item = convertTokenToDP1ItemSingle(tokenInfo, 10);
|
|
39
|
+
* // Returns: { title, source, duration, license, provenance, ... }
|
|
40
|
+
*/
|
|
41
|
+
function convertTokenToDP1ItemSingle(tokenInfo, duration = 10) {
|
|
42
|
+
const { token } = tokenInfo;
|
|
43
|
+
if (!token) {
|
|
44
|
+
throw new Error('Invalid token info: missing token data');
|
|
45
|
+
}
|
|
46
|
+
// Determine the content source URL (prefer animation_url for dynamic content)
|
|
47
|
+
const sourceUrl = token.animation_url || token.animationUrl || token.image?.url || token.image || '';
|
|
48
|
+
// Skip items with data URIs (base64-encoded content)
|
|
49
|
+
if (sourceUrl.startsWith('data:')) {
|
|
50
|
+
throw new Error('Item source is a data URI - excluded from playlist');
|
|
51
|
+
}
|
|
52
|
+
// Map chain to DP1 format
|
|
53
|
+
const chainMap = {
|
|
54
|
+
ethereum: 'evm',
|
|
55
|
+
polygon: 'evm',
|
|
56
|
+
arbitrum: 'evm',
|
|
57
|
+
optimism: 'evm',
|
|
58
|
+
base: 'evm',
|
|
59
|
+
tezos: 'tezos',
|
|
60
|
+
bitmark: 'bitmark',
|
|
61
|
+
};
|
|
62
|
+
const chain = chainMap[token.chain?.toLowerCase()] || 'other';
|
|
63
|
+
// Map token standard to DP1 format
|
|
64
|
+
const standardMap = {
|
|
65
|
+
erc721: 'erc721',
|
|
66
|
+
erc1155: 'erc1155',
|
|
67
|
+
fa2: 'fa2',
|
|
68
|
+
};
|
|
69
|
+
const standard = standardMap[token.standard?.toLowerCase()] || 'other';
|
|
70
|
+
// Generate unique ID for the item (UUID v4 format)
|
|
71
|
+
const crypto = require('crypto');
|
|
72
|
+
const itemId = crypto.randomUUID();
|
|
73
|
+
// Build DP1 item structure according to OpenAPI spec
|
|
74
|
+
const dp1Item = {
|
|
75
|
+
id: itemId,
|
|
76
|
+
title: token.name || `Token #${token.tokenId}`,
|
|
77
|
+
source: sourceUrl,
|
|
78
|
+
duration: duration,
|
|
79
|
+
license: 'token', // NFTs are token-gated by default
|
|
80
|
+
created: new Date().toISOString(),
|
|
81
|
+
provenance: {
|
|
82
|
+
type: 'onChain',
|
|
83
|
+
contract: {
|
|
84
|
+
chain: chain,
|
|
85
|
+
standard: standard,
|
|
86
|
+
address: token.contractAddress,
|
|
87
|
+
tokenId: String(token.tokenId),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
// Add display preferences if available
|
|
92
|
+
dp1Item.display = {
|
|
93
|
+
scaling: 'fit',
|
|
94
|
+
background: '#111',
|
|
95
|
+
margin: 0,
|
|
96
|
+
};
|
|
97
|
+
// Add metadata URI if available
|
|
98
|
+
if (token.metadata?.uri || token.tokenURI) {
|
|
99
|
+
dp1Item.provenance.contract.uri = token.metadata?.uri || token.tokenURI;
|
|
100
|
+
}
|
|
101
|
+
// Add reference to image if animation_url was used as source
|
|
102
|
+
if ((token.animation_url || token.animationUrl) && (token.image?.url || token.image)) {
|
|
103
|
+
dp1Item.ref = token.image?.url || token.image;
|
|
104
|
+
}
|
|
105
|
+
return dp1Item;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Convert NFT token info(s) to DP1 playlist item(s)
|
|
109
|
+
*
|
|
110
|
+
* Handles both single token objects and maps/arrays of tokens.
|
|
111
|
+
* For collections, returns a map of token key to DP1 item.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object|Array} tokenInfo - Token information (single object or map of tokens)
|
|
114
|
+
* @param {number} duration - Display duration in seconds
|
|
115
|
+
* @returns {Object} Map of token key to DP1 playlist item, or single item
|
|
116
|
+
* @example
|
|
117
|
+
* // Single token
|
|
118
|
+
* const item = convertTokenToDP1Item(tokenInfo, 10);
|
|
119
|
+
*
|
|
120
|
+
* // Multiple tokens
|
|
121
|
+
* const items = convertTokenToDP1Item({ token1: info1, token2: info2 }, 10);
|
|
122
|
+
*/
|
|
123
|
+
function convertTokenToDP1Item(tokenInfo, duration = 10) {
|
|
124
|
+
// Handle array or map of tokens
|
|
125
|
+
if (typeof tokenInfo === 'object' && !tokenInfo.token) {
|
|
126
|
+
const results = {};
|
|
127
|
+
Object.entries(tokenInfo).forEach(([key, info]) => {
|
|
128
|
+
if (info.success !== false && info.token) {
|
|
129
|
+
try {
|
|
130
|
+
results[key] = convertTokenToDP1ItemSingle(info, duration);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
results[key] = {
|
|
134
|
+
success: false,
|
|
135
|
+
error: error.message,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
results[key] = {
|
|
141
|
+
success: false,
|
|
142
|
+
error: info.error || 'Invalid token info',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
// Handle single token (backward compatibility)
|
|
149
|
+
try {
|
|
150
|
+
return convertTokenToDP1ItemSingle(tokenInfo, duration);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
error: error.message,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Convert multiple tokens to DP1 playlist items
|
|
161
|
+
*
|
|
162
|
+
* Filters out failed tokens and converts successful ones.
|
|
163
|
+
* Excludes items with data URIs in their source field.
|
|
164
|
+
*
|
|
165
|
+
* @param {Array} tokensInfo - Array of token information
|
|
166
|
+
* @param {number} duration - Display duration in seconds
|
|
167
|
+
* @returns {Array} Array of DP1 playlist items
|
|
168
|
+
* @example
|
|
169
|
+
* const items = convertTokensToDP1Items(tokensInfoArray, 10);
|
|
170
|
+
*/
|
|
171
|
+
function convertTokensToDP1Items(tokensInfo, duration = 10) {
|
|
172
|
+
return tokensInfo
|
|
173
|
+
.filter((info) => info.success && info.token)
|
|
174
|
+
.map((info) => {
|
|
175
|
+
try {
|
|
176
|
+
return convertTokenToDP1Item(info, duration);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
error: error.message,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.filter((item) => item.success !== false);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Generate a descriptive playlist title from items
|
|
189
|
+
*
|
|
190
|
+
* Analyzes items to determine if it's an NFT playlist and generates
|
|
191
|
+
* an appropriate title based on the collection structure.
|
|
192
|
+
*
|
|
193
|
+
* @param {Array} items - Array of DP1 items
|
|
194
|
+
* @returns {string} Generated title
|
|
195
|
+
* @example
|
|
196
|
+
* const title = generatePlaylistTitle(items);
|
|
197
|
+
* // Returns: "NFT Collection Playlist" or "Multi-Collection NFT Playlist"
|
|
198
|
+
*/
|
|
199
|
+
function generatePlaylistTitle(items) {
|
|
200
|
+
if (!items || items.length === 0) {
|
|
201
|
+
return 'DP1 Playlist';
|
|
202
|
+
}
|
|
203
|
+
// Check if all items have provenance (likely NFT playlist)
|
|
204
|
+
const hasProvenance = items.some((item) => item.provenance?.type === 'onChain');
|
|
205
|
+
if (hasProvenance) {
|
|
206
|
+
// Count unique contracts for NFT playlists
|
|
207
|
+
const contracts = new Set();
|
|
208
|
+
items.forEach((item) => {
|
|
209
|
+
if (item.provenance?.contract?.address) {
|
|
210
|
+
contracts.add(item.provenance.contract.address);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
if (contracts.size === 1) {
|
|
214
|
+
return `NFT Collection Playlist`;
|
|
215
|
+
}
|
|
216
|
+
else if (contracts.size > 1) {
|
|
217
|
+
return `Multi-Collection NFT Playlist`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Fallback: use item count
|
|
221
|
+
return `DP1 Playlist (${items.length} ${items.length === 1 ? 'item' : 'items'})`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Build complete DP1 v1.0.0 compliant playlist
|
|
225
|
+
*
|
|
226
|
+
* Creates a complete playlist structure with metadata, defaults, and optional signature.
|
|
227
|
+
* Supports both object parameter and legacy separate parameters for backward compatibility.
|
|
228
|
+
*
|
|
229
|
+
* @param {Object|Array} paramsOrItems - Playlist parameters object or items array (legacy)
|
|
230
|
+
* @param {Array} [paramsOrItems.items] - Array of DP1 items
|
|
231
|
+
* @param {string} [paramsOrItems.title] - Playlist title (auto-generated if not provided)
|
|
232
|
+
* @param {string} [paramsOrItems.slug] - Playlist slug (auto-generated from title if not provided)
|
|
233
|
+
* @param {boolean} [paramsOrItems.deterministicMode] - Enable deterministic mode for testing
|
|
234
|
+
* @param {string} [paramsOrItems.fixedTimestamp] - Fixed timestamp for deterministic mode
|
|
235
|
+
* @param {string} [paramsOrItems.fixedId] - Fixed ID for deterministic mode
|
|
236
|
+
* @param {Object} options - Additional options (legacy parameter)
|
|
237
|
+
* @param {string} [options.title] - Playlist title (legacy)
|
|
238
|
+
* @param {string} [options.slug] - Playlist slug (legacy; auto-generated from title if omitted)
|
|
239
|
+
* @param {boolean} [options.deterministicMode] - Enable deterministic mode for testing
|
|
240
|
+
* @param {string} [options.fixedTimestamp] - Fixed timestamp for deterministic mode
|
|
241
|
+
* @param {string} [options.fixedId] - Fixed ID for deterministic mode
|
|
242
|
+
* @returns {Promise<Object>} Complete DP1 playlist with signature
|
|
243
|
+
* @throws {Error} When items array is empty or invalid
|
|
244
|
+
* @example
|
|
245
|
+
* // New style
|
|
246
|
+
* const playlist = await buildDP1Playlist({ items, title: 'My Playlist', slug: 'my-playlist' });
|
|
247
|
+
*
|
|
248
|
+
* // Legacy style
|
|
249
|
+
* const playlist = await buildDP1Playlist(items, { title: 'My Playlist' });
|
|
250
|
+
*
|
|
251
|
+
* // Deterministic mode for testing
|
|
252
|
+
* const playlist = await buildDP1Playlist(items, {
|
|
253
|
+
* title: 'Test',
|
|
254
|
+
* deterministicMode: true,
|
|
255
|
+
* fixedTimestamp: '2024-01-01T00:00:00.000Z',
|
|
256
|
+
* fixedId: 'playlist_test_123'
|
|
257
|
+
* });
|
|
258
|
+
*/
|
|
259
|
+
async function buildDP1Playlist(paramsOrItems, options = {}) {
|
|
260
|
+
// Handle both object parameter and legacy separate parameters
|
|
261
|
+
let items, title, slug, deterministicMode, fixedTimestamp, fixedId;
|
|
262
|
+
if (paramsOrItems &&
|
|
263
|
+
typeof paramsOrItems === 'object' &&
|
|
264
|
+
!Array.isArray(paramsOrItems) &&
|
|
265
|
+
paramsOrItems.items) {
|
|
266
|
+
// New style: single object parameter
|
|
267
|
+
({ items, title, slug, deterministicMode, fixedTimestamp, fixedId } = paramsOrItems);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Legacy style: separate parameters
|
|
271
|
+
items = paramsOrItems;
|
|
272
|
+
({ title, slug, deterministicMode, fixedTimestamp, fixedId } = options);
|
|
273
|
+
}
|
|
274
|
+
// Validate items
|
|
275
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
276
|
+
throw new Error('Playlist must contain at least one item');
|
|
277
|
+
}
|
|
278
|
+
// Workaround: Parse items if they are JSON strings (some AI models return escaped strings)
|
|
279
|
+
items = items.map((item) => {
|
|
280
|
+
if (typeof item === 'string') {
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(item);
|
|
283
|
+
}
|
|
284
|
+
catch (_e) {
|
|
285
|
+
return item; // If parsing fails, return as-is
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return item;
|
|
289
|
+
});
|
|
290
|
+
// Auto-generate title if not provided
|
|
291
|
+
if (!title) {
|
|
292
|
+
title = generatePlaylistTitle(items);
|
|
293
|
+
}
|
|
294
|
+
// Auto-generate slug when not provided
|
|
295
|
+
if (!slug) {
|
|
296
|
+
slug = slugify(title);
|
|
297
|
+
}
|
|
298
|
+
// Build DP1 playlist structure (DP1 v1.0.0 + OpenAPI spec compliance)
|
|
299
|
+
// Support deterministic mode for testing (freeze timestamp and ID)
|
|
300
|
+
const timestamp = deterministicMode && fixedTimestamp ? fixedTimestamp : new Date().toISOString();
|
|
301
|
+
const crypto = require('crypto');
|
|
302
|
+
const playlistId = deterministicMode && fixedId ? fixedId : crypto.randomUUID();
|
|
303
|
+
const playlist = {
|
|
304
|
+
dpVersion: '1.0.0',
|
|
305
|
+
id: playlistId,
|
|
306
|
+
title,
|
|
307
|
+
created: timestamp,
|
|
308
|
+
items,
|
|
309
|
+
defaults: {
|
|
310
|
+
display: {
|
|
311
|
+
scaling: 'fit',
|
|
312
|
+
background: '#111',
|
|
313
|
+
margin: 0,
|
|
314
|
+
},
|
|
315
|
+
license: 'token',
|
|
316
|
+
duration: 10,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
// Always include slug (auto-generated when missing)
|
|
320
|
+
playlist.slug = slug;
|
|
321
|
+
// Sign the playlist if private key is configured
|
|
322
|
+
try {
|
|
323
|
+
const playlistConfig = getPlaylistConfig();
|
|
324
|
+
if (playlistConfig.privateKey) {
|
|
325
|
+
playlist.signature = await signPlaylist(playlist, playlistConfig.privateKey);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
// If signing fails, log warning but continue (signature is optional)
|
|
330
|
+
console.warn(`Warning: Failed to sign playlist: ${error.message}`);
|
|
331
|
+
}
|
|
332
|
+
return playlist;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Validate DP1 playlist structure according to OpenAPI spec
|
|
336
|
+
*
|
|
337
|
+
* Performs comprehensive validation of playlist structure, fields, and item requirements.
|
|
338
|
+
* Returns detailed errors for each validation failure.
|
|
339
|
+
*
|
|
340
|
+
* @param {Object} playlist - DP1 playlist to validate
|
|
341
|
+
* @returns {Object} Validation result
|
|
342
|
+
* @returns {boolean} returns.valid - Whether playlist is valid
|
|
343
|
+
* @returns {Array<string>} returns.errors - Array of error messages
|
|
344
|
+
* @example
|
|
345
|
+
* const result = validateDP1Playlist(playlist);
|
|
346
|
+
* if (!result.valid) {
|
|
347
|
+
* console.error('Validation errors:', result.errors);
|
|
348
|
+
* }
|
|
349
|
+
*/
|
|
350
|
+
function validateDP1Playlist(playlist) {
|
|
351
|
+
const errors = [];
|
|
352
|
+
// Check required playlist fields
|
|
353
|
+
if (!playlist.dpVersion) {
|
|
354
|
+
errors.push('Missing required field: dpVersion');
|
|
355
|
+
}
|
|
356
|
+
else if (typeof playlist.dpVersion !== 'string') {
|
|
357
|
+
errors.push('Field "dpVersion" must be a string');
|
|
358
|
+
}
|
|
359
|
+
if (!playlist.title) {
|
|
360
|
+
errors.push('Missing required field: title');
|
|
361
|
+
}
|
|
362
|
+
else if (playlist.title.length > 256) {
|
|
363
|
+
errors.push('Field "title" must not exceed 256 characters');
|
|
364
|
+
}
|
|
365
|
+
if (!playlist.items) {
|
|
366
|
+
errors.push('Missing required field: items');
|
|
367
|
+
}
|
|
368
|
+
else if (!Array.isArray(playlist.items)) {
|
|
369
|
+
errors.push('Field "items" must be an array');
|
|
370
|
+
}
|
|
371
|
+
else if (playlist.items.length === 0) {
|
|
372
|
+
errors.push('Playlist must contain at least one item');
|
|
373
|
+
}
|
|
374
|
+
else if (playlist.items.length > 1024) {
|
|
375
|
+
errors.push('Playlist cannot contain more than 1024 items');
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// Validate each item according to PlaylistItem schema
|
|
379
|
+
playlist.items.forEach((item, index) => {
|
|
380
|
+
if (!item.source) {
|
|
381
|
+
errors.push(`Item ${index}: Missing required field "source"`);
|
|
382
|
+
}
|
|
383
|
+
else if (typeof item.source !== 'string') {
|
|
384
|
+
errors.push(`Item ${index}: Field "source" must be a string (URI)`);
|
|
385
|
+
}
|
|
386
|
+
if (item.duration === undefined || item.duration === null) {
|
|
387
|
+
errors.push(`Item ${index}: Missing required field "duration"`);
|
|
388
|
+
}
|
|
389
|
+
else if (typeof item.duration !== 'number' || item.duration < 1) {
|
|
390
|
+
errors.push(`Item ${index}: Field "duration" must be a number >= 1`);
|
|
391
|
+
}
|
|
392
|
+
if (!item.license) {
|
|
393
|
+
errors.push(`Item ${index}: Missing required field "license"`);
|
|
394
|
+
}
|
|
395
|
+
else if (!['open', 'token', 'subscription'].includes(item.license)) {
|
|
396
|
+
errors.push(`Item ${index}: Field "license" must be one of: open, token, subscription`);
|
|
397
|
+
}
|
|
398
|
+
// Validate optional title length
|
|
399
|
+
if (item.title && item.title.length > 256) {
|
|
400
|
+
errors.push(`Item ${index}: Field "title" must not exceed 256 characters`);
|
|
401
|
+
}
|
|
402
|
+
// Validate optional provenance structure
|
|
403
|
+
if (item.provenance) {
|
|
404
|
+
if (!item.provenance.type) {
|
|
405
|
+
errors.push(`Item ${index}: provenance.type is required when provenance is present`);
|
|
406
|
+
}
|
|
407
|
+
else if (!['onChain', 'seriesRegistry', 'offChainURI'].includes(item.provenance.type)) {
|
|
408
|
+
errors.push(`Item ${index}: provenance.type must be one of: onChain, seriesRegistry, offChainURI`);
|
|
409
|
+
}
|
|
410
|
+
if (item.provenance.contract) {
|
|
411
|
+
if (!item.provenance.contract.chain) {
|
|
412
|
+
errors.push(`Item ${index}: provenance.contract.chain is required`);
|
|
413
|
+
}
|
|
414
|
+
else if (!['evm', 'tezos', 'bitmark', 'other'].includes(item.provenance.contract.chain)) {
|
|
415
|
+
errors.push(`Item ${index}: provenance.contract.chain must be one of: evm, tezos, bitmark, other`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
valid: errors.length === 0,
|
|
423
|
+
errors,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Detect MIME type from URL or file extension
|
|
428
|
+
*
|
|
429
|
+
* Analyzes URL to determine appropriate MIME type for media content.
|
|
430
|
+
* Supports images, videos, audio, and 3D models.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} url - Media URL
|
|
433
|
+
* @returns {string} MIME type
|
|
434
|
+
* @example
|
|
435
|
+
* const mimeType = detectMimeType('https://example.com/image.png');
|
|
436
|
+
* // Returns: 'image/png'
|
|
437
|
+
*/
|
|
438
|
+
function detectMimeType(url) {
|
|
439
|
+
if (!url) {
|
|
440
|
+
return 'image/png';
|
|
441
|
+
}
|
|
442
|
+
const extension = url.split('.').pop()?.toLowerCase().split('?')[0];
|
|
443
|
+
const mimeTypes = {
|
|
444
|
+
jpg: 'image/jpeg',
|
|
445
|
+
jpeg: 'image/jpeg',
|
|
446
|
+
png: 'image/png',
|
|
447
|
+
gif: 'image/gif',
|
|
448
|
+
webp: 'image/webp',
|
|
449
|
+
svg: 'image/svg+xml',
|
|
450
|
+
mp4: 'video/mp4',
|
|
451
|
+
webm: 'video/webm',
|
|
452
|
+
mp3: 'audio/mpeg',
|
|
453
|
+
wav: 'audio/wav',
|
|
454
|
+
glb: 'model/gltf-binary',
|
|
455
|
+
gltf: 'model/gltf+json',
|
|
456
|
+
};
|
|
457
|
+
return mimeTypes[extension] || 'image/png';
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Build a single DP1 playlist item from a URL
|
|
461
|
+
*
|
|
462
|
+
* @param {string} url - Media URL
|
|
463
|
+
* @param {number} duration - Duration per item in seconds
|
|
464
|
+
* @param {Object} [options] - Optional configuration
|
|
465
|
+
* @param {string} [options.title] - Optional item title override
|
|
466
|
+
* @returns {Object} DP1 playlist item
|
|
467
|
+
*/
|
|
468
|
+
function buildUrlItem(url, duration = 10, options = {}) {
|
|
469
|
+
const sourceUrl = String(url || '').trim();
|
|
470
|
+
if (!sourceUrl) {
|
|
471
|
+
throw new Error('URL is required to build a playlist item');
|
|
472
|
+
}
|
|
473
|
+
if (sourceUrl.startsWith('data:')) {
|
|
474
|
+
throw new Error('Item source is a data URI - excluded from playlist');
|
|
475
|
+
}
|
|
476
|
+
let title = options.title;
|
|
477
|
+
if (!title) {
|
|
478
|
+
try {
|
|
479
|
+
const parsed = new URL(sourceUrl);
|
|
480
|
+
const pathName = parsed.pathname.split('/').filter(Boolean).pop();
|
|
481
|
+
if (pathName) {
|
|
482
|
+
title = decodeURIComponent(pathName);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
title = parsed.hostname || 'URL Playback';
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (_error) {
|
|
489
|
+
title = 'URL Playback';
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const crypto = require('crypto');
|
|
493
|
+
const itemId = crypto.randomUUID();
|
|
494
|
+
const item = {
|
|
495
|
+
id: itemId,
|
|
496
|
+
title,
|
|
497
|
+
source: sourceUrl,
|
|
498
|
+
duration: duration,
|
|
499
|
+
license: 'open',
|
|
500
|
+
created: new Date().toISOString(),
|
|
501
|
+
provenance: {
|
|
502
|
+
type: 'offChainURI',
|
|
503
|
+
uri: sourceUrl,
|
|
504
|
+
},
|
|
505
|
+
display: {
|
|
506
|
+
scaling: 'fit',
|
|
507
|
+
background: '#111',
|
|
508
|
+
margin: 0,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
return item;
|
|
512
|
+
}
|
|
513
|
+
module.exports = {
|
|
514
|
+
convertTokenToDP1Item,
|
|
515
|
+
convertTokenToDP1ItemSingle,
|
|
516
|
+
convertTokensToDP1Items,
|
|
517
|
+
generatePlaylistTitle,
|
|
518
|
+
buildDP1Playlist,
|
|
519
|
+
validateDP1Playlist,
|
|
520
|
+
detectMimeType,
|
|
521
|
+
buildUrlItem,
|
|
522
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.publishPlaylist = publishPlaylist;
|
|
40
|
+
const axios_1 = __importDefault(require("axios"));
|
|
41
|
+
const fs_1 = __importDefault(require("fs"));
|
|
42
|
+
/**
|
|
43
|
+
* Publish a validated playlist to a DP-1 feed server
|
|
44
|
+
*
|
|
45
|
+
* Flow:
|
|
46
|
+
* 1. Read and parse playlist file
|
|
47
|
+
* 2. Validate playlist against DP-1 spec using verifyPlaylist
|
|
48
|
+
* 3. If valid, send the original playlist to feed server
|
|
49
|
+
* 4. Return result with playlist ID or error
|
|
50
|
+
*
|
|
51
|
+
* @param {string} filePath - Path to playlist JSON file
|
|
52
|
+
* @param {string} feedServerUrl - Feed server base URL
|
|
53
|
+
* @param {string} [apiKey] - Optional API key for authentication
|
|
54
|
+
* @returns {Promise<Object>} Result with success status, playlistId, or error
|
|
55
|
+
* @example
|
|
56
|
+
* const result = await publishPlaylist('playlist.json', 'http://localhost:8787/api/v1', 'api-key');
|
|
57
|
+
* if (result.success) {
|
|
58
|
+
* console.log(`Published with ID: ${result.playlistId}`);
|
|
59
|
+
* } else {
|
|
60
|
+
* console.error(`Failed: ${result.error}`);
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
async function publishPlaylist(filePath, feedServerUrl, apiKey) {
|
|
64
|
+
try {
|
|
65
|
+
// Step 1: Read and parse playlist file
|
|
66
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: `Playlist file not found: ${filePath}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
let playlist;
|
|
73
|
+
try {
|
|
74
|
+
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
75
|
+
playlist = JSON.parse(content);
|
|
76
|
+
}
|
|
77
|
+
catch (_parseError) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
error: `Invalid JSON in playlist file: ${filePath}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Step 2: Validate playlist
|
|
84
|
+
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
|
|
85
|
+
const validationResult = verifyPlaylist(playlist);
|
|
86
|
+
if (!validationResult.valid) {
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
error: `Playlist validation failed: ${validationResult.error}`,
|
|
90
|
+
message: validationResult.details?.map((d) => ` • ${d.path}: ${d.message}`).join('\n'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Step 3: Send validated playlist to feed server
|
|
94
|
+
const headers = {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
};
|
|
97
|
+
// Use provided apiKey, fallback to environment variable, or use empty string as last resort
|
|
98
|
+
const authKey = apiKey !== undefined ? apiKey : process.env.FEED_API_KEY || '';
|
|
99
|
+
if (authKey) {
|
|
100
|
+
headers['Authorization'] = `Bearer ${authKey}`;
|
|
101
|
+
}
|
|
102
|
+
const response = await axios_1.default.post(`${feedServerUrl}/playlists`, playlist, {
|
|
103
|
+
headers,
|
|
104
|
+
timeout: 30000,
|
|
105
|
+
});
|
|
106
|
+
const playlistId = response.data?.id || response.data?.uuid;
|
|
107
|
+
if (response.status === 201 || response.status === 202) {
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
playlistId,
|
|
111
|
+
message: `Published to feed server (${response.status === 202 ? 'queued' : 'created'})`,
|
|
112
|
+
feedServer: feedServerUrl,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
error: `Unexpected response status: ${response.status}`,
|
|
118
|
+
feedServer: feedServerUrl,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const axiosError = error;
|
|
123
|
+
const errorMessage = axiosError.response?.data
|
|
124
|
+
? JSON.stringify(axiosError.response.data)
|
|
125
|
+
: axiosError.message;
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: `Failed to publish: ${errorMessage}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|