fluncle 0.78.0 → 0.79.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 +401 -40
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -558,7 +558,7 @@ function parseVersion(version) {
558
558
  var currentVersion;
559
559
  var init_version = __esm(() => {
560
560
  init_output();
561
- currentVersion = "0.78.0".trim() ? "0.78.0".trim() : "0.1.0";
561
+ currentVersion = "0.79.0".trim() ? "0.79.0".trim() : "0.1.0";
562
562
  });
563
563
 
564
564
  // src/update-notifier.ts
@@ -1351,6 +1351,7 @@ var init_mixtape_mixcloud = __esm(() => {
1351
1351
  // src/commands/mixtape-set-video.ts
1352
1352
  var exports_mixtape_set_video = {};
1353
1353
  __export(exports_mixtape_set_video, {
1354
+ uploadRenditionMultipart: () => uploadRenditionMultipart,
1354
1355
  stageSetVideo: () => stageSetVideo,
1355
1356
  renditionFfmpegArgs: () => renditionFfmpegArgs,
1356
1357
  planMultipart: () => planMultipart,
@@ -1417,7 +1418,7 @@ function renditionFfmpegArgs(inputPath, outputPath) {
1417
1418
  outputPath
1418
1419
  ];
1419
1420
  }
1420
- async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1421
+ async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
1421
1422
  if (!existsSync(masterPath)) {
1422
1423
  throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1423
1424
  }
@@ -1429,7 +1430,9 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1429
1430
  const size = statSync2(renditionPath).size;
1430
1431
  const plan = planMultipart(size);
1431
1432
  onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1432
- const presign = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, { partCount: plan.partCount });
1433
+ const presign = await adminApiPost(presignPath, {
1434
+ partCount: plan.partCount
1435
+ });
1433
1436
  const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1434
1437
  const completed = [];
1435
1438
  try {
@@ -1447,13 +1450,19 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1447
1450
  await abortUpload(presign.abortUrl).catch(() => {});
1448
1451
  throw error;
1449
1452
  }
1450
- await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
1451
- onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1452
1453
  return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1453
1454
  } finally {
1454
1455
  rmSync2(renditionPath, { force: true });
1455
1456
  }
1456
1457
  }
1458
+ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1459
+ const result = await uploadRenditionMultipart(masterPath, `/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, onProgress);
1460
+ await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, {
1461
+ setVideoAt: new Date().toISOString()
1462
+ });
1463
+ onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1464
+ return result;
1465
+ }
1457
1466
  async function putPart(url, path2, part) {
1458
1467
  const response = await fetch(url, {
1459
1468
  body: Bun.file(path2).slice(part.start, part.end),
@@ -2455,7 +2464,7 @@ function parseVersion2(version) {
2455
2464
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2456
2465
  var init_version2 = __esm(() => {
2457
2466
  init_output();
2458
- currentVersion2 = "0.78.0".trim() ? "0.78.0".trim() : "0.1.0";
2467
+ currentVersion2 = "0.79.0".trim() ? "0.79.0".trim() : "0.1.0";
2459
2468
  });
2460
2469
 
2461
2470
  // ../../packages/registry/src/index.ts
@@ -3842,6 +3851,145 @@ var init_mixtape_youtube2 = __esm(() => {
3842
3851
  init_output();
3843
3852
  });
3844
3853
 
3854
+ // src/commands/recordings.ts
3855
+ var exports_recordings = {};
3856
+ __export(exports_recordings, {
3857
+ recordingsListCommand: () => recordingsListCommand,
3858
+ recordingUpdateCommand: () => recordingUpdateCommand,
3859
+ recordingPromoteCommand: () => recordingPromoteCommand,
3860
+ recordingGetCommand: () => recordingGetCommand,
3861
+ recordingGet: () => recordingGet,
3862
+ recordingDeleteCommand: () => recordingDeleteCommand,
3863
+ recordingCreateCommand: () => recordingCreateCommand
3864
+ });
3865
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3866
+ async function recordingGet(id) {
3867
+ const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
3868
+ return response.recording;
3869
+ }
3870
+ function formatRecordingSummary(recording) {
3871
+ const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
3872
+ const cues = recording.tracklist.length;
3873
+ return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
3874
+ }
3875
+ async function recordingsListCommand(options = {}) {
3876
+ const response = await adminApiGet("/api/admin/recordings");
3877
+ if (options.json) {
3878
+ printJson2({ ok: true, recordings: response.recordings });
3879
+ return;
3880
+ }
3881
+ if (response.recordings.length === 0) {
3882
+ console.log("No recordings yet.");
3883
+ return;
3884
+ }
3885
+ console.log(response.recordings.map(formatRecordingSummary).join(`
3886
+ `));
3887
+ }
3888
+ async function recordingGetCommand(id, options = {}) {
3889
+ if (!id) {
3890
+ throw new CliError2("missing_id", "Missing recording id for: get");
3891
+ }
3892
+ const recording = await recordingGet(id);
3893
+ if (options.json) {
3894
+ printJson2({ ok: true, recording });
3895
+ return;
3896
+ }
3897
+ console.log(formatRecordingSummary(recording));
3898
+ console.log(` r2Key: ${recording.r2Key}`);
3899
+ for (const cue of recording.tracklist) {
3900
+ const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
3901
+ console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
3902
+ }
3903
+ }
3904
+ async function recordingCreateCommand(options = {}) {
3905
+ const title = options.title?.trim();
3906
+ if (!title) {
3907
+ throw new CliError2("missing_title", "A recording needs a --title");
3908
+ }
3909
+ if (!options.video) {
3910
+ throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
3911
+ }
3912
+ if (!existsSync3(options.video)) {
3913
+ throw new CliError2("file_not_found", `Set-video master not found: ${options.video}`);
3914
+ }
3915
+ const created = await adminApiPost("/api/admin/recordings", {
3916
+ recordedAt: options.recordedAt,
3917
+ title
3918
+ });
3919
+ const recording = created.recording;
3920
+ const log = (message) => {
3921
+ if (!options.json) {
3922
+ console.log(message);
3923
+ }
3924
+ };
3925
+ log(`Recording ${recording.id} created — staging the set video…`);
3926
+ const upload = await uploadRenditionMultipart(options.video, `/api/admin/recordings/${encodeURIComponent(recording.id)}/set-video/presign`, log);
3927
+ if (options.json) {
3928
+ printJson2({ key: upload.key, ok: true, recording, url: upload.url });
3929
+ return;
3930
+ }
3931
+ console.log(`Recording ${recording.id} staged → ${upload.url}`);
3932
+ }
3933
+ async function recordingUpdateCommand(id, options = {}) {
3934
+ if (!id) {
3935
+ throw new CliError2("missing_id", "Missing recording id for: update");
3936
+ }
3937
+ const body = {};
3938
+ if (options.title !== undefined) {
3939
+ body.title = options.title;
3940
+ }
3941
+ if (options.recordedAt !== undefined) {
3942
+ body.recordedAt = options.recordedAt;
3943
+ }
3944
+ if (options.tracklistFile !== undefined) {
3945
+ if (!existsSync3(options.tracklistFile)) {
3946
+ throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
3947
+ }
3948
+ try {
3949
+ body.tracklistJson = JSON.parse(readFileSync3(options.tracklistFile, "utf8"));
3950
+ } catch {
3951
+ throw new CliError2("invalid_tracklist", `Tracklist file is not valid JSON: ${options.tracklistFile}`);
3952
+ }
3953
+ }
3954
+ if (Object.keys(body).length === 0) {
3955
+ throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
3956
+ }
3957
+ const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
3958
+ if (options.json) {
3959
+ printJson2({ ok: true, recording: response.recording });
3960
+ return;
3961
+ }
3962
+ console.log(`Updated ${formatRecordingSummary(response.recording)}`);
3963
+ }
3964
+ async function recordingDeleteCommand(id, options = {}) {
3965
+ if (!id) {
3966
+ throw new CliError2("missing_id", "Missing recording id for: delete");
3967
+ }
3968
+ await adminApiDelete(`/api/admin/recordings/${encodeURIComponent(id)}`);
3969
+ if (options.json) {
3970
+ printJson2({ ok: true });
3971
+ return;
3972
+ }
3973
+ console.log(`Deleted recording ${id} (and its clips).`);
3974
+ }
3975
+ async function recordingPromoteCommand(id, options = {}) {
3976
+ if (!id) {
3977
+ throw new CliError2("missing_id", "Missing recording id for: promote");
3978
+ }
3979
+ const response = await adminApiPost(`/api/admin/recordings/${encodeURIComponent(id)}/promote`);
3980
+ const recording = response.recording;
3981
+ if (options.json) {
3982
+ printJson2({ ok: true, recording });
3983
+ return;
3984
+ }
3985
+ console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
3986
+ }
3987
+ var init_recordings = __esm(() => {
3988
+ init_api();
3989
+ init_output();
3990
+ init_mixtape_set_video();
3991
+ });
3992
+
3845
3993
  // src/commands/mixtapes.ts
3846
3994
  var exports_mixtapes2 = {};
3847
3995
  __export(exports_mixtapes2, {
@@ -3856,7 +4004,7 @@ __export(exports_mixtapes2, {
3856
4004
  mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
3857
4005
  mixtapeCreateCommand: () => mixtapeCreateCommand2
3858
4006
  });
3859
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
4007
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
3860
4008
  async function mixtapesCommand2() {
3861
4009
  const response = await publicApiGet("/api/mixtapes");
3862
4010
  return response.mixtapes;
@@ -4024,10 +4172,10 @@ function buildBody2(options) {
4024
4172
  return body;
4025
4173
  }
4026
4174
  function parseCueFile2(filePath) {
4027
- if (!existsSync3(filePath)) {
4175
+ if (!existsSync4(filePath)) {
4028
4176
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
4029
4177
  }
4030
- const text = readFileSync3(filePath, "utf-8");
4178
+ const text = readFileSync4(filePath, "utf-8");
4031
4179
  const trimmed = text.trim();
4032
4180
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
4033
4181
  try {
@@ -4124,7 +4272,7 @@ __export(exports_clips, {
4124
4272
  BOX_CLIP_FONT_FILE: () => BOX_CLIP_FONT_FILE
4125
4273
  });
4126
4274
  import { randomUUID as randomUUID2 } from "node:crypto";
4127
- import { existsSync as existsSync4, rmSync as rmSync3, statSync as statSync4 } from "node:fs";
4275
+ import { existsSync as existsSync5, rmSync as rmSync3, statSync as statSync4 } from "node:fs";
4128
4276
  import { tmpdir as tmpdir2 } from "node:os";
4129
4277
  import { join as join5 } from "node:path";
4130
4278
  function clipFootageKey(clipId) {
@@ -4135,7 +4283,7 @@ function resolveFontFile(envVar, bakedPath) {
4135
4283
  if (explicit) {
4136
4284
  return explicit;
4137
4285
  }
4138
- return existsSync4(bakedPath) ? bakedPath : undefined;
4286
+ return existsSync5(bakedPath) ? bakedPath : undefined;
4139
4287
  }
4140
4288
  function resolveClipFontFile() {
4141
4289
  return resolveFontFile("CLIP_FONT_FILE", BOX_CLIP_FONT_FILE);
@@ -4185,24 +4333,32 @@ function resolveTitleLines(options, y) {
4185
4333
  function clipCutFilterComplex(options) {
4186
4334
  const xOffset = Math.max(0, Math.round(options.xOffset));
4187
4335
  const crop = `crop=ih*9/16:ih:${xOffset}:0,scale=${CLIP_WIDTH}:${CLIP_HEIGHT}`;
4336
+ const logId = options.logId?.trim();
4337
+ const hasCoordinate = Boolean(logId);
4188
4338
  const coordinateBox = Math.round(CLIP_COORDINATE_SIZE * 1.18);
4189
- const titleBottomOffset = CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP;
4339
+ const titleBottomOffset = hasCoordinate ? CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP : CLIP_SAFE_BOTTOM;
4190
4340
  const titleY = `h-${titleBottomOffset}-th`;
4191
4341
  const titleLines = resolveTitleLines(options, titleY);
4192
- const coordinate2 = {
4342
+ const coordinate2 = hasCoordinate ? {
4193
4343
  fontFile: options.oxaniumFontFile,
4194
4344
  size: CLIP_COORDINATE_SIZE,
4195
- text: `fluncle://${options.logId}`,
4345
+ text: `fluncle://${logId}`,
4196
4346
  x: CLIP_MARGIN_X,
4197
4347
  y: `h-${CLIP_SAFE_BOTTOM}-th`
4198
- };
4348
+ } : undefined;
4199
4349
  const haloGlyphs = [
4200
4350
  ...titleLines.map((line) => brandDrawtext({ ...line, color: CLIP_HALO_COLOR })),
4201
- brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })
4351
+ ...coordinate2 ? [brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })] : []
4202
4352
  ].join(",");
