fluncle 0.67.0 → 0.69.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.
Files changed (2) hide show
  1. package/bin/fluncle.mjs +302 -22
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -423,7 +423,7 @@ function parseVersion(version) {
423
423
  var currentVersion;
424
424
  var init_version = __esm(() => {
425
425
  init_output();
426
- currentVersion = "0.67.0".trim() ? "0.67.0".trim() : "0.1.0";
426
+ currentVersion = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
427
427
  });
428
428
 
429
429
  // src/update-notifier.ts
@@ -1212,6 +1212,177 @@ var init_mixtape_mixcloud = __esm(() => {
1212
1212
  PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1213
1213
  });
1214
1214
 
1215
+ // src/commands/mixtape-set-video.ts
1216
+ var exports_mixtape_set_video = {};
1217
+ __export(exports_mixtape_set_video, {
1218
+ stageSetVideo: () => stageSetVideo,
1219
+ renditionFfmpegArgs: () => renditionFfmpegArgs,
1220
+ planMultipart: () => planMultipart,
1221
+ buildCompleteXml: () => buildCompleteXml,
1222
+ SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
1223
+ MIN_PART_SIZE: () => MIN_PART_SIZE,
1224
+ MAX_PARTS: () => MAX_PARTS,
1225
+ DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
1226
+ });
1227
+ import { randomUUID } from "node:crypto";
1228
+ import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
1229
+ import { tmpdir } from "node:os";
1230
+ import { join as join4 } from "node:path";
1231
+ function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
1232
+ if (!Number.isInteger(contentLength) || contentLength <= 0) {
1233
+ throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
1234
+ }
1235
+ let effective = Math.max(partSize, MIN_PART_SIZE);
1236
+ if (Math.ceil(contentLength / effective) > MAX_PARTS) {
1237
+ effective = Math.ceil(contentLength / MAX_PARTS);
1238
+ }
1239
+ const parts = [];
1240
+ let start = 0;
1241
+ let partNumber = 1;
1242
+ while (start < contentLength) {
1243
+ const end = Math.min(start + effective, contentLength);
1244
+ parts.push({ end, partNumber, size: end - start, start });
1245
+ start = end;
1246
+ partNumber += 1;
1247
+ }
1248
+ return { partCount: parts.length, partSize: effective, parts };
1249
+ }
1250
+ function escapeXml(value) {
1251
+ return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
1252
+ }
1253
+ function buildCompleteXml(parts) {
1254
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
1255
+ const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
1256
+ return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
1257
+ }
1258
+ function renditionFfmpegArgs(inputPath, outputPath) {
1259
+ return [
1260
+ "-y",
1261
+ "-i",
1262
+ inputPath,
1263
+ "-vf",
1264
+ `scale=-2:${SET_VIDEO_RENDITION.height}`,
1265
+ "-c:v",
1266
+ "libx264",
1267
+ "-preset",
1268
+ "medium",
1269
+ "-crf",
1270
+ String(SET_VIDEO_RENDITION.crf),
1271
+ "-force_key_frames",
1272
+ `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
1273
+ "-pix_fmt",
1274
+ "yuv420p",
1275
+ "-c:a",
1276
+ "aac",
1277
+ "-b:a",
1278
+ SET_VIDEO_RENDITION.audioBitrate,
1279
+ "-movflags",
1280
+ "+faststart",
1281
+ outputPath
1282
+ ];
1283
+ }
1284
+ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1285
+ if (!existsSync(masterPath)) {
1286
+ throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1287
+ }
1288
+ await assertFfmpeg();
1289
+ const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
1290
+ onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
1291
+ await deriveRendition(masterPath, renditionPath);
1292
+ try {
1293
+ const size = statSync2(renditionPath).size;
1294
+ const plan = planMultipart(size);
1295
+ onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1296
+ const presign = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, { partCount: plan.partCount });
1297
+ const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1298
+ const completed = [];
1299
+ try {
1300
+ for (const part of plan.parts) {
1301
+ const url = urlByPart.get(part.partNumber);
1302
+ if (!url) {
1303
+ throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
1304
+ }
1305
+ onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
1306
+ const etag = await putPart(url, renditionPath, part);
1307
+ completed.push({ etag, partNumber: part.partNumber });
1308
+ }
1309
+ await completeUpload(presign.completeUrl, completed);
1310
+ } catch (error) {
1311
+ await abortUpload(presign.abortUrl).catch(() => {});
1312
+ throw error;
1313
+ }
1314
+ await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
1315
+ onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1316
+ return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1317
+ } finally {
1318
+ rmSync2(renditionPath, { force: true });
1319
+ }
1320
+ }
1321
+ async function putPart(url, path2, part) {
1322
+ const response = await fetch(url, {
1323
+ body: Bun.file(path2).slice(part.start, part.end),
1324
+ method: "PUT"
1325
+ });
1326
+ if (!response.ok) {
1327
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
1328
+ throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
1329
+ }
1330
+ const etag = response.headers.get("etag");
1331
+ if (!etag) {
1332
+ throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1333
+ }
1334
+ return etag;
1335
+ }
1336
+ async function completeUpload(url, parts) {
1337
+ const response = await fetch(url, {
1338
+ body: buildCompleteXml(parts),
1339
+ headers: { "content-type": "application/xml" },
1340
+ method: "POST"
1341
+ });
1342
+ const text = await response.text().catch(() => "");
1343
+ if (!response.ok || text.includes("<Error>")) {
1344
+ throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1345
+ }
1346
+ }
1347
+ async function abortUpload(url) {
1348
+ await fetch(url, { method: "DELETE" });
1349
+ }
1350
+ async function assertFfmpeg() {
1351
+ try {
1352
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1353
+ await proc.exited;
1354
+ if (proc.exitCode !== 0) {
1355
+ throw new Error("ffmpeg -version exited non-zero");
1356
+ }
1357
+ } catch {
1358
+ throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1359
+ }
1360
+ }
1361
+ async function deriveRendition(inputPath, outputPath) {
1362
+ const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1363
+ stderr: "pipe",
1364
+ stdout: "ignore"
1365
+ });
1366
+ await proc.exited;
1367
+ if (proc.exitCode !== 0) {
1368
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1369
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1370
+ }
1371
+ }
1372
+ var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1373
+ var init_mixtape_set_video = __esm(() => {
1374
+ init_api();
1375
+ init_output();
1376
+ SET_VIDEO_RENDITION = {
1377
+ audioBitrate: "192k",
1378
+ crf: 20,
1379
+ gopSeconds: 2,
1380
+ height: 1080
1381
+ };
1382
+ DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1383
+ MIN_PART_SIZE = 5 * 1024 * 1024;
1384
+ });
1385
+
1215
1386
  // src/commands/mixtapes.ts
1216
1387
  var exports_mixtapes = {};
1217
1388
  __export(exports_mixtapes, {
@@ -1225,7 +1396,7 @@ __export(exports_mixtapes, {
1225
1396
  mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1226
1397
  mixtapeCreateCommand: () => mixtapeCreateCommand
1227
1398
  });
1228
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1399
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1229
1400
  async function mixtapesCommand() {
1230
1401
  const response = await publicApiGet("/api/mixtapes");
1231
1402
  return response.mixtapes;
@@ -1266,15 +1437,19 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1266
1437
  if (!mixtape.id) {
1267
1438
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1268
1439
  }
1269
- const both = !options.youtube && !options.mixcloud;
1440
+ const both = !options.youtube && !options.mixcloud && !options.setVideo;
1270
1441
  const doYoutube = both || Boolean(options.youtube);
1271
1442
  const doMixcloud = both || Boolean(options.mixcloud);
1443
+ const doSetVideo = Boolean(options.setVideo);
1272
1444
  if (doYoutube && !options.video) {
1273
1445
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1274
1446
  }
1275
1447
  if (doMixcloud && !options.audio) {
1276
1448
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1277
1449
  }
1450
+ if (doSetVideo && !options.video) {
1451
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1452
+ }
1278
1453
  const mixtapeId = mixtape.id;
1279
1454
  let logId = mixtape.logId;
1280
1455
  if (!mixtape.durationMs) {
@@ -1319,6 +1494,16 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1319
1494
  results.push({ platform: "mixcloud", url: result.url });
1320
1495
  onProgress(`Mixcloud: ${result.url}`);
1321
1496
  }
1497
+ if (doSetVideo) {
1498
+ if (!options.video) {
1499
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1500
+ }
1501
+ onProgress("Set video: staging the 1080p rendition…");
1502
+ const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1503
+ const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1504
+ results.push({ platform: "set-video", url: result.url });
1505
+ onProgress(`Set video: ${result.url}`);
1506
+ }
1322
1507
  return { logId, mixtapeId, results };
1323
1508
  }
1324
1509
  async function probeDurationMs(filePath) {
@@ -1353,7 +1538,7 @@ function buildBody(options) {
1353
1538
  return body;
1354
1539
  }
1355
1540
  function parseCueFile(filePath) {
1356
- if (!existsSync(filePath)) {
1541
+ if (!existsSync2(filePath)) {
1357
1542
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1358
1543
  }
1359
1544
  const text = readFileSync2(filePath, "utf-8");
@@ -2138,7 +2323,7 @@ function parseVersion2(version) {
2138
2323
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2139
2324
  var init_version2 = __esm(() => {
2140
2325
  init_output();
2141
- currentVersion2 = "0.67.0".trim() ? "0.67.0".trim() : "0.1.0";
2326
+ currentVersion2 = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
2142
2327
  });
2143
2328
 
2144
2329
  // ../../packages/registry/src/index.ts
@@ -2673,10 +2858,8 @@ var init_src = __esm(() => {
2673
2858
  ],
2674
2859
  kind: "extension",
2675
2860
  name: "extension.lens",
2676
- operatorNotes: "Fluncle Lens (apps/extension), MV3. PENDING Chrome Web Store review DARK until approval, then flip `pending` + set the assigned listing URL. The placeholder `url` is the store search for the listing; probeConfig is the post-approval /status check.",
2677
- pending: true,
2678
- probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
2679
- url: "https://chromewebstore.google.com/search/Fluncle%20Lens",
2861
+ operatorNotes: "Fluncle Lens (apps/extension), MV3, LIVE on the Chrome Web Store (published 2026-06-29). Store listing reachability is Google's, not ours, so it is not on the /status board.",
2862
+ url: "https://chromewebstore.google.com/detail/efkkceaofendabikblfjhoepgejfpakk",
2680
2863
  weights: { web: "secondary" }
2681
2864
  },
2682
2865
  {
@@ -2894,7 +3077,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
2894
3077
  const detail = (await response.text().catch(() => "")).slice(0, 300);
2895
3078
  throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
2896
3079
  }
2897
- urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
3080
+ urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
2898
3081
  }
2899
3082
  const manifest = files.render ? await readManifestFields(files.render) : {};
2900
3083
  const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
@@ -3004,7 +3187,7 @@ async function trackNoteCommand(idOrLogId, options) {
3004
3187
  const body = { note: options.note };
3005
3188
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3006
3189
  }
3007
- var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
3190
+ var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
3008
3191
  var init_track = __esm(() => {
3009
3192
  init_api();
3010
3193
  init_output();
@@ -3162,6 +3345,73 @@ var init_admin_tracks = __esm(() => {
3162
3345
  init_recent2();
3163
3346
  });
3164
3347
 
3348
+ // src/version-match.ts
3349
+ function isRemix(title) {
3350
+ return REMIX_MARKER.test(title);
3351
+ }
3352
+ function stripVersionSuffix(title) {
3353
+ const parts = title.split(/\s+-\s+/);
3354
+ if (parts.length > 1 && VERSION_MARKER.test(parts[parts.length - 1] ?? "")) {
3355
+ return parts.slice(0, -1).join(" - ").trim();
3356
+ }
3357
+ return title.trim();
3358
+ }
3359
+ function tokenize(value) {
3360
+ return value.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean);
3361
+ }
3362
+ function versionTokens(title) {
3363
+ const parts = title.split(/\s+-\s+/);
3364
+ const tail = parts.length > 1 ? parts[parts.length - 1] ?? "" : "";
3365
+ if (parts.length > 1 && VERSION_MARKER.test(tail)) {
3366
+ return new Set(tokenize(tail));
3367
+ }
3368
+ const bracketed = /[([]([^)\]]*?)[)\]]/.exec(title);
3369
+ if (bracketed?.[1] && VERSION_MARKER.test(bracketed[1])) {
3370
+ return new Set(tokenize(bracketed[1]));
3371
+ }
3372
+ return new Set;
3373
+ }
3374
+ function versionMatches(findingTitle, candidateTitle) {
3375
+ const findingIsRemix = isRemix(findingTitle);
3376
+ const candidateIsRemix = isRemix(candidateTitle);
3377
+ if (findingIsRemix) {
3378
+ if (!candidateIsRemix) {
3379
+ return false;
3380
+ }
3381
+ const want = [...versionTokens(findingTitle)].filter((t) => !VERSION_STOPWORDS.has(t));
3382
+ if (want.length === 0) {
3383
+ return true;
3384
+ }
3385
+ const have = versionTokens(candidateTitle);
3386
+ for (const token of want) {
3387
+ if (!have.has(token)) {
3388
+ return false;
3389
+ }
3390
+ }
3391
+ return true;
3392
+ }
3393
+ return !candidateIsRemix;
3394
+ }
3395
+ function baseTitleMatches(findingTitle, candidateTitle) {
3396
+ const want = new Set(tokenize(stripVersionSuffix(findingTitle)));
3397
+ const have = new Set(tokenize(stripVersionSuffix(candidateTitle)));
3398
+ if (want.size === 0) {
3399
+ return false;
3400
+ }
3401
+ for (const token of want) {
3402
+ if (!have.has(token)) {
3403
+ return false;
3404
+ }
3405
+ }
3406
+ return true;
3407
+ }
3408
+ var VERSION_MARKER, REMIX_MARKER, VERSION_STOPWORDS;
3409
+ var init_version_match = __esm(() => {
3410
+ VERSION_MARKER = /\b(mix|edit|version|remix|dub|vip|bootleg|rework|re-?edit|flip|refix|remaster(?:ed)?|instrumental)\b/i;
3411
+ REMIX_MARKER = /\b(remix|bootleg|vip|rework|re-?edit|flip|refix)\b/i;
3412
+ VERSION_STOPWORDS = new Set(["mix", "the", "and", "feat", "ft", "edit", "version", "remix"]);
3413
+ });
3414
+
3165
3415
  // src/commands/preview-archive.ts
3166
3416
  var exports_preview_archive = {};
3167
3417
  __export(exports_preview_archive, {
@@ -3265,16 +3515,22 @@ async function resolveDeezerByIsrc(isrc) {
3265
3515
  return;
3266
3516
  }
3267
3517
  }
3518
+ function durationAgrees(findingMs, candidateSeconds) {
3519
+ if (!findingMs || !candidateSeconds) {
3520
+ return true;
3521
+ }
3522
+ return Math.abs(candidateSeconds - findingMs / 1000) <= DURATION_TOLERANCE_S;
3523
+ }
3268
3524
  async function resolveDeezerSearch(track) {
3269
3525
  const artist = track.artists[0]?.trim();
3270
3526
  if (!artist || !track.title.trim()) {
3271
3527
  return;
3272
3528
  }
3273
3529
  try {
3274
- const query = `artist:"${artist}" track:"${track.title.trim()}"`;
3530
+ const query = `artist:"${artist}" track:"${stripVersionSuffix(track.title.trim())}"`;
3275
3531
  const response = await fetch(`https://api.deezer.com/search?q=${encodeURIComponent(query)}`);
3276
3532
  const body = await response.json();
3277
- const hit = (body.data ?? []).find((item) => item.preview);
3533
+ const hit = (body.data ?? []).find((item) => item.preview && versionMatches(track.title, item.title ?? "") && baseTitleMatches(track.title, item.title ?? "") && durationAgrees(track.durationMs, item.duration));
3278
3534
  return downloadPreview(hit?.preview, "deezer:search");
3279
3535
  } catch {
3280
3536
  return;
@@ -3288,7 +3544,7 @@ async function resolveItunes(track) {
3288
3544
  try {
3289
3545
  const response = await fetch(`https://itunes.apple.com/search?term=${encodeURIComponent(`${artist} ${track.title}`)}&media=music&limit=10`);
3290
3546
  const body = await response.json();
3291
- const hit = (body.results ?? []).find((item) => item.previewUrl && normalize(item.artistName ?? "").includes(normalize(artist)));
3547
+ const hit = (body.results ?? []).find((item) => item.previewUrl && normalize(item.artistName ?? "").includes(normalize(artist)) && versionMatches(track.title, item.trackName ?? "") && baseTitleMatches(track.title, item.trackName ?? "") && durationAgrees(track.durationMs, item.trackTimeMillis ? item.trackTimeMillis / 1000 : undefined));
3292
3548
  return downloadPreview(hit?.previewUrl, "itunes");
3293
3549
  } catch {
3294
3550
  return;
@@ -3350,8 +3606,10 @@ function extensionForMime(mime) {
3350
3606
  function normalize(value) {
3351
3607
  return value.toLowerCase().replace(/[^a-z0-9]/g, "");
3352
3608
  }
3609
+ var DURATION_TOLERANCE_S = 5;
3353
3610
  var init_preview_archive = __esm(() => {
3354
3611
  init_api();
3612
+ init_version_match();
3355
3613
  });
3356
3614
 
3357
3615
  // src/commands/mixtape-youtube.ts
@@ -3362,7 +3620,7 @@ __export(exports_mixtape_youtube2, {
3362
3620
  distributeYoutube: () => distributeYoutube2,
3363
3621
  authYoutubeCommand: () => authYoutubeCommand2
3364
3622
  });
3365
- import { statSync as statSync2 } from "node:fs";
3623
+ import { statSync as statSync3 } from "node:fs";
3366
3624
  async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
3367
3625
  const contentLength = fileSize2(videoPath);
3368
3626
  const contentType = "video/mp4";
@@ -3494,7 +3752,7 @@ async function resolveMixtapeId2(idOrLogId) {
3494
3752
  }
3495
3753
  function fileSize2(path2) {
3496
3754
  try {
3497
- return statSync2(path2).size;
3755
+ return statSync3(path2).size;
3498
3756
  } catch {
3499
3757
  throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
3500
3758
  }
@@ -3520,9 +3778,10 @@ __export(exports_newsletter, {
3520
3778
  newsletterUpdateCommand: () => newsletterUpdateCommand,
3521
3779
  newsletterSendCommand: () => newsletterSendCommand,
3522
3780
  newsletterListCommand: () => newsletterListCommand,
3523
- newsletterDraftCommand: () => newsletterDraftCommand
3781
+ newsletterDraftCommand: () => newsletterDraftCommand,
3782
+ newsletterDeleteCommand: () => newsletterDeleteCommand
3524
3783
  });
3525
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
3784
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3526
3785
  function buildBody2(options, { requireContent }) {
3527
3786
  const body = {};
3528
3787
  if (options.contentFile !== undefined) {
@@ -3542,7 +3801,7 @@ function buildBody2(options, { requireContent }) {
3542
3801
  return body;
3543
3802
  }
3544
3803
  function readContentFile(filePath) {
3545
- if (!existsSync2(filePath)) {
3804
+ if (!existsSync3(filePath)) {
3546
3805
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
3547
3806
  }
3548
3807
  const text = readFileSync3(filePath, "utf-8");
@@ -3565,6 +3824,9 @@ async function newsletterListCommand() {
3565
3824
  const response = await adminApiGet("/api/admin/newsletter/editions");
3566
3825
  return response.editions;
3567
3826
  }
3827
+ async function newsletterDeleteCommand(id) {
3828
+ return adminApiDelete(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`);
3829
+ }
3568
3830
  var init_newsletter = __esm(() => {
3569
3831
  init_api();
3570
3832
  init_output();
@@ -4179,7 +4441,7 @@ var COORD_FALLBACK2 = "—";
4179
4441
  var init_format2 = () => {};
4180
4442
 
4181
4443
  // src/cli.ts
4182
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
4444
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
4183
4445
  import path2 from "path";
4184
4446
 
4185
4447
  // ../../node_modules/commander/lib/error.js
@@ -6539,7 +6801,7 @@ function addAdminCommands(program2) {
6539
6801
  const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6540
6802
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
6541
6803
  });
6542
- adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6804
+ adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--set-video", "Also stage a 1080p set-video rendition (from --video) to R2 + flip setVideoAt for the /log player").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6543
6805
  const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6544
6806
  await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
6545
6807
  });
@@ -6567,6 +6829,10 @@ function addAdminCommands(program2) {
6567
6829
  const { newsletterListCommand: newsletterListCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
6568
6830
  await runNewsletterList(options, newsletterListCommand2);
6569
6831
  });
6832
+ adminNewsletter.command("delete").description("Delete an edition (draft or sent) \u2014 OPERATOR only; reopens the send window").argument("[id]").option("--yes", "Confirm the delete", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
6833
+ const { newsletterDeleteCommand: newsletterDeleteCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
6834
+ await runNewsletterDelete(id, options, newsletterDeleteCommand2);
6835
+ });
6570
6836
  const submissions = configureCommand(admin.command("submissions").description("Review listener submissions"));
6571
6837
  submissions.option("--json", "Print JSON", false).action(async (options) => {
6572
6838
  const { listSubmissionsCommand: listSubmissionsCommand2 } = await Promise.resolve().then(() => (init_submissions(), exports_submissions));
@@ -6848,7 +7114,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
6848
7114
  return;
6849
7115
  }
6850
7116
  const candidate = path2.join(dir, name);
6851
- return existsSync3(candidate) ? candidate : undefined;
7117
+ return existsSync4(candidate) ? candidate : undefined;
6852
7118
  };
6853
7119
  const resolveFile = (explicit, name) => {
6854
7120
  if (explicit) {
@@ -7185,6 +7451,20 @@ async function runNewsletterSend(id, options, newsletterSendCommand2) {
7185
7451
  const number = result.edition.number;
7186
7452
  console.log(number === undefined ? `Sent edition ${result.edition.id}.` : `Sent edition #${number} \u2014 it's out to the list and in the archive.`);
7187
7453
  }
7454
+ async function runNewsletterDelete(id, options, newsletterDeleteCommand2) {
7455
+ if (!id) {
7456
+ throw new Error("Missing edition id. Usage: fluncle admin newsletter delete <id> --yes");
7457
+ }
7458
+ if (!options.yes) {
7459
+ throw new Error("Pass --yes to confirm \u2014 this hard-deletes the edition (draft or sent).");
7460
+ }
7461
+ const result = await newsletterDeleteCommand2(id);
7462
+ if (options.json) {
7463
+ printJson({ ...result, ok: true });
7464
+ return;
7465
+ }
7466
+ console.log(`Deleted edition ${result.id}. The send window reopens to cover its finds.`);
7467
+ }
7188
7468
  async function runNewsletterList(options, newsletterListCommand2) {
7189
7469
  const editions = await newsletterListCommand2();
7190
7470
  if (options.json) {
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.67.0"
34
+ "version": "0.69.0"
35
35
  }