@feralfile/cli 2.4.0 → 2.6.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.
@@ -7,17 +7,16 @@
7
7
  },
8
8
  "feedServers": [
9
9
  {
10
- "baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1",
11
- "apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
10
+ "baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
12
11
  },
13
12
  {
14
- "baseUrl": "http://localhost:8787",
15
- "apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
13
+ "baseUrl": "http://localhost:8787"
16
14
  }
17
15
  ],
18
16
  "playlist": {
19
17
  "privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
20
- "role": "agent"
18
+ "role": "agent",
19
+ "curatorName": "YOUR_CURATOR_NAME"
21
20
  },
22
21
  "ff1Relayer": {
23
22
  "baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,7 +60,7 @@
60
60
  "dependencies": {
61
61
  "@feralfile/source-resolver": "^1.0.0",
62
62
  "@napi-rs/keyring": "1.3.0",
63
- "axios": "^1.6.2",
63
+ "axios": "^1.20.0",
64
64
  "bonjour-service": "^1.3.0",
65
65
  "chalk": "^4.1.2",
66
66
  "commander": "^11.1.0",
@@ -68,7 +68,7 @@
68
68
  "dp1-js": "2.3.1",
69
69
  "ff1": "^1.0.0",
70
70
  "fuzzysort": "^3.1.0",
71
- "viem": "^2.38.2"
71
+ "viem": "^2.56.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@types/node": "^24.7.1",
@@ -12,7 +12,7 @@ const path_1 = require("path");
12
12
  const enrich_playlist_1 = require("../utilities/enrich-playlist");
13
13
  const playlist_verifier_1 = require("../utilities/playlist-verifier");
14
14
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
- const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
15
+ const { resolveTokenBatch } = require('../utilities/nft-indexer');
16
16
  /**
17
17
  * writePlaylistAtomically replaces a file only once the new bytes are safely on
18
18
  * disk.
@@ -63,7 +63,16 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
63
63
  // of it, or widen who can reach it. Carry the ownership across, and when
64
64
  // that is not permitted refuse the in-place replacement instead of
65
65
  // completing it with different access than the file had.
66
- if (current && (current.uid !== process.getuid?.() || current.gid !== process.getgid?.())) {
66
+ //
67
+ // Only where POSIX identity exists. Windows has no getuid/getgid and
68
+ // reports uid and gid as 0 on every stat, so the comparison there would
69
+ // always "differ" and chown a file whose ownership never moved. (libuv
70
+ // makes fchown a no-op on Windows, so nothing would break — but the
71
+ // branch would be asserting something it cannot know.)
72
+ const uid = process.getuid?.();
73
+ const gid = process.getgid?.();
74
+ const canCompareOwnership = uid !== undefined && gid !== undefined;
75
+ if (current && canCompareOwnership && (current.uid !== uid || current.gid !== gid)) {
67
76
  try {
68
77
  await handle.chown(current.uid, current.gid);
69
78
  }
@@ -230,12 +239,22 @@ exports.enrichCommand = new commander_1.Command('enrich')
230
239
  printedProgress = true;
231
240
  process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
232
241
  };
233
- // getNFTTokenInfoBatch is (tokens, duration, onProgress) — the second
242
+ // resolveTokenBatch is (tokens, duration, onProgress) — the second
234
243
  // positional is DP-1 display seconds, not the callback. Enrichment never
235
244
  // sets duration (the curator's timing is not ours to touch), so it is
236
245
  // passed undefined and the callback goes third. find.ts carries the same
237
246
  // warning; getting this wrong silently returns unusable results.
238
- const lookup = (tokens, onProgressCallback) => getNFTTokenInfoBatch(tokens, undefined, onProgressCallback);
247
+ //
248
+ // The raw results are used rather than getNFTTokenInfoBatch's items so
249
+ // the indexer's still rides beside each item: the item itself drops the
250
+ // still whenever it equals the indexer's chosen source, and enrichment
251
+ // needs it against the curator's source instead.
252
+ const lookup = async (tokens, onProgressCallback) => {
253
+ const results = await resolveTokenBatch(tokens, undefined, onProgressCallback);
254
+ return results
255
+ .filter((result) => result.success && result.item)
256
+ .map((result) => ({ ...result.item, still: result.still }));
257
+ };
239
258
  const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
240
259
  force: options.force,
241
260
  assumeEthereum: options.assumeEthereum,
@@ -641,8 +641,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
641
641
  serverIndex = n;
642
642
  }
643
643
  const serverUrl = feedConfig.baseURLs[serverIndex];
644
- const serverApiKey = feedConfig.servers?.[serverIndex]?.apiKey ?? feedConfig.apiKey;
645
- const result = await publishPlaylist(savedPath, serverUrl, serverApiKey);
644
+ const result = await publishPlaylist(savedPath, serverUrl);
646
645
  if (result.success) {
647
646
  console.log(chalk_1.default.green('✓ Published'));
648
647
  if (result.playlistId) {
@@ -128,7 +128,6 @@ exports.playCommand = new commander_1.Command('play')
128
128
  console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
129
129
  process.exit(1);
130
130
  }
131
- const config = (0, config_1.getConfig)();
132
131
  // No explicit duration: a direct video/audio URL plays its natural
133
132
  // length (DP-1 §4.1); static media uses config.defaultDuration.
134
133
  const resolved = probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
@@ -143,7 +142,11 @@ exports.playCommand = new commander_1.Command('play')
143
142
  // A direct media/web URL is wrapped in an unsigned single-item playlist,
144
143
  // so it must be signed before the verify gate; a loaded playlist is sent
145
144
  // as-is. castPlaylist owns the sign → verify → send sequence.
146
- const privateKey = config.playlist?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
145
+ // Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
146
+ // place that knows a config value may still be the config.json.example placeholder, in which case a
147
+ // valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
148
+ // this path sign with the template literal while every other signing path used the real key.
149
+ const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
147
150
  const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
148
151
  deviceName: options.device,
149
152
  skipVerify: options.skipVerify,
@@ -56,7 +56,6 @@ exports.publishCommand = new commander_1.Command('publish')
56
56
  process.exit(1);
57
57
  }
58
58
  let serverUrl = feedConfig.baseURLs[0];
59
- let serverApiKey = feedConfig.apiKey;
60
59
  if (feedConfig.baseURLs.length > 1) {
61
60
  if (!options.server) {
62
61
  console.log(chalk_1.default.yellow('Multiple feed servers found. Select one:'));
@@ -77,14 +76,8 @@ exports.publishCommand = new commander_1.Command('publish')
77
76
  process.exit(1);
78
77
  }
79
78
  serverUrl = feedConfig.baseURLs[serverIndex];
80
- if (feedConfig.servers && feedConfig.servers[serverIndex]) {
81
- serverApiKey = feedConfig.servers[serverIndex].apiKey;
82
- }
83
- }
84
- else if (feedConfig.servers && feedConfig.servers[0]) {
85
- serverApiKey = feedConfig.servers[0].apiKey;
86
79
  }
87
- const result = await publishPlaylist(file, serverUrl, serverApiKey);
80
+ const result = await publishPlaylist(file, serverUrl);
88
81
  if (result.success) {
89
82
  console.log(chalk_1.default.green('Published'));
90
83
  if (result.playlistId) {
@@ -10,15 +10,47 @@ const config_files_1 = require("./helpers/config-files");
10
10
  const config_1 = require("../config");
11
11
  const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
12
12
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
13
+ const signing_identity_1 = require("../utilities/signing-identity");
13
14
  const config_placeholders_1 = require("../utilities/config-placeholders");
14
15
  exports.statusCommand = new commander_1.Command('status')
15
16
  .description('Show configuration status')
16
- .action(async () => {
17
+ // Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
18
+ // signs with a key the config never mentions, so reporting only the configured identity would hand the
19
+ // user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
20
+ // missing one.
21
+ .option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
22
+ .action(async (options) => {
17
23
  try {
24
+ const overrideKey = options.key?.trim();
25
+ if (overrideKey) {
26
+ try {
27
+ console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
28
+ console.log(chalk_1.default.dim(" declare this in the playlist's curators[] before signing"));
29
+ return;
30
+ }
31
+ catch (error) {
32
+ console.log(chalk_1.default.red(`--key unusable: ${error.message}`));
33
+ process.exit(1);
34
+ }
35
+ }
18
36
  const configPath = await (0, config_files_1.resolveExistingConfigPath)();
19
37
  if (!configPath) {
20
38
  console.log(chalk_1.default.red('config.json not found'));
21
39
  console.log(chalk_1.default.dim('Run: ff-cli setup'));
40
+ // PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
41
+ // the one value a user must know before they can write a publishable playlist — it has to be
42
+ // declared in curators[] before signing. Exiting silently here would leave an environment-only
43
+ // user unable to obtain it from anywhere. The exit status still reports "not configured".
44
+ const envKey = process.env.PLAYLIST_PRIVATE_KEY?.trim();
45
+ if (envKey) {
46
+ try {
47
+ console.log(chalk_1.default.green(`\nSigning identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(envKey)}`));
48
+ console.log(chalk_1.default.dim(" from PLAYLIST_PRIVATE_KEY; declare it in the playlist's curators[]"));
49
+ }
50
+ catch (error) {
51
+ console.log(chalk_1.default.red(`\nPLAYLIST_PRIVATE_KEY unusable: ${error.message}`));
52
+ }
53
+ }
22
54
  process.exit(1);
23
55
  }
24
56
  const config = await (0, config_files_1.readConfigFile)(configPath);
@@ -38,6 +70,17 @@ exports.statusCommand = new commander_1.Command('status')
38
70
  playlistRoleError = playlistRoleMaterial;
39
71
  }
40
72
  }
73
+ // The feed matches a signature's kid against the document's own curators[], so this value is not
74
+ // decoration: without it a user cannot write a publishable playlist, and it appears nowhere else.
75
+ let signingDidKey;
76
+ if (hasPlaylistSigningKey) {
77
+ try {
78
+ signingDidKey = (0, signing_identity_1.playlistSigningDidKey)(playlistKeyMaterial);
79
+ }
80
+ catch {
81
+ signingDidKey = undefined;
82
+ }
83
+ }
41
84
  const statuses = [
42
85
  {
43
86
  label: 'Config file',
@@ -55,6 +98,13 @@ exports.statusCommand = new commander_1.Command('status')
55
98
  : undefined,
56
99
  hint: ' (needed for signing and legacy verification)',
57
100
  },
101
+ {
102
+ label: 'Signing identity (did:key)',
103
+ ok: Boolean(signingDidKey),
104
+ optional: false,
105
+ detail: signingDidKey,
106
+ hint: " (declare this in the playlist's curators[] so the feed accepts a publish)",
107
+ },
58
108
  {
59
109
  label: 'Playlist signing role',
60
110
  ok: hasValidPlaylistRole,
@@ -121,10 +121,38 @@ function getPlaylistConfig() {
121
121
  const config = getConfig();
122
122
  const playlistConfig = config.playlist || {};
123
123
  return {
124
- privateKey: playlistConfig.privateKey || process.env.PLAYLIST_PRIVATE_KEY || null,
124
+ // `config init` writes the sample file through verbatim, so an untouched config carries the
125
+ // YOUR_ED25519_PRIVATE_KEY... literal. It is truthy, so without this it would beat a perfectly good
126
+ // PLAYLIST_PRIVATE_KEY and every signing path would fail on a key the user never chose.
127
+ privateKey: (0, config_placeholders_1.isMissingConfigValue)(playlistConfig.privateKey)
128
+ ? process.env.PLAYLIST_PRIVATE_KEY || null
129
+ : playlistConfig.privateKey || null,
125
130
  role: playlistConfig.role || process.env.PLAYLIST_ROLE || null,
131
+ curatorName: resolveCuratorName(playlistConfig.curatorName),
126
132
  };
127
133
  }
134
+ /** The literal shipped in config.json.example, and the only curator name treated as unset. */
135
+ const CURATOR_NAME_TEMPLATE = 'YOUR_CURATOR_NAME';
136
+ /**
137
+ * Resolve the curator name recorded in a built playlist's signed `curators[]`.
138
+ *
139
+ * Two hazards meet here, and they pull in opposite directions. The sample value must not win over the
140
+ * environment, or a freshly initialized config publishes under a placeholder. But the generic
141
+ * YOUR_/your_ placeholder test is too broad for a human name — a real studio called "YOUR_STUDIO" would
142
+ * be silently replaced, and the wrong attribution would then be sealed inside a signature. So only the
143
+ * exact template literal counts as unset.
144
+ *
145
+ * A non-string value is treated as unset rather than passed through: config.json is arbitrary runtime
146
+ * JSON, and this value is assigned into the document *after* the builder has validated it, so a number
147
+ * here would be signed into a malformed playlist instead of being rejected.
148
+ */
149
+ function resolveCuratorName(configured) {
150
+ const fromConfig = typeof configured === 'string' ? configured.trim() : '';
151
+ if (fromConfig && fromConfig !== CURATOR_NAME_TEMPLATE) {
152
+ return fromConfig;
153
+ }
154
+ return process.env.PLAYLIST_CURATOR_NAME?.trim() || null;
155
+ }
128
156
  /**
129
157
  * Resolve the effective playlist signing role used by runtime paths.
130
158
  *
@@ -146,12 +174,15 @@ function resolveEffectivePlaylistRole(config) {
146
174
  /**
147
175
  * Get feed configuration for DP1 feed API
148
176
  *
149
- * Supports both legacy (feed.baseURLs/apiKey) and new (feedServers array) formats.
177
+ * Supports both legacy (feed.baseURLs) and new (feedServers array) formats.
178
+ *
179
+ * No API key is returned. The feed authorizes writes from the signatures inside the document, so a key
180
+ * is neither sent nor accepted; an `apiKey` left in an existing config file is simply ignored rather
181
+ * than being an error, so old configs keep working.
150
182
  *
151
183
  * @returns {Object} Feed configuration
152
184
  * @returns {string[]} returns.baseURLs - Array of base URLs for feed APIs
153
- * @returns {string} [returns.apiKey] - Optional API key for authentication (legacy)
154
- * @returns {Array<Object>} [returns.servers] - Array of feed servers with individual API keys (new)
185
+ * @returns {Array<Object>} [returns.servers] - Array of configured feed servers (new format)
155
186
  */
156
187
  function getFeedConfig() {
157
188
  const config = getConfig();
@@ -179,7 +210,6 @@ function getFeedConfig() {
179
210
  }
180
211
  return {
181
212
  baseURLs: urls,
182
- apiKey: feedConfig.apiKey,
183
213
  };
184
214
  }
185
215
  /**
@@ -281,6 +311,16 @@ function validateConfig() {
281
311
  }
282
312
  }
283
313
  }
314
+ // curatorName is signed into curators[] as a public name, and it is written into the document after
315
+ // the builder has validated it — so a malformed value is not caught anywhere downstream. Reporting a
316
+ // clean config while `build` emits an invalid signed playlist is the failure worth preventing here.
317
+ const curatorNameValue = config.playlist
318
+ ?.curatorName;
319
+ if (curatorNameValue !== undefined && curatorNameValue !== null) {
320
+ if (typeof curatorNameValue !== 'string' || curatorNameValue.trim().length === 0) {
321
+ errors.push('playlist.curatorName must be a non-empty string');
322
+ }
323
+ }
284
324
  if (config.playlist?.role !== undefined || process.env.PLAYLIST_ROLE !== undefined) {
285
325
  try {
286
326
  resolveEffectivePlaylistRole(config);
@@ -129,7 +129,43 @@ function coordinateKeyOf(provenance) {
129
129
  if (token.length === 0) {
130
130
  return null;
131
131
  }
132
- return `${canonicalChain(chain)}:${address.trim().toLowerCase()}:${token}`;
132
+ const canonical = canonicalChain(chain);
133
+ const standard = effectiveStandard(canonical, address, contract.standard);
134
+ return `${canonical}:${standard}:${address.trim().toLowerCase()}:${token}`;
135
+ }
136
+ /**
137
+ * Standards the indexer keys CIDs on. Mirrors CID_STANDARDS in nft-indexer.js.
138
+ */
139
+ const CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
140
+ /**
141
+ * effectiveStandard names the standard a coordinate is looked up under, so the
142
+ * correlation key separates what the indexer separates.
143
+ *
144
+ * The indexer keys ERC-721 and ERC-1155 tokens at one address under different
145
+ * CIDs, so a hybrid contract can hold both standards at one token id. Two
146
+ * playlist items asserting different standards for one coordinate are two
147
+ * lookups, and each answer must reach only the item that asked for it.
148
+ *
149
+ * When the playlist asserts nothing the client detects, and this mirrors that
150
+ * detection (resolveTokenStandard in nft-indexer.js) so the request key and
151
+ * the key read off the response agree. The mirror is deliberate rather than
152
+ * an import: this module takes its lookup by injection and stays free of the
153
+ * indexer client. If the two ever drift, correlation fails closed — the item
154
+ * reports not-indexed — rather than attributing one token's metadata to
155
+ * another.
156
+ */
157
+ function effectiveStandard(canonicalChainName, address, asserted) {
158
+ const normalized = typeof asserted === 'string' ? asserted.trim().toLowerCase() : '';
159
+ if (CID_STANDARDS.has(normalized)) {
160
+ return normalized;
161
+ }
162
+ if (canonicalChainName === 'tezos' || address.trim().startsWith('KT')) {
163
+ return 'fa2';
164
+ }
165
+ if (canonicalChainName === 'evm') {
166
+ return 'erc721';
167
+ }
168
+ return 'other';
133
169
  }
134
170
  /**
135
171
  * describeItem names an item for operator output, preferring its title.
@@ -160,14 +196,22 @@ function coordinateFor(item) {
160
196
  return null;
161
197
  }
162
198
  const normalized = chain.trim().toLowerCase();
163
- return {
199
+ const coordinate = {
164
200
  chain: CHAIN_ALIASES[normalized] ?? normalized,
165
201
  contractAddress: address,
166
202
  tokenId: token,
167
203
  };
204
+ // Carried as written. The indexer client accepts the standards its CIDs
205
+ // are keyed on and falls back to detection for anything else, so an
206
+ // unexpected value costs nothing beyond the lookup it would have lost anyway.
207
+ const standard = contract.standard;
208
+ if (typeof standard === 'string' && standard.trim().length > 0) {
209
+ coordinate.standard = standard.trim().toLowerCase();
210
+ }
211
+ return coordinate;
168
212
  }
169
213
  /**
170
- * stillFromIndexerSource recovers a thumbnail the manifest builder suppressed.
214
+ * stillFor recovers a thumbnail the manifest builder suppressed.
171
215
  *
172
216
  * `resolveStillUri` blanks a still that equals the source it was given, on the
173
217
  * reasonable ground that pointing a thumbnail at the artwork itself adds
@@ -179,64 +223,26 @@ function coordinateFor(item) {
179
223
  * exists to repair: a live work the app cannot rasterize, whose grid tile
180
224
  * stays empty.
181
225
  *
182
- * Recovering it is the inverse of the suppression. When the indexer's source
183
- * differs from the item's and is an http(s) URL, that source IS the still that
184
- * was elided, so it becomes the thumbnail relative to the item's own source.
226
+ * Recovery is the same rule applied against the curator's source instead of
227
+ * the indexer's: the still the indexer names for the token (`resolved.still`,
228
+ * its `display.image_url`) is used whenever it differs from the item's own
229
+ * source. It is the same field `find` and `build` write into
230
+ * `thumbnails.default`, and it is taken on the indexer's word, as they take
231
+ * it. No guess is made from a URL's shape — the indexer's source is often a
232
+ * live rendition and its stills are often extensionless CDN URLs, so a
233
+ * suffix check gets both cases wrong — and a lookup that names no still
234
+ * leaves the item without one.
185
235
  */
186
- function stillFromIndexerSource(item, resolved) {
187
- const indexerSource = typeof resolved.source === 'string' ? resolved.source.trim() : '';
236
+ function stillFor(item, resolved) {
237
+ const still = typeof resolved.still === 'string' ? resolved.still.trim() : '';
188
238
  const itemSource = typeof item.source === 'string' ? item.source.trim() : '';
189
- if (!indexerSource || indexerSource === itemSource) {
239
+ if (!still || still === itemSource) {
190
240
  return '';
191
241
  }
192
- if (!indexerSource.startsWith('http://') && !indexerSource.startsWith('https://')) {
242
+ if (!still.startsWith('http://') && !still.startsWith('https://')) {
193
243
  return '';
194
244
  }
195
- // The indexer's source is not necessarily a still. getBestMediaUrl prefers
196
- // display.animation_url and media assets over display.image_url, so it is
197
- // frequently a live HTML rendition — and writing one into the thumbnail slot
198
- // is worse than leaving the slot empty: the grid still cannot rasterize it,
199
- // and the item now claims a still it does not have.
200
- //
201
- // Only a URL that is demonstrably an image is used. The indexer exposes no
202
- // separately identified still on the item this command receives, so the file
203
- // extension is the evidence available; carrying a real image field through
204
- // the batch result would be the better fix and belongs upstream.
205
- return looksLikeImage(indexerSource) ? indexerSource : '';
206
- }
207
- /**
208
- * Media the app can draw a grid tile from, matching the types
209
- * playlist-builder.js already recognizes. SVG and video belong here: the app
210
- * rasterizes both, and excluding them left works with the empty tile this
211
- * command exists to remove. HTML is deliberately absent — an unrasterizable
212
- * page in the thumbnail slot is worse than an empty slot, because the item
213
- * then claims a still it does not have.
214
- */
215
- const RASTERIZABLE_EXTENSIONS = [
216
- '.png',
217
- '.jpg',
218
- '.jpeg',
219
- '.gif',
220
- '.webp',
221
- '.avif',
222
- '.bmp',
223
- '.svg',
224
- '.mp4',
225
- '.webm',
226
- ];
227
- /**
228
- * looksLikeImage tests a URL's path extension, ignoring query and fragment.
229
- */
230
- function looksLikeImage(url) {
231
- let pathname = url;
232
- try {
233
- pathname = new URL(url).pathname;
234
- }
235
- catch {
236
- pathname = url.split('?')[0].split('#')[0];
237
- }
238
- const lower = pathname.toLowerCase();
239
- return RASTERIZABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
245
+ return still;
240
246
  }
241
247
  /**
242
248
  * hasThumbnail reports whether a manifest already carries a default still.
@@ -258,9 +264,10 @@ function hasThumbnail(manifest) {
258
264
  * thumbnails) is what enrichment exists to fetch and is taken as given.
259
265
  */
260
266
  function manifestFor(item, resolved) {
261
- const still = stillFromIndexerSource(item, resolved);
267
+ const still = stillFor(item, resolved);
268
+ const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
269
+ const resolvedTitle = typeof resolved.title === 'string' ? resolved.title.trim() : '';
262
270
  let manifest = resolved.inlineManifest;
263
- let rekey = false;
264
271
  if (manifest && still && !hasThumbnail(manifest)) {
265
272
  manifest = {
266
273
  ...manifest,
@@ -269,30 +276,36 @@ function manifestFor(item, resolved) {
269
276
  thumbnails: { default: { uri: still } },
270
277
  },
271
278
  };
272
- rekey = true;
273
279
  }
274
- else if (!manifest && still) {
275
- // Nothing was emitted because the still was the only content the token
276
- // had, and it had been suppressed. A thumbnail-only manifest is worth its
277
- // payload in a way a title-only one is not: it is the difference between
278
- // a grid tile and an empty square.
279
- const synthesizedTitle = typeof item.title === 'string' ? item.title.trim() : '';
280
- manifest = {
281
- refVersion: '1.1.0',
282
- id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
283
- created: MANIFEST_CREATED,
284
- locale: 'en',
285
- metadata: {
286
- ...(synthesizedTitle ? { title: synthesizedTitle } : {}),
287
- thumbnails: { default: { uri: still } },
288
- },
289
- };
280
+ else if (!manifest) {
281
+ // Nothing was emitted because the token had only a title, or only a still
282
+ // that was suppressed against the indexer's source. Either can still be
283
+ // worth a manifest here, for a reason `find` and `build` never meet: their
284
+ // items always carry the token's title, so a title-only manifest would
285
+ // repeat what the item already says. A curator's item may carry no title
286
+ // at all, and the tombstone then shows nothing — so a substantiated title
287
+ // is written when the item lacks one, and a still is written whenever
288
+ // there is one, because it is the difference between a grid tile and an
289
+ // empty square.
290
+ const synthesizedTitle = curatorTitle || resolvedTitle;
291
+ const titleWorthCarrying = !curatorTitle && resolvedTitle.length > 0;
292
+ if (still || titleWorthCarrying) {
293
+ manifest = {
294
+ refVersion: '1.1.0',
295
+ id: derivedManifestId('', `${synthesizedTitle}\n${still}`),
296
+ created: MANIFEST_CREATED,
297
+ locale: 'en',
298
+ metadata: {
299
+ ...(synthesizedTitle ? { title: synthesizedTitle } : {}),
300
+ ...(still ? { thumbnails: { default: { uri: still } } } : {}),
301
+ },
302
+ };
303
+ }
290
304
  }
291
305
  if (!manifest) {
292
306
  return undefined;
293
307
  }
294
308
  const metadata = manifest.metadata ?? {};
295
- const curatorTitle = typeof item.title === 'string' ? item.title.trim() : '';
296
309
  const overrideTitle = curatorTitle.length > 0 && metadata.title !== curatorTitle;
297
310
  // The id is derived from the finished payload every time, not only when this
298
311
  // command changed something. The indexer keys its id on the token
@@ -304,10 +317,6 @@ function manifestFor(item, resolved) {
304
317
  //
305
318
  // The original id is folded in, so the result stays anchored to the token
306
319
  // rather than floating free of it, and stays deterministic across runs.
307
- //
308
- // `rekey` is read only to record that a thumbnail was recovered; the
309
- // derivation below covers it either way.
310
- void rekey;
311
320
  const finalMetadata = overrideTitle ? { ...metadata, title: curatorTitle } : metadata;
312
321
  return {
313
322
  ...manifest,
@@ -505,7 +505,7 @@ async function buildPlaylistDirect(params, options = {}) {
505
505
  console.log(chalk.cyan('\nPublishing to feed server...'));
506
506
  try {
507
507
  const { publishPlaylist } = require('./playlist-publisher');
508
- publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl, playlistSettings.feedServer.apiKey);
508
+ publishResult = await publishPlaylist(savedPath, playlistSettings.feedServer.baseUrl);
509
509
  if (publishResult.success) {
510
510
  console.log(chalk.green(`✓ Published to feed server`));
511
511
  if (publishResult.playlistId) {