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.
@@ -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
+ }