4203
4353
  const sharpGlyphs = [
4204
4354
  ...titleLines.map((line) => brandDrawtext({ ...line, borderw: CLIP_SHARP_BORDERW, color: CLIP_TITLE_COLOR })),
4205
- brandDrawtext({ ...coordinate2, borderw: CLIP_SHARP_BORDERW, color: CLIP_COORDINATE_COLOR })
4355
+ ...coordinate2 ? [
4356
+ brandDrawtext({
4357
+ ...coordinate2,
4358
+ borderw: CLIP_SHARP_BORDERW,
4359
+ color: CLIP_COORDINATE_COLOR
4360
+ })
4361
+ ] : []
4206
4362
  ].join(",");
4207
4363
  return [
4208
4364
  `[0:v]${crop},setsar=1[base]`,
@@ -4256,6 +4412,9 @@ function clipCutFfmpegArgs(options) {
4256
4412
  }
4257
4413
  async function clipsListCommand(filter = {}) {
4258
4414
  const params = new URLSearchParams;
4415
+ if (filter.recordingId) {
4416
+ params.set("recordingId", filter.recordingId);
4417
+ }
4259
4418
  if (filter.mixtapeId) {
4260
4419
  params.set("mixtapeId", filter.mixtapeId);
4261
4420
  }
@@ -4266,11 +4425,27 @@ async function clipsListCommand(filter = {}) {
4266
4425
  const response = await adminApiGet(`/api/admin/clips${query ? `?${query}` : ""}`);
4267
4426
  return response.clips;
4268
4427
  }
4269
- async function clipCutCommand(clipId, onProgress = () => {}) {
4270
- const clips = await clipsListCommand();
4271
- const clip = clips.find((candidate) => candidate.id === clipId);
4272
- if (!clip) {
4273
- throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
4428
+ async function resolveClipSource(clip) {
4429
+ if (clip.recordingId) {
4430
+ const { recordingGet: recordingGet2 } = await Promise.resolve().then(() => (init_recordings(), exports_recordings));
4431
+ const recording = await recordingGet2(clip.recordingId);
4432
+ if (!recording.r2Key) {
4433
+ throw new CliError2("recording_not_staged", `Recording ${clip.recordingId} has no staged set video`);
4434
+ }
4435
+ return {
4436
+ logId: recording.logId,
4437
+ members: recording.tracklist.map((cue) => ({
4438
+ artists: cue.artists,
4439
+ startMs: cue.startMs,
4440
+ title: cue.title
4441
+ })),
4442
+ setDurationMs: recording.durationMs ?? 0,
4443
+ setUrl: `${FOUND_BASE3}/${recording.r2Key.split("/").map(encodeURIComponent).join("/")}`,
4444
+ title: recording.title
4445
+ };
4446
+ }
4447
+ if (!clip.mixtapeId) {
4448
+ throw new CliError2("clip_unlinked", `Clip ${clip.id} is linked to neither a recording nor a mixtape`);
4274
4449
  }
4275
4450
  const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
4276
4451
  const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
@@ -4280,22 +4455,36 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4280
4455
  if (!mixtape.setVideoAt) {
4281
4456
  throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4282
4457
  }
4458
+ return {
4459
+ logId: mixtape.logId,
4460
+ members: mixtape.members,
4461
+ setDurationMs: mixtape.durationMs ?? 0,
4462
+ setUrl: setVideoUrl(mixtape.logId),
4463
+ title: mixtape.title
4464
+ };
4465
+ }
4466
+ async function clipCutCommand(clipId, onProgress = () => {}) {
4467
+ const clips = await clipsListCommand();
4468
+ const clip = clips.find((candidate) => candidate.id === clipId);
4469
+ if (!clip) {
4470
+ throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
4471
+ }
4472
+ const source = await resolveClipSource(clip);
4283
4473
  await assertFfmpeg2();
4284
- const setUrl = setVideoUrl(mixtape.logId);
4285
4474
  const outputPath = join5(tmpdir2(), `fluncle-clip-${randomUUID2()}.mp4`);
4286
4475
  try {
4287
- onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms] from ${mixtape.logId}…`);
4476
+ onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms]${source.logId ? ` from ${source.logId}` : ""}…`);
4288
4477
  await runClipCut({
4289
4478
  inMs: clip.inMs,
4290
- logId: mixtape.logId,
4291
- members: mixtape.members,
4479
+ logId: source.logId,
4480
+ members: source.members,
4292
4481
  outMs: clip.outMs,
4293
4482
  outputPath,
4294
4483
  oxaniumFontFile: resolveClipFontFile(),
4295
4484
  sansFontFile: resolveClipSansFontFile(),
4296
- setDurationMs: mixtape.durationMs ?? 0,
4297
- setUrl,
4298
- title: mixtape.title,
4485
+ setDurationMs: source.setDurationMs,
4486
+ setUrl: source.setUrl,
4487
+ title: source.title,
4299
4488
  xOffset: clip.xOffset
4300
4489
  });
4301
4490
  const sizeBytes = statSync4(outputPath).size;
@@ -4310,7 +4499,7 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4310
4499
  return {
4311
4500
  clipId,
4312
4501
  key: presign.key,
4313
- logId: mixtape.logId,
4502
+ logId: source.logId,
4314
4503
  sizeBytes,
4315
4504
  url: `${FOUND_BASE3}/${presign.key}`
4316
4505
  };
@@ -4362,6 +4551,145 @@ var init_clips = __esm(() => {
4362
4551
  MAX_CLIP_BYTES = 100 * 1024 * 1024;
4363
4552
  });
4364
4553
 
4554
+ // src/commands/recordings.ts
4555
+ var exports_recordings2 = {};
4556
+ __export(exports_recordings2, {
4557
+ recordingsListCommand: () => recordingsListCommand2,
4558
+ recordingUpdateCommand: () => recordingUpdateCommand2,
4559
+ recordingPromoteCommand: () => recordingPromoteCommand2,
4560
+ recordingGetCommand: () => recordingGetCommand2,
4561
+ recordingGet: () => recordingGet2,
4562
+ recordingDeleteCommand: () => recordingDeleteCommand2,
4563
+ recordingCreateCommand: () => recordingCreateCommand2
4564
+ });
4565
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "node:fs";
4566
+ async function recordingGet2(id) {
4567
+ const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
4568
+ return response.recording;
4569
+ }
4570
+ function formatRecordingSummary2(recording) {
4571
+ const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
4572
+ const cues = recording.tracklist.length;
4573
+ return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
4574
+ }
4575
+ async function recordingsListCommand2(options = {}) {
4576
+ const response = await adminApiGet("/api/admin/recordings");
4577
+ if (options.json) {
4578
+ printJson2({ ok: true, recordings: response.recordings });
4579
+ return;
4580
+ }
4581
+ if (response.recordings.length === 0) {
4582
+ console.log("No recordings yet.");
4583
+ return;
4584
+ }
4585
+ console.log(response.recordings.map(formatRecordingSummary2).join(`
4586
+ `));
4587
+ }
4588
+ async function recordingGetCommand2(id, options = {}) {
4589
+ if (!id) {
4590
+ throw new CliError2("missing_id", "Missing recording id for: get");
4591
+ }
4592
+ const recording = await recordingGet2(id);
4593
+ if (options.json) {
4594
+ printJson2({ ok: true, recording });
4595
+ return;
4596
+ }
4597
+ console.log(formatRecordingSummary2(recording));
4598
+ console.log(` r2Key: ${recording.r2Key}`);
4599
+ for (const cue of recording.tracklist) {
4600
+ const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
4601
+ console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
4602
+ }
4603
+ }
4604
+ async function recordingCreateCommand2(options = {}) {
4605
+ const title = options.title?.trim();
4606
+ if (!title) {
4607
+ throw new CliError2("missing_title", "A recording needs a --title");
4608
+ }
4609
+ if (!options.video) {
4610
+ throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
4611
+ }
4612
+ if (!existsSync6(options.video)) {
4613
+ throw new CliError2("file_not_found", `Set-video master not found: ${options.video}`);
4614
+ }
4615
+ const created = await adminApiPost("/api/admin/recordings", {
4616
+ recordedAt: options.recordedAt,
4617
+ title
4618
+ });
4619
+ const recording = created.recording;
4620
+ const log = (message) => {
4621
+ if (!options.json) {
4622
+ console.log(message);
4623
+ }
4624
+ };
4625
+ log(`Recording ${recording.id} created — staging the set video…`);
4626
+ const upload = await uploadRenditionMultipart(options.video, `/api/admin/recordings/${encodeURIComponent(recording.id)}/set-video/presign`, log);
4627
+ if (options.json) {
4628
+ printJson2({ key: upload.key, ok: true, recording, url: upload.url });
4629
+ return;
4630
+ }
4631
+ console.log(`Recording ${recording.id} staged → ${upload.url}`);
4632
+ }
4633
+ async function recordingUpdateCommand2(id, options = {}) {
4634
+ if (!id) {
4635
+ throw new CliError2("missing_id", "Missing recording id for: update");
4636
+ }
4637
+ const body = {};
4638
+ if (options.title !== undefined) {
4639
+ body.title = options.title;
4640
+ }
4641
+ if (options.recordedAt !== undefined) {
4642
+ body.recordedAt = options.recordedAt;
4643
+ }
4644
+ if (options.tracklistFile !== undefined) {
4645
+ if (!existsSync6(options.tracklistFile)) {
4646
+ throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
4647
+ }
4648
+ try {
4649
+ body.tracklistJson = JSON.parse(readFileSync5(options.tracklistFile, "utf8"));
4650
+ } catch {
4651
+ throw new CliError2("invalid_tracklist", `Tracklist file is not valid JSON: ${options.tracklistFile}`);
4652
+ }
4653
+ }
4654
+ if (Object.keys(body).length === 0) {
4655
+ throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
4656
+ }
4657
+ const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
4658
+ if (options.json) {
4659
+ printJson2({ ok: true, recording: response.recording });
4660
+ return;
4661
+ }
4662
+ console.log(`Updated ${formatRecordingSummary2(response.recording)}`);
4663
+ }
4664
+ async function recordingDeleteCommand2(id, options = {}) {
4665
+ if (!id) {
4666
+ throw new CliError2("missing_id", "Missing recording id for: delete");
4667
+ }
4668
+ await adminApiDelete(`/api/admin/recordings/${encodeURIComponent(id)}`);
4669
+ if (options.json) {
4670
+ printJson2({ ok: true });
4671
+ return;
4672
+ }
4673
+ console.log(`Deleted recording ${id} (and its clips).`);
4674
+ }
4675
+ async function recordingPromoteCommand2(id, options = {}) {
4676
+ if (!id) {
4677
+ throw new CliError2("missing_id", "Missing recording id for: promote");
4678
+ }
4679
+ const response = await adminApiPost(`/api/admin/recordings/${encodeURIComponent(id)}/promote`);
4680
+ const recording = response.recording;
4681
+ if (options.json) {
4682
+ printJson2({ ok: true, recording });
4683
+ return;
4684
+ }
4685
+ console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
4686
+ }
4687
+ var init_recordings2 = __esm(() => {
4688
+ init_api();
4689
+ init_output();
4690
+ init_mixtape_set_video();
4691
+ });
4692
+
4365
4693
  // src/commands/newsletter.ts
4366
4694
  var exports_newsletter = {};
4367
4695
  __export(exports_newsletter, {
@@ -4371,7 +4699,7 @@ __export(exports_newsletter, {
4371
4699
  newsletterDraftCommand: () => newsletterDraftCommand,
4372
4700
  newsletterDeleteCommand: () => newsletterDeleteCommand
4373
4701
  });
4374
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
4702
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
4375
4703
  function buildBody3(options, { requireContent }) {
4376
4704
  const body = {};
4377
4705
  if (options.contentFile !== undefined) {
@@ -4391,10 +4719,10 @@ function buildBody3(options, { requireContent }) {
4391
4719
  return body;
4392
4720
  }
4393
4721
  function readContentFile(filePath) {
4394
- if (!existsSync5(filePath)) {
4722
+ if (!existsSync7(filePath)) {
4395
4723
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
4396
4724
  }
4397
- const text = readFileSync4(filePath, "utf-8");
4725
+ const text = readFileSync6(filePath, "utf-8");
4398
4726
  try {
4399
4727
  return JSON.parse(text);
4400
4728
  } catch (error) {
@@ -5029,7 +5357,7 @@ var init_format2 = __esm(() => {
5029
5357
  });
5030
5358
 
5031
5359
  // src/cli.ts
5032
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
5360
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5033
5361
  import path2 from "path";
5034
5362
 
5035
5363
  // ../../node_modules/commander/lib/error.js
@@ -7397,7 +7725,7 @@ function addAdminCommands(program2) {
7397
7725
  adminClips.action(() => {
7398
7726
  adminClips.outputHelp();
7399
7727
  });
7400
- adminClips.command("list").description("List clips (filter by --status pending|done and/or --mixtape <id>)").option("--status <status>", "Filter by cut status (pending|done)").option("--mixtape <id>", "Filter by mixtape id").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7728
+ 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) => {
7401
7729
  const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7402
7730
  await runClipsList(options, clipsListCommand2);
7403
7731
  });
@@ -7405,6 +7733,34 @@ function addAdminCommands(program2) {
7405
7733
  const { clipCutCommand: clipCutCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7406
7734
  await runClipsCut(clipId, options, clipCutCommand2);
7407
7735
  });
7736
+ const adminRecordings = configureCommand(admin.command("recordings").description("Recording (unpublished set) commands"));
7737
+ adminRecordings.action(() => {
7738
+ adminRecordings.outputHelp();
7739
+ });
7740
+ adminRecordings.command("create").description("Create a recording + stage its set video (from --video)").option("--title <text>", "Recording title").option("--video <file>", "Set-video master to stage (a 1080p rendition is derived)").option("--recorded-at <date>", "Recorded date (ISO)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7741
+ const { recordingCreateCommand: recordingCreateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7742
+ await recordingCreateCommand3(options);
7743
+ });
7744
+ adminRecordings.command("list").description("List every recording").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7745
+ const { recordingsListCommand: recordingsListCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7746
+ await recordingsListCommand3(options);
7747
+ });
7748
+ adminRecordings.command("get").description("Show one recording by id").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7749
+ const { recordingGetCommand: recordingGetCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7750
+ await recordingGetCommand3(id, options);
7751
+ });
7752
+ adminRecordings.command("update").description("Update a recording's title, recorded date, or tracklist (--tracklist-file)").argument("[id]").option("--title <text>", "Recording title").option("--recorded-at <date>", "Recorded date (ISO)").option("--tracklist-file <file>", "JSON file with the whole cue tracklist array").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7753
+ const { recordingUpdateCommand: recordingUpdateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7754
+ await recordingUpdateCommand3(id, options);
7755
+ });
7756
+ adminRecordings.command("delete").description("Delete a recording (cascade its clips)").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7757
+ const { recordingDeleteCommand: recordingDeleteCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7758
+ await recordingDeleteCommand3(id, options);
7759
+ });
7760
+ adminRecordings.command("promote").description("Promote a recording to a published mixtape (mint-or-reuse; idempotent)").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7761
+ const { recordingPromoteCommand: recordingPromoteCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7762
+ await recordingPromoteCommand3(id, options);
7763
+ });
7408
7764
  const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
7409
7765
  adminNewsletter.action(() => {
7410
7766
  adminNewsletter.outputHelp();
@@ -7508,7 +7864,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
7508
7864
  console.log(` mime: ${result.mime}`);
7509
7865
  }
7510
7866
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
7511
- const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7867
+ const script = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7512
7868
  if (!idOrLogId || !script || !script.trim()) {
7513
7869
  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]");
7514
7870
  }
@@ -7564,7 +7920,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
7564
7920
  }
7565
7921
  }
7566
7922
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
7567
- const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7923
+ const note = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7568
7924
  if (!idOrLogId || !note || !note.trim()) {
7569
7925
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
7570
7926
  }
@@ -7710,7 +8066,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
7710
8066
  return;
7711
8067
  }
7712
8068
  const candidate = path2.join(dir, name);
7713
- return existsSync6(candidate) ? candidate : undefined;
8069
+ return existsSync8(candidate) ? candidate : undefined;
7714
8070
  };
7715
8071
  const resolveFile = (explicit, name) => {
7716
8072
  if (explicit) {
@@ -7998,7 +8354,11 @@ async function runMixtapeList(options, mixtapeListCommand2) {
7998
8354
  }
7999
8355
  }
8000
8356
  async function runClipsList(options, clipsListCommand2) {
8001
- const clips = await clipsListCommand2({ mixtapeId: options.mixtape, status: options.status });
8357
+ const clips = await clipsListCommand2({
8358
+ mixtapeId: options.mixtape,
8359
+ recordingId: options.recording,
8360
+ status: options.status
8361
+ });
8002
8362
  if (options.json) {
8003
8363
  printJson({ clips, ok: true });
8004
8364
  return;
@@ -8008,7 +8368,8 @@ async function runClipsList(options, clipsListCommand2) {
8008
8368
  return;
8009
8369
  }
8010
8370
  for (const clip of clips) {
8011
- console.log(`${clip.id} ${clip.status} ${clip.mixtapeId} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
8371
+ const source = clip.recordingId ?? clip.mixtapeId ?? "\u2014";
8372
+ console.log(`${clip.id} ${clip.status} ${source} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
8012
8373
  }
8013
8374
  }
8014
8375
  async function runClipsCut(clipId, options, clipCutCommand2) {
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.78.0"
34
+ "version": "0.79.0"
35
35
  }