fluncle 0.85.0 → 0.87.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 +303 -600
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -538,7 +538,7 @@ function parseVersion(version) {
538
538
  var currentVersion;
539
539
  var init_version = __esm(() => {
540
540
  init_output();
541
- currentVersion = "0.85.0".trim() ? "0.85.0".trim() : "0.1.0";
541
+ currentVersion = "0.87.0".trim() ? "0.87.0".trim() : "0.1.0";
542
542
  });
543
543
 
544
544
  // src/update-notifier.ts
@@ -1329,186 +1329,6 @@ var init_mixtape_mixcloud = __esm(() => {
1329
1329
  PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1330
1330
  });
1331
1331
 
1332
- // src/commands/mixtape-set-video.ts
1333
- var exports_mixtape_set_video = {};
1334
- __export(exports_mixtape_set_video, {
1335
- uploadRenditionMultipart: () => uploadRenditionMultipart,
1336
- stageSetVideo: () => stageSetVideo,
1337
- renditionFfmpegArgs: () => renditionFfmpegArgs,
1338
- planMultipart: () => planMultipart,
1339
- buildCompleteXml: () => buildCompleteXml,
1340
- SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
1341
- MIN_PART_SIZE: () => MIN_PART_SIZE,
1342
- MAX_PARTS: () => MAX_PARTS,
1343
- DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
1344
- });
1345
- import { randomUUID } from "node:crypto";
1346
- import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
1347
- import { tmpdir } from "node:os";
1348
- import { join as join4 } from "node:path";
1349
- function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
1350
- if (!Number.isInteger(contentLength) || contentLength <= 0) {
1351
- throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
1352
- }
1353
- let effective = Math.max(partSize, MIN_PART_SIZE);
1354
- if (Math.ceil(contentLength / effective) > MAX_PARTS) {
1355
- effective = Math.ceil(contentLength / MAX_PARTS);
1356
- }
1357
- const parts = [];
1358
- let start = 0;
1359
- let partNumber = 1;
1360
- while (start < contentLength) {
1361
- const end = Math.min(start + effective, contentLength);
1362
- parts.push({ end, partNumber, size: end - start, start });
1363
- start = end;
1364
- partNumber += 1;
1365
- }
1366
- return { partCount: parts.length, partSize: effective, parts };
1367
- }
1368
- function escapeXml(value) {
1369
- return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
1370
- }
1371
- function buildCompleteXml(parts) {
1372
- const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
1373
- const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
1374
- return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
1375
- }
1376
- function renditionFfmpegArgs(inputPath, outputPath) {
1377
- return [
1378
- "-y",
1379
- "-i",
1380
- inputPath,
1381
- "-vf",
1382
- `scale=-2:${SET_VIDEO_RENDITION.height}`,
1383
- "-c:v",
1384
- "libx264",
1385
- "-preset",
1386
- "medium",
1387
- "-crf",
1388
- String(SET_VIDEO_RENDITION.crf),
1389
- "-force_key_frames",
1390
- `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
1391
- "-pix_fmt",
1392
- "yuv420p",
1393
- "-c:a",
1394
- "aac",
1395
- "-b:a",
1396
- SET_VIDEO_RENDITION.audioBitrate,
1397
- "-movflags",
1398
- "+faststart",
1399
- outputPath
1400
- ];
1401
- }
1402
- async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
1403
- if (!existsSync(masterPath)) {
1404
- throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1405
- }
1406
- await assertFfmpeg();
1407
- const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
1408
- onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
1409
- await deriveRendition(masterPath, renditionPath);
1410
- try {
1411
- const size = statSync2(renditionPath).size;
1412
- const plan = planMultipart(size);
1413
- onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1414
- const presign = await adminApiPost(presignPath, {
1415
- partCount: plan.partCount
1416
- });
1417
- const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1418
- const completed = [];
1419
- try {
1420
- for (const part of plan.parts) {
1421
- const url = urlByPart.get(part.partNumber);
1422
- if (!url) {
1423
- throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
1424
- }
1425
- onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
1426
- const etag = await putPart(url, renditionPath, part);
1427
- completed.push({ etag, partNumber: part.partNumber });
1428
- }
1429
- await completeUpload(presign.completeUrl, completed);
1430
- } catch (error) {
1431
- await abortUpload(presign.abortUrl).catch(() => {});
1432
- throw error;
1433
- }
1434
- return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1435
- } finally {
1436
- rmSync2(renditionPath, { force: true });
1437
- }
1438
- }
1439
- async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1440
- const result = await uploadRenditionMultipart(masterPath, `/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, onProgress);
1441
- await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, {
1442
- setVideoAt: new Date().toISOString()
1443
- });
1444
- onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1445
- return result;
1446
- }
1447
- async function putPart(url, path2, part) {
1448
- const response = await fetch(url, {
1449
- body: Bun.file(path2).slice(part.start, part.end),
1450
- method: "PUT"
1451
- });
1452
- if (!response.ok) {
1453
- const detail = (await response.text().catch(() => "")).slice(0, 300);
1454
- throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
1455
- }
1456
- const etag = response.headers.get("etag");
1457
- if (!etag) {
1458
- throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1459
- }
1460
- return etag;
1461
- }
1462
- async function completeUpload(url, parts) {
1463
- const response = await fetch(url, {
1464
- body: buildCompleteXml(parts),
1465
- headers: { "content-type": "application/xml" },
1466
- method: "POST"
1467
- });
1468
- const text = await response.text().catch(() => "");
1469
- if (!response.ok || text.includes("<Error>")) {
1470
- throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1471
- }
1472
- }
1473
- async function abortUpload(url) {
1474
- await fetch(url, { method: "DELETE" });
1475
- }
1476
- async function assertFfmpeg() {
1477
- try {
1478
- const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1479
- await proc.exited;
1480
- if (proc.exitCode !== 0) {
1481
- throw new Error("ffmpeg -version exited non-zero");
1482
- }
1483
- } catch {
1484
- throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1485
- }
1486
- }
1487
- async function deriveRendition(inputPath, outputPath) {
1488
- const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1489
- stderr: "pipe",
1490
- stdout: "ignore"
1491
- });
1492
- await proc.exited;
1493
- if (proc.exitCode !== 0) {
1494
- const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1495
- throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1496
- }
1497
- }
1498
- var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1499
- var init_mixtape_set_video = __esm(() => {
1500
- init_api();
1501
- init_output();
1502
- SET_VIDEO_RENDITION = {
1503
- audioBitrate: "192k",
1504
- crf: 20,
1505
- gopSeconds: 2,
1506
- height: 1080
1507
- };
1508
- DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1509
- MIN_PART_SIZE = 5 * 1024 * 1024;
1510
- });
1511
-
1512
1332
  // src/commands/mixtapes.ts
1513
1333
  var exports_mixtapes = {};
1514
1334
  __export(exports_mixtapes, {
@@ -1523,7 +1343,7 @@ __export(exports_mixtapes, {
1523
1343
  mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1524
1344
  mixtapeCreateCommand: () => mixtapeCreateCommand
1525
1345
  });
1526
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1346
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1527
1347
  async function mixtapesCommand() {
1528
1348
  const response = await publicApiGet("/api/mixtapes");
1529
1349
  return response.mixtapes;
@@ -1552,21 +1372,24 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1552
1372
  if (!mixtape.id) {
1553
1373
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1554
1374
  }
1555
- const both = !options.youtube && !options.mixcloud && !options.setVideo;
1375
+ if (mixtape.status === "draft") {
1376
+ throw new CliError2("mixtape_not_promoted", `${mixtape.id} is still a draft — promote its recording first:
1377
+ ` + " fluncle admin recordings promote <recordingId>");
1378
+ }
1379
+ const both = !options.youtube && !options.mixcloud;
1556
1380
  const doYoutube = both || Boolean(options.youtube);
1557
1381
  const doMixcloud = both || Boolean(options.mixcloud);
1558
- const doSetVideo = Boolean(options.setVideo);
1559
1382
  if (doYoutube && !options.video) {
1560
1383
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1561
1384
  }
1562
1385
  if (doMixcloud && !options.audio) {
1563
1386
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1564
1387
  }
1565
- if (doSetVideo && !options.video) {
1566
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1567
- }
1568
1388
  const mixtapeId = mixtape.id;
1569
- let logId = mixtape.logId;
1389
+ const logId = mixtape.logId;
1390
+ if (!logId) {
1391
+ throw new CliError2("missing_log_id", "The mixtape has no Log ID — was it promoted successfully?");
1392
+ }
1570
1393
  if (!mixtape.durationMs) {
1571
1394
  const source = options.audio ?? options.video;
1572
1395
  const durationMs = source ? await probeDurationMs(source) : undefined;
@@ -1575,18 +1398,10 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1575
1398
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1576
1399
  }
1577
1400
  }
1578
- if (mixtape.status === "draft") {
1579
- onProgress("Minting the coordinate…");
1580
- const published = await mixtapePublishCommand(mixtapeId);
1581
- logId = published.mixtape.logId;
1582
- onProgress(`Minted ${logId}.`);
1583
- } else if (mixtape.status === "published") {
1401
+ if (mixtape.status === "published") {
1584
1402
  onProgress(`Already published (${logId}); re-distributing.`);
1585
1403
  } else {
1586
- onProgress(`Resuming distribution for ${logId}.`);
1587
- }
1588
- if (!logId) {
1589
- throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
1404
+ onProgress(`Distributing ${logId} (promoted — coordinate already minted).`);
1590
1405
  }
1591
1406
  const results = [];
1592
1407
  if (doYoutube) {
@@ -1609,16 +1424,6 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1609
1424
  results.push({ platform: "mixcloud", url: result.url });
1610
1425
  onProgress(`Mixcloud: ${result.url}`);
1611
1426
  }
1612
- if (doSetVideo) {
1613
- if (!options.video) {
1614
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1615
- }
1616
- onProgress("Set video: staging the 1080p rendition…");
1617
- const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1618
- const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1619
- results.push({ platform: "set-video", url: result.url });
1620
- onProgress(`Set video: ${result.url}`);
1621
- }
1622
1427
  return { logId, mixtapeId, results };
1623
1428
  }
1624
1429
  async function mixtapeResyncCommand(idOrLogId, options, onProgress = () => {}) {
@@ -1691,7 +1496,7 @@ function buildBody(options) {
1691
1496
  return body;
1692
1497
  }
1693
1498
  function parseCueFile(filePath) {
1694
- if (!existsSync2(filePath)) {
1499
+ if (!existsSync(filePath)) {
1695
1500
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1696
1501
  }
1697
1502
  const text = readFileSync2(filePath, "utf-8");
@@ -2446,7 +2251,7 @@ function parseVersion2(version) {
2446
2251
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2447
2252
  var init_version2 = __esm(() => {
2448
2253
  init_output();
2449
- currentVersion2 = "0.85.0".trim() ? "0.85.0".trim() : "0.1.0";
2254
+ currentVersion2 = "0.87.0".trim() ? "0.87.0".trim() : "0.1.0";
2450
2255
  });
2451
2256
 
2452
2257
  // ../../packages/registry/src/index.ts
@@ -3200,7 +3005,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
3200
3005
  const detail = (await response.text().catch(() => "")).slice(0, 300);
3201
3006
  throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
3202
3007
  }
3203
- urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
3008
+ urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
3204
3009
  }
3205
3010
  const manifest = files.render ? await readManifestFields(files.render) : {};
3206
3011
  const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
@@ -3311,7 +3116,7 @@ async function trackNoteCommand(idOrLogId, options) {
3311
3116
  const body = { note: options.note };
3312
3117
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3313
3118
  }
3314
- var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
3119
+ var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
3315
3120
  var init_track = __esm(() => {
3316
3121
  init_api();
3317
3122
  init_output();
@@ -3705,7 +3510,7 @@ __export(exports_mixtape_youtube2, {
3705
3510
  distributeYoutube: () => distributeYoutube2,
3706
3511
  authYoutubeCommand: () => authYoutubeCommand2
3707
3512
  });
3708
- import { statSync as statSync3 } from "node:fs";
3513
+ import { statSync as statSync2 } from "node:fs";
3709
3514
  async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
3710
3515
  const contentLength = fileSize2(videoPath);
3711
3516
  const contentType = "video/mp4";
@@ -3841,7 +3646,7 @@ async function resolveMixtapeId2(idOrLogId) {
3841
3646
  }
3842
3647
  function fileSize2(path2) {
3843
3648
  try {
3844
- return statSync3(path2).size;
3649
+ return statSync2(path2).size;
3845
3650
  } catch {
3846
3651
  throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
3847
3652
  }
@@ -3861,75 +3666,235 @@ var init_mixtape_youtube2 = __esm(() => {
3861
3666
  init_output();
3862
3667
  });
3863
3668
 
3864
- // src/commands/recordings.ts
3865
- var exports_recordings = {};
3866
- __export(exports_recordings, {
3867
- recordingsListCommand: () => recordingsListCommand,
3868
- recordingUpdateCommand: () => recordingUpdateCommand,
3869
- recordingReplaceCuesCommand: () => recordingReplaceCuesCommand,
3870
- recordingPromoteCommand: () => recordingPromoteCommand,
3871
- recordingGetCommand: () => recordingGetCommand,
3872
- recordingGet: () => recordingGet,
3873
- recordingDeleteCommand: () => recordingDeleteCommand,
3874
- recordingCreateCommand: () => recordingCreateCommand
3875
- });
3876
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3877
- async function recordingGet(id) {
3878
- const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
3879
- return response.recording;
3880
- }
3881
- function formatRecordingSummary(recording) {
3882
- const kind = recording.hasVideo ? `take v${recording.version}` : "plan";
3883
- const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
3884
- const cues = recording.tracklist.length;
3885
- return `${recording.id} [${kind}] ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
3886
- }
3887
- async function recordingsListCommand(options = {}) {
3888
- const query = new URLSearchParams;
3889
- if (options.kind) {
3890
- query.set("kind", options.kind);
3891
- }
3892
- if (options.parentId) {
3893
- query.set("parentId", options.parentId);
3669
+ // src/commands/mixtape-set-video.ts
3670
+ import { randomUUID } from "node:crypto";
3671
+ import { existsSync as existsSync2, rmSync as rmSync2, statSync as statSync3 } from "node:fs";
3672
+ import { tmpdir } from "node:os";
3673
+ import { join as join4 } from "node:path";
3674
+ function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
3675
+ if (!Number.isInteger(contentLength) || contentLength <= 0) {
3676
+ throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
3894
3677
  }
3895
- const suffix = query.toString() ? `?${query.toString()}` : "";
3896
- const response = await adminApiGet(`/api/admin/recordings${suffix}`);
3897
- if (options.json) {
3898
- printJson2({ ok: true, recordings: response.recordings });
3899
- return;
3678
+ let effective = Math.max(partSize, MIN_PART_SIZE);
3679
+ if (Math.ceil(contentLength / effective) > MAX_PARTS) {
3680
+ effective = Math.ceil(contentLength / MAX_PARTS);
3900
3681
  }
3901
- if (response.recordings.length === 0) {
3902
- console.log("No recordings yet.");
3903
- return;
3682
+ const parts = [];
3683
+ let start = 0;
3684
+ let partNumber = 1;
3685
+ while (start < contentLength) {
3686
+ const end = Math.min(start + effective, contentLength);
3687
+ parts.push({ end, partNumber, size: end - start, start });
3688
+ start = end;
3689
+ partNumber += 1;
3904
3690
  }
3905
- console.log(response.recordings.map(formatRecordingSummary).join(`
3906
- `));
3691
+ return { partCount: parts.length, partSize: effective, parts };
3907
3692
  }
3908
- async function recordingGetCommand(id, options = {}) {
3909
- if (!id) {
3910
- throw new CliError2("missing_id", "Missing recording id for: get");
3911
- }
3912
- const recording = await recordingGet(id);
3913
- if (options.json) {
3914
- printJson2({ ok: true, recording });
3915
- return;
3916
- }
3917
- console.log(formatRecordingSummary(recording));
3918
- console.log(` r2Key: ${recording.r2Key ?? "— (no set video)"}`);
3919
- for (const cue of recording.tracklist) {
3920
- const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
3921
- console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
3922
- }
3693
+ function escapeXml(value) {
3694
+ return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
3923
3695
  }
3924
- async function recordingCreateCommand(options = {}) {
3925
- if (options.plan) {
3926
- const created2 = await adminApiPost("/api/admin/recordings", {
3927
- kind: "plan",
3928
- recordedAt: options.recordedAt
3929
- });
3930
- const recording2 = created2.recording;
3931
- if (options.json) {
3932
- printJson2({ ok: true, recording: recording2 });
3696
+ function buildCompleteXml(parts) {
3697
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
3698
+ const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
3699
+ return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
3700
+ }
3701
+ function renditionFfmpegArgs(inputPath, outputPath) {
3702
+ return [
3703
+ "-y",
3704
+ "-i",
3705
+ inputPath,
3706
+ "-vf",
3707
+ `scale=-2:${SET_VIDEO_RENDITION.height}`,
3708
+ "-c:v",
3709
+ "libx264",
3710
+ "-preset",
3711
+ "medium",
3712
+ "-crf",
3713
+ String(SET_VIDEO_RENDITION.crf),
3714
+ "-force_key_frames",
3715
+ `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
3716
+ "-pix_fmt",
3717
+ "yuv420p",
3718
+ "-c:a",
3719
+ "aac",
3720
+ "-b:a",
3721
+ SET_VIDEO_RENDITION.audioBitrate,
3722
+ "-movflags",
3723
+ "+faststart",
3724
+ outputPath
3725
+ ];
3726
+ }
3727
+ async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
3728
+ if (!existsSync2(masterPath)) {
3729
+ throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
3730
+ }
3731
+ await assertFfmpeg();
3732
+ const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
3733
+ onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
3734
+ await deriveRendition(masterPath, renditionPath);
3735
+ try {
3736
+ const size = statSync3(renditionPath).size;
3737
+ const plan = planMultipart(size);
3738
+ onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
3739
+ const presign = await adminApiPost(presignPath, {
3740
+ partCount: plan.partCount
3741
+ });
3742
+ const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
3743
+ const completed = [];
3744
+ try {
3745
+ for (const part of plan.parts) {
3746
+ const url = urlByPart.get(part.partNumber);
3747
+ if (!url) {
3748
+ throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
3749
+ }
3750
+ onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
3751
+ const etag = await putPart(url, renditionPath, part);
3752
+ completed.push({ etag, partNumber: part.partNumber });
3753
+ }
3754
+ await completeUpload(presign.completeUrl, completed);
3755
+ } catch (error) {
3756
+ await abortUpload(presign.abortUrl).catch(() => {});
3757
+ throw error;
3758
+ }
3759
+ return { key: presign.key, url: `${FOUND_BASE2}/${presign.key}` };
3760
+ } finally {
3761
+ rmSync2(renditionPath, { force: true });
3762
+ }
3763
+ }
3764
+ async function putPart(url, path2, part) {
3765
+ const response = await fetch(url, {
3766
+ body: Bun.file(path2).slice(part.start, part.end),
3767
+ method: "PUT"
3768
+ });
3769
+ if (!response.ok) {
3770
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
3771
+ throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
3772
+ }
3773
+ const etag = response.headers.get("etag");
3774
+ if (!etag) {
3775
+ throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
3776
+ }
3777
+ return etag;
3778
+ }
3779
+ async function completeUpload(url, parts) {
3780
+ const response = await fetch(url, {
3781
+ body: buildCompleteXml(parts),
3782
+ headers: { "content-type": "application/xml" },
3783
+ method: "POST"
3784
+ });
3785
+ const text = await response.text().catch(() => "");
3786
+ if (!response.ok || text.includes("<Error>")) {
3787
+ throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
3788
+ }
3789
+ }
3790
+ async function abortUpload(url) {
3791
+ await fetch(url, { method: "DELETE" });
3792
+ }
3793
+ async function assertFfmpeg() {
3794
+ try {
3795
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
3796
+ await proc.exited;
3797
+ if (proc.exitCode !== 0) {
3798
+ throw new Error("ffmpeg -version exited non-zero");
3799
+ }
3800
+ } catch {
3801
+ throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
3802
+ }
3803
+ }
3804
+ async function deriveRendition(inputPath, outputPath) {
3805
+ const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
3806
+ stderr: "pipe",
3807
+ stdout: "ignore"
3808
+ });
3809
+ await proc.exited;
3810
+ if (proc.exitCode !== 0) {
3811
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
3812
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
3813
+ }
3814
+ }
3815
+ var FOUND_BASE2 = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
3816
+ var init_mixtape_set_video = __esm(() => {
3817
+ init_api();
3818
+ init_output();
3819
+ SET_VIDEO_RENDITION = {
3820
+ audioBitrate: "192k",
3821
+ crf: 20,
3822
+ gopSeconds: 2,
3823
+ height: 1080
3824
+ };
3825
+ DEFAULT_PART_SIZE = 100 * 1024 * 1024;
3826
+ MIN_PART_SIZE = 5 * 1024 * 1024;
3827
+ });
3828
+
3829
+ // src/commands/recordings.ts
3830
+ var exports_recordings = {};
3831
+ __export(exports_recordings, {
3832
+ recordingsListCommand: () => recordingsListCommand,
3833
+ recordingUpdateCommand: () => recordingUpdateCommand,
3834
+ recordingReplaceCuesCommand: () => recordingReplaceCuesCommand,
3835
+ recordingPromoteCommand: () => recordingPromoteCommand,
3836
+ recordingGetCommand: () => recordingGetCommand,
3837
+ recordingGet: () => recordingGet,
3838
+ recordingDeleteCommand: () => recordingDeleteCommand,
3839
+ recordingCreateCommand: () => recordingCreateCommand
3840
+ });
3841
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3842
+ async function recordingGet(id) {
3843
+ const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
3844
+ return response.recording;
3845
+ }
3846
+ function formatRecordingSummary(recording) {
3847
+ const kind = recording.hasVideo ? `take v${recording.version}` : "plan";
3848
+ const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
3849
+ const cues = recording.tracklist.length;
3850
+ return `${recording.id} [${kind}] ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
3851
+ }
3852
+ async function recordingsListCommand(options = {}) {
3853
+ const query = new URLSearchParams;
3854
+ if (options.kind) {
3855
+ query.set("kind", options.kind);
3856
+ }
3857
+ if (options.parentId) {
3858
+ query.set("parentId", options.parentId);
3859
+ }
3860
+ const suffix = query.toString() ? `?${query.toString()}` : "";
3861
+ const response = await adminApiGet(`/api/admin/recordings${suffix}`);
3862
+ if (options.json) {
3863
+ printJson2({ ok: true, recordings: response.recordings });
3864
+ return;
3865
+ }
3866
+ if (response.recordings.length === 0) {
3867
+ console.log("No recordings yet.");
3868
+ return;
3869
+ }
3870
+ console.log(response.recordings.map(formatRecordingSummary).join(`
3871
+ `));
3872
+ }
3873
+ async function recordingGetCommand(id, options = {}) {
3874
+ if (!id) {
3875
+ throw new CliError2("missing_id", "Missing recording id for: get");
3876
+ }
3877
+ const recording = await recordingGet(id);
3878
+ if (options.json) {
3879
+ printJson2({ ok: true, recording });
3880
+ return;
3881
+ }
3882
+ console.log(formatRecordingSummary(recording));
3883
+ console.log(` r2Key: ${recording.r2Key ?? "— (no set video)"}`);
3884
+ for (const cue of recording.tracklist) {
3885
+ const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
3886
+ console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
3887
+ }
3888
+ }
3889
+ async function recordingCreateCommand(options = {}) {
3890
+ if (options.plan) {
3891
+ const created2 = await adminApiPost("/api/admin/recordings", {
3892
+ kind: "plan",
3893
+ recordedAt: options.recordedAt
3894
+ });
3895
+ const recording2 = created2.recording;
3896
+ if (options.json) {
3897
+ printJson2({ ok: true, recording: recording2 });
3933
3898
  return;
3934
3899
  }
3935
3900
  console.log(`Plan ${recording2.id} created — handle "${recording2.title}"`);
@@ -4049,253 +4014,6 @@ var init_recordings = __esm(() => {
4049
4014
  init_mixtape_set_video();
4050
4015
  });
4051
4016
 
4052
- // src/commands/mixtapes.ts
4053
- var exports_mixtapes2 = {};
4054
- __export(exports_mixtapes2, {
4055
- mixtapesCommand: () => mixtapesCommand2,
4056
- mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
4057
- mixtapeResyncCommand: () => mixtapeResyncCommand2,
4058
- mixtapePublishCommand: () => mixtapePublishCommand2,
4059
- mixtapeMembersCommand: () => mixtapeMembersCommand2,
4060
- mixtapeListCommand: () => mixtapeListCommand,
4061
- mixtapeGetCommand: () => mixtapeGetCommand,
4062
- mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
4063
- mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
4064
- mixtapeCreateCommand: () => mixtapeCreateCommand2
4065
- });
4066
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
4067
- async function mixtapesCommand2() {
4068
- const response = await publicApiGet("/api/mixtapes");
4069
- return response.mixtapes;
4070
- }
4071
- async function mixtapeCreateCommand2(options) {
4072
- return adminApiPost("/api/admin/mixtapes", buildBody2(options));
4073
- }
4074
- async function mixtapeUpdateCommand2(id, options) {
4075
- return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
4076
- }
4077
- async function mixtapeMembersCommand2(id, refs, options) {
4078
- const members = refs.map((ref) => ({ ref }));
4079
- if (options.from) {
4080
- members.push(...parseCueFile2(options.from));
4081
- }
4082
- return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
4083
- }
4084
- async function mixtapePublishCommand2(id) {
4085
- return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
4086
- }
4087
- async function mixtapeDeleteCommand2(id) {
4088
- return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
4089
- }
4090
- async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
4091
- const mixtape = await mixtapeGetCommand(idOrLogId);
4092
- if (!mixtape.id) {
4093
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
4094
- }
4095
- const both = !options.youtube && !options.mixcloud && !options.setVideo;
4096
- const doYoutube = both || Boolean(options.youtube);
4097
- const doMixcloud = both || Boolean(options.mixcloud);
4098
- const doSetVideo = Boolean(options.setVideo);
4099
- if (doYoutube && !options.video) {
4100
- throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
4101
- }
4102
- if (doMixcloud && !options.audio) {
4103
- throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
4104
- }
4105
- if (doSetVideo && !options.video) {
4106
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
4107
- }
4108
- const mixtapeId = mixtape.id;
4109
- let logId = mixtape.logId;
4110
- if (!mixtape.durationMs) {
4111
- const source = options.audio ?? options.video;
4112
- const durationMs = source ? await probeDurationMs2(source) : undefined;
4113
- if (durationMs) {
4114
- await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
4115
- onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
4116
- }
4117
- }
4118
- if (mixtape.status === "draft") {
4119
- onProgress("Minting the coordinate…");
4120
- const published = await mixtapePublishCommand2(mixtapeId);
4121
- logId = published.mixtape.logId;
4122
- onProgress(`Minted ${logId}.`);
4123
- } else if (mixtape.status === "published") {
4124
- onProgress(`Already published (${logId}); re-distributing.`);
4125
- } else {
4126
- onProgress(`Resuming distribution for ${logId}.`);
4127
- }
4128
- if (!logId) {
4129
- throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
4130
- }
4131
- const results = [];
4132
- if (doYoutube) {
4133
- if (!options.video) {
4134
- throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
4135
- }
4136
- onProgress("YouTube: uploading video…");
4137
- const { distributeYoutube: distributeYoutube3 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
4138
- const result = await distributeYoutube3(mixtapeId, options.video, onProgress);
4139
- results.push({ platform: "youtube", url: result.url });
4140
- onProgress(`YouTube: ${result.url}`);
4141
- }
4142
- if (doMixcloud) {
4143
- if (!options.audio) {
4144
- throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
4145
- }
4146
- onProgress("Mixcloud: uploading audio…");
4147
- const { distributeMixcloud: distributeMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
4148
- const result = await distributeMixcloud2(mixtapeId, options.audio, onProgress, options.unlisted);
4149
- results.push({ platform: "mixcloud", url: result.url });
4150
- onProgress(`Mixcloud: ${result.url}`);
4151
- }
4152
- if (doSetVideo) {
4153
- if (!options.video) {
4154
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
4155
- }
4156
- onProgress("Set video: staging the 1080p rendition…");
4157
- const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
4158
- const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
4159
- results.push({ platform: "set-video", url: result.url });
4160
- onProgress(`Set video: ${result.url}`);
4161
- }
4162
- return { logId, mixtapeId, results };
4163
- }
4164
- async function mixtapeResyncCommand2(idOrLogId, options, onProgress = () => {}) {
4165
- const mixtape = await mixtapeGetCommand(idOrLogId);
4166
- if (!mixtape.id) {
4167
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
4168
- }
4169
- if (!mixtape.logId) {
4170
- throw new CliError2("mixtape_no_log_id", "The mixtape isn't published yet — distribute it before re-syncing.");
4171
- }
4172
- const mixtapeId = mixtape.id;
4173
- const explicit = Boolean(options.youtube) || Boolean(options.mixcloud);
4174
- let doYoutube = Boolean(options.youtube);
4175
- let doMixcloud = Boolean(options.mixcloud);
4176
- if (!explicit) {
4177
- const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
4178
- const platforms = new Set(social.posts.map((post) => post.platform));
4179
- doYoutube = platforms.has("youtube");
4180
- doMixcloud = platforms.has("mixcloud");
4181
- if (!doYoutube && !doMixcloud) {
4182
- throw new CliError2("mixtape_not_distributed", "The mixtape has no YouTube or Mixcloud link to re-sync.");
4183
- }
4184
- }
4185
- const results = [];
4186
- if (doYoutube) {
4187
- onProgress("YouTube: re-syncing description + chapters…");
4188
- const { resyncYoutube: resyncYoutube3 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
4189
- const result = await resyncYoutube3(mixtapeId);
4190
- results.push({ platform: "youtube", url: result.url });
4191
- onProgress(`YouTube: ${result.url}`);
4192
- }
4193
- if (doMixcloud) {
4194
- onProgress("Mixcloud: re-syncing sections…");
4195
- const { resyncMixcloud: resyncMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
4196
- const result = await resyncMixcloud2(mixtapeId);
4197
- results.push({ platform: "mixcloud", url: result.url });
4198
- onProgress(`Mixcloud: ${result.url}`);
4199
- }
4200
- return { logId: mixtape.logId, mixtapeId, results };
4201
- }
4202
- async function probeDurationMs2(filePath) {
4203
- try {
4204
- const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
4205
- const out = await new Response(proc.stdout).text();
4206
- await proc.exited;
4207
- const seconds = Number.parseFloat(out.trim());
4208
- return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
4209
- } catch {
4210
- return;
4211
- }
4212
- }
4213
- function buildBody2(options) {
4214
- const body = {};
4215
- if (options.note !== undefined) {
4216
- body.note = options.note;
4217
- }
4218
- if (options.recordedAt !== undefined) {
4219
- body.recordedAt = options.recordedAt;
4220
- }
4221
- if (options.soundcloudUrl !== undefined) {
4222
- body.soundcloudUrl = options.soundcloudUrl;
4223
- }
4224
- if (options.durationMs !== undefined) {
4225
- const parsed = parseDuration(options.durationMs);
4226
- if (parsed === null) {
4227
- throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
4228
- }
4229
- body.durationMs = parsed;
4230
- }
4231
- return body;
4232
- }
4233
- function parseCueFile2(filePath) {
4234
- if (!existsSync4(filePath)) {
4235
- throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
4236
- }
4237
- const text = readFileSync4(filePath, "utf-8");
4238
- const trimmed = text.trim();
4239
- if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
4240
- try {
4241
- const parsed = JSON.parse(trimmed);
4242
- if (!Array.isArray(parsed)) {
4243
- throw new Error("not an array");
4244
- }
4245
- return parsed.map((entry, index) => {
4246
- if (typeof entry === "string") {
4247
- return { ref: entry.trim() };
4248
- }
4249
- const obj = entry;
4250
- if (typeof obj?.ref !== "string") {
4251
- throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
4252
- }
4253
- const cue = { ref: obj.ref.trim() };
4254
- if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
4255
- cue.startMs = obj.startMs;
4256
- }
4257
- return cue;
4258
- });
4259
- } catch (error) {
4260
- if (error instanceof CliError2) {
4261
- throw error;
4262
- }
4263
- throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
4264
- }
4265
- }
4266
- return parseCueSheet2(text);
4267
- }
4268
- function parseCueSheet2(text) {
4269
- const entries = [];
4270
- for (const line of text.split(/\r?\n/)) {
4271
- const trimmed = line.trim();
4272
- if (!trimmed || trimmed.startsWith("#")) {
4273
- continue;
4274
- }
4275
- const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
4276
- if (match) {
4277
- const [, time, ref] = match;
4278
- if (time === undefined || ref === undefined) {
4279
- continue;
4280
- }
4281
- const startMs = parseDuration(time);
4282
- if (startMs === null) {
4283
- continue;
4284
- }
4285
- entries.push({ ref: ref.trim(), startMs });
4286
- } else {
4287
- entries.push({ ref: trimmed });
4288
- }
4289
- }
4290
- return entries;
4291
- }
4292
- var init_mixtapes2 = __esm(() => {
4293
- init_util();
4294
- init_api();
4295
- init_mixtape_api();
4296
- init_output();
4297
- });
4298
-
4299
4017
  // src/commands/clips.ts
4300
4018
  var exports_clips = {};
4301
4019
  __export(exports_clips, {
@@ -4371,9 +4089,6 @@ async function clipsListCommand(filter = {}) {
4371
4089
  if (filter.recordingId) {
4372
4090
  params.set("recordingId", filter.recordingId);
4373
4091
  }
4374
- if (filter.mixtapeId) {
4375
- params.set("mixtapeId", filter.mixtapeId);
4376
- }
4377
4092
  if (filter.status) {
4378
4093
  params.set("status", filter.status);
4379
4094
  }
@@ -4382,28 +4097,17 @@ async function clipsListCommand(filter = {}) {
4382
4097
  return response.clips;
4383
4098
  }
4384
4099
  async function resolveClipSource(clip) {
4385
- if (clip.recordingId) {
4386
- const { recordingGet: recordingGet2 } = await Promise.resolve().then(() => (init_recordings(), exports_recordings));
4387
- const recording = await recordingGet2(clip.recordingId);
4388
- if (!recording.r2Key) {
4389
- throw new CliError2("recording_not_staged", `Recording ${clip.recordingId} has no staged set video`);
4390
- }
4391
- return {
4392
- setUrl: `${FOUND_BASE3}/${recording.r2Key.split("/").map(encodeURIComponent).join("/")}`
4393
- };
4394
- }
4395
- if (!clip.mixtapeId) {
4396
- throw new CliError2("clip_unlinked", `Clip ${clip.id} is linked to neither a recording nor a mixtape`);
4397
- }
4398
- const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
4399
- const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
4400
- if (!mixtape.logId) {
4401
- throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
4100
+ if (!clip.recordingId) {
4101
+ throw new CliError2("clip_unlinked", `Clip ${clip.id} is linked to no recording`);
4402
4102
  }
4403
- if (!mixtape.setVideoAt) {
4404
- throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4103
+ const { recordingGet: recordingGet2 } = await Promise.resolve().then(() => (init_recordings(), exports_recordings));
4104
+ const recording = await recordingGet2(clip.recordingId);
4105
+ if (!recording.r2Key) {
4106
+ throw new CliError2("recording_not_staged", `Recording ${clip.recordingId} has no staged set video`);
4405
4107
  }
4406
- return { setUrl: setVideoUrl(mixtape.logId) };
4108
+ return {
4109
+ setUrl: `${FOUND_BASE3}/${recording.r2Key.split("/").map(encodeURIComponent).join("/")}`
4110
+ };
4407
4111
  }
4408
4112
  async function clipCutCommand(clipId, onProgress = () => {}) {
4409
4113
  const clips = await clipsListCommand();
@@ -4497,7 +4201,7 @@ __export(exports_recordings2, {
4497
4201
  recordingDeleteCommand: () => recordingDeleteCommand2,
4498
4202
  recordingCreateCommand: () => recordingCreateCommand2
4499
4203
  });
4500
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
4204
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
4501
4205
  async function recordingGet2(id) {
4502
4206
  const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
4503
4207
  return response.recording;
@@ -4566,7 +4270,7 @@ async function recordingCreateCommand2(options = {}) {
4566
4270
  if (!options.video) {
4567
4271
  throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
4568
4272
  }
4569
- if (!existsSync5(options.video)) {
4273
+ if (!existsSync4(options.video)) {
4570
4274
  throw new CliError2("file_not_found", `Set-video master not found: ${options.video}`);
4571
4275
  }
4572
4276
  const created = await adminApiPost("/api/admin/recordings", {
@@ -4602,11 +4306,11 @@ async function recordingUpdateCommand2(id, options = {}) {
4602
4306
  body.parentId = options.parentId;
4603
4307
  }
4604
4308
  if (options.tracklistFile !== undefined) {
4605
- if (!existsSync5(options.tracklistFile)) {
4309
+ if (!existsSync4(options.tracklistFile)) {
4606
4310
  throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
4607
4311
  }
4608
4312
  try {
4609
- body.tracklistJson = JSON.parse(readFileSync5(options.tracklistFile, "utf8"));
4313
+ body.tracklistJson = JSON.parse(readFileSync4(options.tracklistFile, "utf8"));
4610
4314
  } catch {
4611
4315
  throw new CliError2("invalid_tracklist", `Tracklist file is not valid JSON: ${options.tracklistFile}`);
4612
4316
  }
@@ -4651,12 +4355,12 @@ async function recordingReplaceCuesCommand2(id, options = {}) {
4651
4355
  if (!options.cuesFile) {
4652
4356
  throw new CliError2("missing_cues_file", "replace-cues needs a --cues-file <file> (the ordered cue array)");
4653
4357
  }
4654
- if (!existsSync5(options.cuesFile)) {
4358
+ if (!existsSync4(options.cuesFile)) {
4655
4359
  throw new CliError2("file_not_found", `Cues file not found: ${options.cuesFile}`);
4656
4360
  }
4657
4361
  let cues;
4658
4362
  try {
4659
- cues = JSON.parse(readFileSync5(options.cuesFile, "utf8"));
4363
+ cues = JSON.parse(readFileSync4(options.cuesFile, "utf8"));
4660
4364
  } catch {
4661
4365
  throw new CliError2("invalid_cues", `Cues file is not valid JSON: ${options.cuesFile}`);
4662
4366
  }
@@ -4682,8 +4386,8 @@ __export(exports_newsletter, {
4682
4386
  newsletterDraftCommand: () => newsletterDraftCommand,
4683
4387
  newsletterDeleteCommand: () => newsletterDeleteCommand
4684
4388
  });
4685
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
4686
- function buildBody3(options, { requireContent }) {
4389
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
4390
+ function buildBody2(options, { requireContent }) {
4687
4391
  const body = {};
4688
4392
  if (options.contentFile !== undefined) {
4689
4393
  body.contentJson = readContentFile(options.contentFile);
@@ -4702,10 +4406,10 @@ function buildBody3(options, { requireContent }) {
4702
4406
  return body;
4703
4407
  }
4704
4408
  function readContentFile(filePath) {
4705
- if (!existsSync6(filePath)) {
4409
+ if (!existsSync5(filePath)) {
4706
4410
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
4707
4411
  }
4708
- const text = readFileSync6(filePath, "utf-8");
4412
+ const text = readFileSync5(filePath, "utf-8");
4709
4413
  try {
4710
4414
  return JSON.parse(text);
4711
4415
  } catch (error) {
@@ -4713,10 +4417,10 @@ function readContentFile(filePath) {
4713
4417
  }
4714
4418
  }
4715
4419
  async function newsletterDraftCommand(options) {
4716
- return adminApiPost("/api/admin/newsletter/editions", buildBody3(options, { requireContent: true }));
4420
+ return adminApiPost("/api/admin/newsletter/editions", buildBody2(options, { requireContent: true }));
4717
4421
  }
4718
4422
  async function newsletterUpdateCommand(id, options) {
4719
- return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`, buildBody3(options, { requireContent: false }));
4423
+ return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`, buildBody2(options, { requireContent: false }));
4720
4424
  }
4721
4425
  async function newsletterSendCommand(id) {
4722
4426
  return adminApiPost(`/api/admin/newsletter/editions/${encodeURIComponent(id)}/send`);
@@ -5340,7 +5044,7 @@ var init_format2 = __esm(() => {
5340
5044
  });
5341
5045
 
5342
5046
  // src/cli.ts
5343
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
5047
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
5344
5048
  import path2 from "path";
5345
5049
 
5346
5050
  // ../../node_modules/commander/lib/error.js
@@ -7506,8 +7210,8 @@ function addListenCommands(program2) {
7506
7210
  await runRecent(options, recentCommand3);
7507
7211
  });
7508
7212
  program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
7509
- const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7510
- await runMixtapes(options, mixtapesCommand3);
7213
+ const { mixtapesCommand: mixtapesCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7214
+ await runMixtapes(options, mixtapesCommand2);
7511
7215
  });
7512
7216
  program2.command("open").description("Pick a track, open it in Spotify").argument("[target]").option("--app", "Open in the native app", false).option("--browser", "Open in the browser", false).option("--limit <limit>", "Number of recent tracks to choose from", "20").allowExcessArguments().action(async (target, options) => {
7513
7217
  const openCommands = await Promise.resolve().then(() => (init_open(), exports_open));
@@ -7669,24 +7373,24 @@ function addAdminCommands(program2) {
7669
7373
  adminMixtapes.outputHelp();
7670
7374
  });
7671
7375
  adminMixtapes.command("create").description("Log a new mixtape draft").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (options) => {
7672
- const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7673
- await runMixtapeCreate(options, mixtapeCreateCommand3);
7376
+ const { mixtapeCreateCommand: mixtapeCreateCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7377
+ await runMixtapeCreate(options, mixtapeCreateCommand2);
7674
7378
  });
7675
7379
  adminMixtapes.command("update").description("Update a mixtape's fields").argument("[id]").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (id, options) => {
7676
- const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7677
- await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
7380
+ const { mixtapeUpdateCommand: mixtapeUpdateCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7381
+ await runMixtapeUpdate(id, options, mixtapeUpdateCommand2);
7678
7382
  });
7679
7383
  adminMixtapes.command("members").description("Set a mixtape's tracklist (refs and/or a cue-sheet file)").argument("[id]").argument("[refs...]").option("--from <file>", "Cue-sheet or JSON file with members").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, refs, options) => {
7680
- const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7681
- await runMixtapeMembers(id, refs, options, mixtapeMembersCommand3);
7384
+ const { mixtapeMembersCommand: mixtapeMembersCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7385
+ await runMixtapeMembers(id, refs, options, mixtapeMembersCommand2);
7682
7386
  });
7683
7387
  adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7684
- const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7685
- await runMixtapePublish(id, options, mixtapePublishCommand3);
7388
+ const { mixtapePublishCommand: mixtapePublishCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7389
+ await runMixtapePublish(id, options, mixtapePublishCommand2);
7686
7390
  });
7687
7391
  adminMixtapes.command("delete").description("Discard a mixtape draft (published mixtapes can't be deleted)").argument("[id]").option("--json", "Print JSON", false).option("--yes", "Skip confirmation", false).allowExcessArguments().action(async (id, options) => {
7688
- const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7689
- await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
7392
+ const { mixtapeDeleteCommand: mixtapeDeleteCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7393
+ await runMixtapeDelete(id, options, mixtapeDeleteCommand2);
7690
7394
  });
7691
7395
  adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7692
7396
  const { mixtapeListCommand: mixtapeListCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
@@ -7696,23 +7400,23 @@ function addAdminCommands(program2) {
7696
7400
  const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7697
7401
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand2);
7698
7402
  });
7699
- 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) => {
7700
- const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7701
- await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
7403
+ adminMixtapes.command("distribute").description("Push a promoted mixtape to YouTube (video) and Mixcloud (audio). The mixtape must already be promoted (`recordings promote`) \u2014 distribute is push-only.").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) => {
7404
+ const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7405
+ await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
7702
7406
  });
7703
7407
  adminMixtapes.command("publish-youtube").description("Flip a distributed mixtape's YouTube video from unlisted to public").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7704
7408
  const { publishYoutubeCommand: publishYoutubeCommand3 } = await Promise.resolve().then(() => (init_mixtape_youtube2(), exports_mixtape_youtube2));
7705
7409
  await runMixtapePublishYoutube(idOrLogId, options, publishYoutubeCommand3);
7706
7410
  });
7707
7411
  adminMixtapes.command("resync").description("Re-push a published mixtape's YouTube chapters + Mixcloud sections from its current cues (no re-upload)").argument("[idOrLogId]").option("--youtube", "Only re-sync YouTube").option("--mixcloud", "Only re-sync Mixcloud").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7708
- const { mixtapeResyncCommand: mixtapeResyncCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7709
- await runMixtapeResync(idOrLogId, options, mixtapeResyncCommand3);
7412
+ const { mixtapeResyncCommand: mixtapeResyncCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7413
+ await runMixtapeResync(idOrLogId, options, mixtapeResyncCommand2);
7710
7414
  });
7711
7415
  const adminClips = configureCommand(admin.command("clips").description("Mixtape clip (Fluncle Studio) commands"));
7712
7416
  adminClips.action(() => {
7713
7417
  adminClips.outputHelp();
7714
7418
  });
7715
- adminClips.command("list").description("List clips (filter by --status pending|done, --recording <id>, and/or --mixtape <id>)").option("--status <status>", "Filter by cut status (pending|done)").option("--recording <id>", "Filter by recording id").option("--mixtape <id>", "Filter by mixtape id").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7419
+ adminClips.command("list").description("List clips (filter by --status pending|done and/or --recording <id>)").option("--status <status>", "Filter by cut status (pending|done)").option("--recording <id>", "Filter by recording id").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7716
7420
  const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7717
7421
  await runClipsList(options, clipsListCommand2);
7718
7422
  });
@@ -7855,7 +7559,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
7855
7559
  console.log(` mime: ${result.mime}`);
7856
7560
  }
7857
7561
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
7858
- const script = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7562
+ const script = options.scriptFile ? readFileSync6(options.scriptFile, "utf8") : options.script;
7859
7563
  if (!idOrLogId || !script || !script.trim()) {
7860
7564
  throw new Error("Usage: fluncle admin tracks observe <track_id|log_id> (--script <text> | --script-file <file>) [--voice-id <id>] [--duration-ms <ms>] [--context-note <text>] [--json]");
7861
7565
  }
@@ -7911,7 +7615,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
7911
7615
  }
7912
7616
  }
7913
7617
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
7914
- const note = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7618
+ const note = options.scriptFile ? readFileSync6(options.scriptFile, "utf8") : options.script;
7915
7619
  if (!idOrLogId || !note || !note.trim()) {
7916
7620
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
7917
7621
  }
@@ -8057,7 +7761,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
8057
7761
  return;
8058
7762
  }
8059
7763
  const candidate = path2.join(dir, name);
8060
- return existsSync7(candidate) ? candidate : undefined;
7764
+ return existsSync6(candidate) ? candidate : undefined;
8061
7765
  };
8062
7766
  const resolveFile = (explicit, name) => {
8063
7767
  if (explicit) {
@@ -8234,56 +7938,56 @@ async function runTrackPurgeVideo(idOrLogId, options, trackPurgeVideoCommand2) {
8234
7938
  }
8235
7939
  console.log(result.noVideo ? `${result.logId} has no video \u2014 nothing to purge.` : `Purging the stale renditions for ${result.logId} from the edge. The next play picks up the fresh render.`);
8236
7940
  }
8237
- async function runMixtapeCreate(options, mixtapeCreateCommand3) {
8238
- const result = await mixtapeCreateCommand3(options);
7941
+ async function runMixtapeCreate(options, mixtapeCreateCommand2) {
7942
+ const result = await mixtapeCreateCommand2(options);
8239
7943
  if (options.json) {
8240
7944
  printJson(result);
8241
7945
  return;
8242
7946
  }
8243
7947
  console.log(`Logged draft ${result.mixtape.id}. It stays a draft until you publish it.`);
8244
7948
  }
8245
- async function runMixtapeUpdate(id, options, mixtapeUpdateCommand3) {
7949
+ async function runMixtapeUpdate(id, options, mixtapeUpdateCommand2) {
8246
7950
  if (!id) {
8247
7951
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes update <id>");
8248
7952
  }
8249
- const result = await mixtapeUpdateCommand3(id, options);
7953
+ const result = await mixtapeUpdateCommand2(id, options);
8250
7954
  if (options.json) {
8251
7955
  printJson(result);
8252
7956
  return;
8253
7957
  }
8254
7958
  console.log(`Saved ${result.mixtape.id}.`);
8255
7959
  }
8256
- async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand3) {
7960
+ async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand2) {
8257
7961
  if (!id) {
8258
7962
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes members <id> [refs...]");
8259
7963
  }
8260
7964
  if (refs.length === 0 && !options.from) {
8261
7965
  throw new Error("Provide refs as arguments or a cue-sheet file with --from");
8262
7966
  }
8263
- const result = await mixtapeMembersCommand3(id, refs, options);
7967
+ const result = await mixtapeMembersCommand2(id, refs, options);
8264
7968
  if (options.json) {
8265
7969
  printJson(result);
8266
7970
  return;
8267
7971
  }
8268
7972
  console.log(`Saved the tracklist: ${result.mixtape.memberCount} bangers on ${result.mixtape.id}.`);
8269
7973
  }
8270
- async function runMixtapePublish(id, options, mixtapePublishCommand3) {
7974
+ async function runMixtapePublish(id, options, mixtapePublishCommand2) {
8271
7975
  if (!id) {
8272
7976
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes publish <id>");
8273
7977
  }
8274
- const result = await mixtapePublishCommand3(id);
7978
+ const result = await mixtapePublishCommand2(id);
8275
7979
  if (options.json) {
8276
7980
  printJson(result);
8277
7981
  return;
8278
7982
  }
8279
7983
  console.log(`Minted ${result.mixtape.logId} (fluncle://${result.mixtape.logId}) \u2014 distributing. ` + "Run `distribute` to push it to the platforms.");
8280
7984
  }
8281
- async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3) {
7985
+ async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2) {
8282
7986
  if (!idOrLogId) {
8283
7987
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes distribute <idOrLogId> --video <mp4> --audio <file>");
8284
7988
  }
8285
7989
  const onProgress = options.json ? () => {} : (message) => console.log(message);
8286
- const result = await mixtapeDistributeCommand3(idOrLogId, options, onProgress);
7990
+ const result = await mixtapeDistributeCommand2(idOrLogId, options, onProgress);
8287
7991
  if (options.json) {
8288
7992
  printJson(result);
8289
7993
  return;
@@ -8304,12 +8008,12 @@ async function runMixtapePublishYoutube(idOrLogId, options, publishYoutubeComman
8304
8008
  }
8305
8009
  console.log(`YouTube video is now public: ${result.url}`);
8306
8010
  }
8307
- async function runMixtapeResync(idOrLogId, options, mixtapeResyncCommand3) {
8011
+ async function runMixtapeResync(idOrLogId, options, mixtapeResyncCommand2) {
8308
8012
  if (!idOrLogId) {
8309
8013
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes resync <idOrLogId>");
8310
8014
  }
8311
8015
  const onProgress = options.json ? () => {} : (message) => console.log(message);
8312
- const result = await mixtapeResyncCommand3(idOrLogId, options, onProgress);
8016
+ const result = await mixtapeResyncCommand2(idOrLogId, options, onProgress);
8313
8017
  if (options.json) {
8314
8018
  printJson(result);
8315
8019
  return;
@@ -8319,14 +8023,14 @@ async function runMixtapeResync(idOrLogId, options, mixtapeResyncCommand3) {
8319
8023
  console.log(`Re-synced ${result.logId} (fluncle://${result.logId}).
8320
8024
  ${links}`);
8321
8025
  }
8322
- async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
8026
+ async function runMixtapeDelete(id, options, mixtapeDeleteCommand2) {
8323
8027
  if (!id) {
8324
8028
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes delete <id> --yes");
8325
8029
  }
8326
8030
  if (!options.yes) {
8327
8031
  throw new Error("Pass --yes to confirm the discard. Published mixtapes can't be deleted.");
8328
8032
  }
8329
- await mixtapeDeleteCommand3(id);
8033
+ await mixtapeDeleteCommand2(id);
8330
8034
  if (options.json) {
8331
8035
  printJson({ id, ok: true });
8332
8036
  return;
@@ -8351,7 +8055,6 @@ async function runMixtapeList(options, mixtapeListCommand2) {
8351
8055
  }
8352
8056
  async function runClipsList(options, clipsListCommand2) {
8353
8057
  const clips = await clipsListCommand2({
8354
- mixtapeId: options.mixtape,
8355
8058
  recordingId: options.recording,
8356
8059
  status: options.status
8357
8060
  });
@@ -8364,7 +8067,7 @@ async function runClipsList(options, clipsListCommand2) {
8364
8067
  return;
8365
8068
  }
8366
8069
  for (const clip of clips) {
8367
- const source = clip.recordingId ?? clip.mixtapeId ?? "\u2014";
8070
+ const source = clip.recordingId ?? "\u2014";
8368
8071
  console.log(`${clip.id} ${clip.status} ${source} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
8369
8072
  }
8370
8073
  }
@@ -8527,8 +8230,8 @@ async function runRecent(options, recentCommand3) {
8527
8230
  console.log(trackRows2(tracks).join(`
8528
8231
  `));
8529
8232
  }
8530
- async function runMixtapes(options, mixtapesCommand3) {
8531
- const mixtapes = await mixtapesCommand3();
8233
+ async function runMixtapes(options, mixtapesCommand2) {
8234
+ const mixtapes = await mixtapesCommand2();
8532
8235
  if (options.json) {
8533
8236
  printJson({ mixtapes, ok: true });
8534
8237
  return;
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.85.0"
34
+ "version": "0.87.0"
35
35
  }