fluncle 0.78.0 → 0.80.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 +408 -43
  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.80.0".trim() ? "0.80.0".trim() : "0.1.0";
562
562
  });
563
563
 
564
564
  // src/update-notifier.ts
@@ -982,6 +982,7 @@ function mapTrack(track) {
982
982
  videoGrain: track.videoGrain,
983
983
  videoModel: track.videoModel,
984
984
  videoModelReasoning: track.videoModelReasoning,
985
+ videoRegister: track.videoRegister,
985
986
  videoUrl: track.videoUrl,
986
987
  videoVehicle: track.videoVehicle
987
988
  };
@@ -1351,6 +1352,7 @@ var init_mixtape_mixcloud = __esm(() => {
1351
1352
  // src/commands/mixtape-set-video.ts
1352
1353
  var exports_mixtape_set_video = {};
1353
1354
  __export(exports_mixtape_set_video, {
1355
+ uploadRenditionMultipart: () => uploadRenditionMultipart,
1354
1356
  stageSetVideo: () => stageSetVideo,
1355
1357
  renditionFfmpegArgs: () => renditionFfmpegArgs,
1356
1358
  planMultipart: () => planMultipart,
@@ -1417,7 +1419,7 @@ function renditionFfmpegArgs(inputPath, outputPath) {
1417
1419
  outputPath
1418
1420
  ];
1419
1421
  }
1420
- async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1422
+ async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
1421
1423
  if (!existsSync(masterPath)) {
1422
1424
  throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1423
1425
  }
@@ -1429,7 +1431,9 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1429
1431
  const size = statSync2(renditionPath).size;
1430
1432
  const plan = planMultipart(size);
1431
1433
  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 });
1434
+ const presign = await adminApiPost(presignPath, {
1435
+ partCount: plan.partCount
1436
+ });
1433
1437
  const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1434
1438
  const completed = [];
1435
1439
  try {
@@ -1447,13 +1451,19 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1447
1451
  await abortUpload(presign.abortUrl).catch(() => {});
1448
1452
  throw error;
1449
1453
  }
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
1454
  return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1453
1455
  } finally {
1454
1456
  rmSync2(renditionPath, { force: true });
1455
1457
  }
1456
1458
  }
1459
+ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1460
+ const result = await uploadRenditionMultipart(masterPath, `/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, onProgress);
1461
+ await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, {
1462
+ setVideoAt: new Date().toISOString()
1463
+ });
1464
+ onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1465
+ return result;
1466
+ }
1457
1467
  async function putPart(url, path2, part) {
1458
1468
  const response = await fetch(url, {
1459
1469
  body: Bun.file(path2).slice(part.start, part.end),
@@ -1943,6 +1953,7 @@ function mapTrack2(track) {
1943
1953
  videoGrain: track.videoGrain,
1944
1954
  videoModel: track.videoModel,
1945
1955
  videoModelReasoning: track.videoModelReasoning,
1956
+ videoRegister: track.videoRegister,
1946
1957
  videoUrl: track.videoUrl,
1947
1958
  videoVehicle: track.videoVehicle
1948
1959
  };
@@ -2455,7 +2466,7 @@ function parseVersion2(version) {
2455
2466
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2456
2467
  var init_version2 = __esm(() => {
2457
2468
  init_output();
2458
- currentVersion2 = "0.78.0".trim() ? "0.78.0".trim() : "0.1.0";
2469
+ currentVersion2 = "0.80.0".trim() ? "0.80.0".trim() : "0.1.0";
2459
2470
  });
2460
2471
 
2461
2472
  // ../../packages/registry/src/index.ts
@@ -3220,7 +3231,8 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
3220
3231
  videoModelReasoning,
3221
3232
  ...squared ? { squared: true } : {},
3222
3233
  ...manifest.vehicle ? { videoVehicle: manifest.vehicle } : {},
3223
- ...manifest.grain ? { videoGrain: manifest.grain } : {}
3234
+ ...manifest.grain ? { videoGrain: manifest.grain } : {},
3235
+ ...manifest.register ? { videoRegister: manifest.register } : {}
3224
3236
  });
3225
3237
  return { logId: finalize.logId, ok: true, trackId: finalize.trackId, urls };
3226
3238
  }
@@ -3228,7 +3240,7 @@ async function readManifestFields(renderPath) {
3228
3240
  try {
3229
3241
  const manifest = await Bun.file(renderPath).json();
3230
3242
  const result = {};
3231
- for (const key of ["vehicle", "grain", "model", "reasoning"]) {
3243
+ for (const key of ["vehicle", "grain", "model", "reasoning", "register"]) {
3232
3244
  const value = manifest[key];
3233
3245
  if (typeof value === "string" && value.trim()) {
3234
3246
  result[key] = value.trim().slice(0, 120);
@@ -3467,6 +3479,7 @@ async function vehiclesCommand(limit) {
3467
3479
  artists: track.artists,
3468
3480
  grain: track.videoGrain,
3469
3481
  logId: track.logId,
3482
+ register: track.videoRegister,
3470
3483
  title: track.title,
3471
3484
  vehicle: track.videoVehicle
3472
3485
  }));
@@ -3842,6 +3855,145 @@ var init_mixtape_youtube2 = __esm(() => {
3842
3855
  init_output();
3843
3856
  });
3844
3857
 
3858
+ // src/commands/recordings.ts
3859
+ var exports_recordings = {};
3860
+ __export(exports_recordings, {
3861
+ recordingsListCommand: () => recordingsListCommand,
3862
+ recordingUpdateCommand: () => recordingUpdateCommand,
3863
+ recordingPromoteCommand: () => recordingPromoteCommand,
3864
+ recordingGetCommand: () => recordingGetCommand,
3865
+ recordingGet: () => recordingGet,
3866
+ recordingDeleteCommand: () => recordingDeleteCommand,
3867
+ recordingCreateCommand: () => recordingCreateCommand
3868
+ });
3869
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3870
+ async function recordingGet(id) {
3871
+ const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
3872
+ return response.recording;
3873
+ }
3874
+ function formatRecordingSummary(recording) {
3875
+ const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
3876
+ const cues = recording.tracklist.length;
3877
+ return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
3878
+ }
3879
+ async function recordingsListCommand(options = {}) {
3880
+ const response = await adminApiGet("/api/admin/recordings");
3881
+ if (options.json) {
3882
+ printJson2({ ok: true, recordings: response.recordings });
3883
+ return;
3884
+ }
3885
+ if (response.recordings.length === 0) {
3886
+ console.log("No recordings yet.");
3887
+ return;
3888
+ }
3889
+ console.log(response.recordings.map(formatRecordingSummary).join(`
3890
+ `));
3891
+ }
3892
+ async function recordingGetCommand(id, options = {}) {
3893
+ if (!id) {
3894
+ throw new CliError2("missing_id", "Missing recording id for: get");
3895
+ }
3896
+ const recording = await recordingGet(id);
3897
+ if (options.json) {
3898
+ printJson2({ ok: true, recording });
3899
+ return;
3900
+ }
3901
+ console.log(formatRecordingSummary(recording));
3902
+ console.log(` r2Key: ${recording.r2Key}`);
3903
+ for (const cue of recording.tracklist) {
3904
+ const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
3905
+ console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
3906
+ }
3907
+ }
3908
+ async function recordingCreateCommand(options = {}) {
3909
+ const title = options.title?.trim();
3910
+ if (!title) {
3911
+ throw new CliError2("missing_title", "A recording needs a --title");
3912
+ }
3913
+ if (!options.video) {
3914
+ throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
3915
+ }
3916
+ if (!existsSync3(options.video)) {
3917
+ throw new CliError2("file_not_found", `Set-video master not found: ${options.video}`);
3918
+ }
3919
+ const created = await adminApiPost("/api/admin/recordings", {
3920
+ recordedAt: options.recordedAt,
3921
+ title
3922
+ });
3923
+ const recording = created.recording;
3924
+ const log = (message) => {
3925
+ if (!options.json) {
3926
+ console.log(message);
3927
+ }
3928
+ };
3929
+ log(`Recording ${recording.id} created — staging the set video…`);
3930
+ const upload = await uploadRenditionMultipart(options.video, `/api/admin/recordings/${encodeURIComponent(recording.id)}/set-video/presign`, log);
3931
+ if (options.json) {
3932
+ printJson2({ key: upload.key, ok: true, recording, url: upload.url });
3933
+ return;
3934
+ }
3935
+ console.log(`Recording ${recording.id} staged → ${upload.url}`);
3936
+ }
3937
+ async function recordingUpdateCommand(id, options = {}) {
3938
+ if (!id) {
3939
+ throw new CliError2("missing_id", "Missing recording id for: update");
3940
+ }
3941
+ const body = {};
3942
+ if (options.title !== undefined) {
3943
+ body.title = options.title;
3944
+ }
3945
+ if (options.recordedAt !== undefined) {
3946
+ body.recordedAt = options.recordedAt;
3947
+ }
3948
+ if (options.tracklistFile !== undefined) {
3949
+ if (!existsSync3(options.tracklistFile)) {
3950
+ throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
3951
+ }
3952
+ try {
3953
+ body.tracklistJson = JSON.parse(readFileSync3(options.tracklistFile, "utf8"));
3954
+ } catch {
3955
+ throw new CliError2("invalid_tracklist", `Tracklist file is not valid JSON: ${options.tracklistFile}`);
3956
+ }
3957
+ }
3958
+ if (Object.keys(body).length === 0) {
3959
+ throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
3960
+ }
3961
+ const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
3962
+ if (options.json) {
3963
+ printJson2({ ok: true, recording: response.recording });
3964
+ return;
3965
+ }
3966
+ console.log(`Updated ${formatRecordingSummary(response.recording)}`);
3967
+ }
3968
+ async function recordingDeleteCommand(id, options = {}) {
3969
+ if (!id) {
3970
+ throw new CliError2("missing_id", "Missing recording id for: delete");
3971
+ }
3972
+ await adminApiDelete(`/api/admin/recordings/${encodeURIComponent(id)}`);
3973
+ if (options.json) {
3974
+ printJson2({ ok: true });
3975
+ return;
3976
+ }
3977
+ console.log(`Deleted recording ${id} (and its clips).`);
3978
+ }
3979
+ async function recordingPromoteCommand(id, options = {}) {
3980
+ if (!id) {
3981
+ throw new CliError2("missing_id", "Missing recording id for: promote");
3982
+ }
3983
+ const response = await adminApiPost(`/api/admin/recordings/${encodeURIComponent(id)}/promote`);
3984
+ const recording = response.recording;
3985
+ if (options.json) {
3986
+ printJson2({ ok: true, recording });
3987
+ return;
3988
+ }
3989
+ console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
3990
+ }
3991
+ var init_recordings = __esm(() => {
3992
+ init_api();
3993
+ init_output();
3994
+ init_mixtape_set_video();
3995
+ });
3996
+
3845
3997
  // src/commands/mixtapes.ts
3846
3998
  var exports_mixtapes2 = {};
3847
3999
  __export(exports_mixtapes2, {
@@ -3856,7 +4008,7 @@ __export(exports_mixtapes2, {
3856
4008
  mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
3857
4009
  mixtapeCreateCommand: () => mixtapeCreateCommand2
3858
4010
  });
3859
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
4011
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
3860
4012
  async function mixtapesCommand2() {
3861
4013
  const response = await publicApiGet("/api/mixtapes");
3862
4014
  return response.mixtapes;
@@ -4024,10 +4176,10 @@ function buildBody2(options) {
4024
4176
  return body;
4025
4177
  }
4026
4178
  function parseCueFile2(filePath) {
4027
- if (!existsSync3(filePath)) {
4179
+ if (!existsSync4(filePath)) {
4028
4180
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
4029
4181
  }
4030
- const text = readFileSync3(filePath, "utf-8");
4182
+ const text = readFileSync4(filePath, "utf-8");
4031
4183
  const trimmed = text.trim();
4032
4184
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
4033
4185
  try {
@@ -4124,7 +4276,7 @@ __export(exports_clips, {
4124
4276
  BOX_CLIP_FONT_FILE: () => BOX_CLIP_FONT_FILE
4125
4277
  });
4126
4278
  import { randomUUID as randomUUID2 } from "node:crypto";
4127
- import { existsSync as existsSync4, rmSync as rmSync3, statSync as statSync4 } from "node:fs";
4279
+ import { existsSync as existsSync5, rmSync as rmSync3, statSync as statSync4 } from "node:fs";
4128
4280
  import { tmpdir as tmpdir2 } from "node:os";
4129
4281
  import { join as join5 } from "node:path";
4130
4282
  function clipFootageKey(clipId) {
@@ -4135,7 +4287,7 @@ function resolveFontFile(envVar, bakedPath) {
4135
4287
  if (explicit) {
4136
4288
  return explicit;
4137
4289
  }
4138
- return existsSync4(bakedPath) ? bakedPath : undefined;
4290
+ return existsSync5(bakedPath) ? bakedPath : undefined;
4139
4291
  }
4140
4292
  function resolveClipFontFile() {
4141
4293
  return resolveFontFile("CLIP_FONT_FILE", BOX_CLIP_FONT_FILE);
@@ -4185,24 +4337,32 @@ function resolveTitleLines(options, y) {
4185
4337
  function clipCutFilterComplex(options) {
4186
4338
  const xOffset = Math.max(0, Math.round(options.xOffset));
4187
4339
  const crop = `crop=ih*9/16:ih:${xOffset}:0,scale=${CLIP_WIDTH}:${CLIP_HEIGHT}`;
4340
+ const logId = options.logId?.trim();
4341
+ const hasCoordinate = Boolean(logId);
4188
4342
  const coordinateBox = Math.round(CLIP_COORDINATE_SIZE * 1.18);
4189
- const titleBottomOffset = CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP;
4343
+ const titleBottomOffset = hasCoordinate ? CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP : CLIP_SAFE_BOTTOM;
4190
4344
  const titleY = `h-${titleBottomOffset}-th`;
4191
4345
  const titleLines = resolveTitleLines(options, titleY);
4192
- const coordinate2 = {
4346
+ const coordinate2 = hasCoordinate ? {
4193
4347
  fontFile: options.oxaniumFontFile,
4194
4348
  size: CLIP_COORDINATE_SIZE,
4195
- text: `fluncle://${options.logId}`,
4349
+ text: `fluncle://${logId}`,
4196
4350
  x: CLIP_MARGIN_X,
4197
4351
  y: `h-${CLIP_SAFE_BOTTOM}-th`
4198
- };
4352
+ } : undefined;
4199
4353
  const haloGlyphs = [
4200
4354
  ...titleLines.map((line) => brandDrawtext({ ...line, color: CLIP_HALO_COLOR })),
4201
- brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })
4355
+ ...coordinate2 ? [brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })] : []
4202
4356
  ].join(",");
4203
4357
  const sharpGlyphs = [
4204
4358
  ...titleLines.map((line) => brandDrawtext({ ...line, borderw: CLIP_SHARP_BORDERW, color: CLIP_TITLE_COLOR })),
4205
- brandDrawtext({ ...coordinate2, borderw: CLIP_SHARP_BORDERW, color: CLIP_COORDINATE_COLOR })
4359
+ ...coordinate2 ? [
4360
+ brandDrawtext({
4361
+ ...coordinate2,
4362
+ borderw: CLIP_SHARP_BORDERW,
4363
+ color: CLIP_COORDINATE_COLOR
4364
+ })
4365
+ ] : []
4206
4366
  ].join(",");
4207
4367
  return [
4208
4368
  `[0:v]${crop},setsar=1[base]`,
@@ -4256,6 +4416,9 @@ function clipCutFfmpegArgs(options) {
4256
4416
  }
4257
4417
  async function clipsListCommand(filter = {}) {
4258
4418
  const params = new URLSearchParams;
4419
+ if (filter.recordingId) {
4420
+ params.set("recordingId", filter.recordingId);
4421
+ }
4259
4422
  if (filter.mixtapeId) {
4260
4423
  params.set("mixtapeId", filter.mixtapeId);
4261
4424
  }
@@ -4266,11 +4429,27 @@ async function clipsListCommand(filter = {}) {
4266
4429
  const response = await adminApiGet(`/api/admin/clips${query ? `?${query}` : ""}`);
4267
4430
  return response.clips;
4268
4431
  }
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}`);
4432
+ async function resolveClipSource(clip) {
4433
+ if (clip.recordingId) {
4434
+ const { recordingGet: recordingGet2 } = await Promise.resolve().then(() => (init_recordings(), exports_recordings));
4435
+ const recording = await recordingGet2(clip.recordingId);
4436
+ if (!recording.r2Key) {
4437
+ throw new CliError2("recording_not_staged", `Recording ${clip.recordingId} has no staged set video`);
4438
+ }
4439
+ return {
4440
+ logId: recording.logId,
4441
+ members: recording.tracklist.map((cue) => ({
4442
+ artists: cue.artists,
4443
+ startMs: cue.startMs,
4444
+ title: cue.title
4445
+ })),
4446
+ setDurationMs: recording.durationMs ?? 0,
4447
+ setUrl: `${FOUND_BASE3}/${recording.r2Key.split("/").map(encodeURIComponent).join("/")}`,
4448
+ title: recording.title
4449
+ };
4450
+ }
4451
+ if (!clip.mixtapeId) {
4452
+ throw new CliError2("clip_unlinked", `Clip ${clip.id} is linked to neither a recording nor a mixtape`);
4274
4453
  }
4275
4454
  const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
4276
4455
  const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
@@ -4280,22 +4459,36 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4280
4459
  if (!mixtape.setVideoAt) {
4281
4460
  throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4282
4461
  }
4462
+ return {
4463
+ logId: mixtape.logId,
4464
+ members: mixtape.members,
4465
+ setDurationMs: mixtape.durationMs ?? 0,
4466
+ setUrl: setVideoUrl(mixtape.logId),
4467
+ title: mixtape.title
4468
+ };
4469
+ }
4470
+ async function clipCutCommand(clipId, onProgress = () => {}) {
4471
+ const clips = await clipsListCommand();
4472
+ const clip = clips.find((candidate) => candidate.id === clipId);
4473
+ if (!clip) {
4474
+ throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
4475
+ }
4476
+ const source = await resolveClipSource(clip);
4283
4477
  await assertFfmpeg2();
4284
- const setUrl = setVideoUrl(mixtape.logId);
4285
4478
  const outputPath = join5(tmpdir2(), `fluncle-clip-${randomUUID2()}.mp4`);
4286
4479
  try {
4287
- onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms] from ${mixtape.logId}…`);
4480
+ onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms]${source.logId ? ` from ${source.logId}` : ""}…`);
4288
4481
  await runClipCut({
4289
4482
  inMs: clip.inMs,
4290
- logId: mixtape.logId,
4291
- members: mixtape.members,
4483
+ logId: source.logId,
4484
+ members: source.members,
4292
4485
  outMs: clip.outMs,
4293
4486
  outputPath,
4294
4487
  oxaniumFontFile: resolveClipFontFile(),
4295
4488
  sansFontFile: resolveClipSansFontFile(),
4296
- setDurationMs: mixtape.durationMs ?? 0,
4297
- setUrl,
4298
- title: mixtape.title,
4489
+ setDurationMs: source.setDurationMs,
4490
+ setUrl: source.setUrl,
4491
+ title: source.title,
4299
4492
  xOffset: clip.xOffset
4300
4493
  });
4301
4494
  const sizeBytes = statSync4(outputPath).size;
@@ -4310,7 +4503,7 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4310
4503
  return {
4311
4504
  clipId,
4312
4505
  key: presign.key,
4313
- logId: mixtape.logId,
4506
+ logId: source.logId,
4314
4507
  sizeBytes,
4315
4508
  url: `${FOUND_BASE3}/${presign.key}`
4316
4509
  };
@@ -4362,6 +4555,145 @@ var init_clips = __esm(() => {
4362
4555
  MAX_CLIP_BYTES = 100 * 1024 * 1024;
4363
4556
  });
4364
4557
 
4558
+ // src/commands/recordings.ts
4559
+ var exports_recordings2 = {};
4560
+ __export(exports_recordings2, {
4561
+ recordingsListCommand: () => recordingsListCommand2,
4562
+ recordingUpdateCommand: () => recordingUpdateCommand2,
4563
+ recordingPromoteCommand: () => recordingPromoteCommand2,
4564
+ recordingGetCommand: () => recordingGetCommand2,
4565
+ recordingGet: () => recordingGet2,
4566
+ recordingDeleteCommand: () => recordingDeleteCommand2,
4567
+ recordingCreateCommand: () => recordingCreateCommand2
4568
+ });
4569
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "node:fs";
4570
+ async function recordingGet2(id) {
4571
+ const response = await adminApiGet(`/api/admin/recordings/${encodeURIComponent(id)}`);
4572
+ return response.recording;
4573
+ }
4574
+ function formatRecordingSummary2(recording) {
4575
+ const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
4576
+ const cues = recording.tracklist.length;
4577
+ return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
4578
+ }
4579
+ async function recordingsListCommand2(options = {}) {
4580
+ const response = await adminApiGet("/api/admin/recordings");
4581
+ if (options.json) {
4582
+ printJson2({ ok: true, recordings: response.recordings });
4583
+ return;
4584
+ }
4585
+ if (response.recordings.length === 0) {
4586
+ console.log("No recordings yet.");
4587
+ return;
4588
+ }
4589
+ console.log(response.recordings.map(formatRecordingSummary2).join(`
4590
+ `));
4591
+ }
4592
+ async function recordingGetCommand2(id, options = {}) {
4593
+ if (!id) {
4594
+ throw new CliError2("missing_id", "Missing recording id for: get");
4595
+ }
4596
+ const recording = await recordingGet2(id);
4597
+ if (options.json) {
4598
+ printJson2({ ok: true, recording });
4599
+ return;
4600
+ }
4601
+ console.log(formatRecordingSummary2(recording));
4602
+ console.log(` r2Key: ${recording.r2Key}`);
4603
+ for (const cue of recording.tracklist) {
4604
+ const at = cue.startMs === undefined ? "—" : `${(cue.startMs / 1000).toFixed(1)}s`;
4605
+ console.log(` ${at} ${cue.artists.join(", ")} — ${cue.title}`);
4606
+ }
4607
+ }
4608
+ async function recordingCreateCommand2(options = {}) {
4609
+ const title = options.title?.trim();
4610
+ if (!title) {
4611
+ throw new CliError2("missing_title", "A recording needs a --title");
4612
+ }
4613
+ if (!options.video) {
4614
+ throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
4615
+ }
4616
+ if (!existsSync6(options.video)) {
4617
+ throw new CliError2("file_not_found", `Set-video master not found: ${options.video}`);
4618
+ }
4619
+ const created = await adminApiPost("/api/admin/recordings", {
4620
+ recordedAt: options.recordedAt,
4621
+ title
4622
+ });
4623
+ const recording = created.recording;
4624
+ const log = (message) => {
4625
+ if (!options.json) {
4626
+ console.log(message);
4627
+ }
4628
+ };
4629
+ log(`Recording ${recording.id} created — staging the set video…`);
4630
+ const upload = await uploadRenditionMultipart(options.video, `/api/admin/recordings/${encodeURIComponent(recording.id)}/set-video/presign`, log);
4631
+ if (options.json) {
4632
+ printJson2({ key: upload.key, ok: true, recording, url: upload.url });
4633
+ return;
4634
+ }
4635
+ console.log(`Recording ${recording.id} staged → ${upload.url}`);
4636
+ }
4637
+ async function recordingUpdateCommand2(id, options = {}) {
4638
+ if (!id) {
4639
+ throw new CliError2("missing_id", "Missing recording id for: update");
4640
+ }
4641
+ const body = {};
4642
+ if (options.title !== undefined) {
4643
+ body.title = options.title;
4644
+ }
4645
+ if (options.recordedAt !== undefined) {
4646
+ body.recordedAt = options.recordedAt;
4647
+ }
4648
+ if (options.tracklistFile !== undefined) {
4649
+ if (!existsSync6(options.tracklistFile)) {
4650
+ throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
4651
+ }
4652
+ try {
4653
+ body.tracklistJson = JSON.parse(readFileSync5(options.tracklistFile, "utf8"));
4654
+ } catch {
4655
+ throw new CliError2("invalid_tracklist", `Tracklist file is not valid JSON: ${options.tracklistFile}`);
4656
+ }
4657
+ }
4658
+ if (Object.keys(body).length === 0) {
4659
+ throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
4660
+ }
4661
+ const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
4662
+ if (options.json) {
4663
+ printJson2({ ok: true, recording: response.recording });
4664
+ return;
4665
+ }
4666
+ console.log(`Updated ${formatRecordingSummary2(response.recording)}`);
4667
+ }
4668
+ async function recordingDeleteCommand2(id, options = {}) {
4669
+ if (!id) {
4670
+ throw new CliError2("missing_id", "Missing recording id for: delete");
4671
+ }
4672
+ await adminApiDelete(`/api/admin/recordings/${encodeURIComponent(id)}`);
4673
+ if (options.json) {
4674
+ printJson2({ ok: true });
4675
+ return;
4676
+ }
4677
+ console.log(`Deleted recording ${id} (and its clips).`);
4678
+ }
4679
+ async function recordingPromoteCommand2(id, options = {}) {
4680
+ if (!id) {
4681
+ throw new CliError2("missing_id", "Missing recording id for: promote");
4682
+ }
4683
+ const response = await adminApiPost(`/api/admin/recordings/${encodeURIComponent(id)}/promote`);
4684
+ const recording = response.recording;
4685
+ if (options.json) {
4686
+ printJson2({ ok: true, recording });
4687
+ return;
4688
+ }
4689
+ console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
4690
+ }
4691
+ var init_recordings2 = __esm(() => {
4692
+ init_api();
4693
+ init_output();
4694
+ init_mixtape_set_video();
4695
+ });
4696
+
4365
4697
  // src/commands/newsletter.ts
4366
4698
  var exports_newsletter = {};
4367
4699
  __export(exports_newsletter, {
@@ -4371,7 +4703,7 @@ __export(exports_newsletter, {
4371
4703
  newsletterDraftCommand: () => newsletterDraftCommand,
4372
4704
  newsletterDeleteCommand: () => newsletterDeleteCommand
4373
4705
  });
4374
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
4706
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
4375
4707
  function buildBody3(options, { requireContent }) {
4376
4708
  const body = {};
4377
4709
  if (options.contentFile !== undefined) {
@@ -4391,10 +4723,10 @@ function buildBody3(options, { requireContent }) {
4391
4723
  return body;
4392
4724
  }
4393
4725
  function readContentFile(filePath) {
4394
- if (!existsSync5(filePath)) {
4726
+ if (!existsSync7(filePath)) {
4395
4727
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
4396
4728
  }
4397
- const text = readFileSync4(filePath, "utf-8");
4729
+ const text = readFileSync6(filePath, "utf-8");
4398
4730
  try {
4399
4731
  return JSON.parse(text);
4400
4732
  } catch (error) {
@@ -5006,7 +5338,7 @@ function vehicleRows(rows) {
5006
5338
  return Math.max(width, coordinate2(row).length);
5007
5339
  }, 0);
5008
5340
  return rows.map((row) => {
5009
- return `${coordinate2(row).padEnd(coordWidth)} ${foundDate(row.addedAt)} ${row.vehicle ?? COORD_FALLBACK2}`;
5341
+ return `${coordinate2(row).padEnd(coordWidth)} ${foundDate(row.addedAt)} ${row.vehicle ?? COORD_FALLBACK2} · ${row.register ?? COORD_FALLBACK2}`;
5010
5342
  });
5011
5343
  }
5012
5344
  function trackDetailLines(track) {
@@ -5029,7 +5361,7 @@ var init_format2 = __esm(() => {
5029
5361
  });
5030
5362
 
5031
5363
  // src/cli.ts
5032
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
5364
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5033
5365
  import path2 from "path";
5034
5366
 
5035
5367
  // ../../node_modules/commander/lib/error.js
@@ -7397,7 +7729,7 @@ function addAdminCommands(program2) {
7397
7729
  adminClips.action(() => {
7398
7730
  adminClips.outputHelp();
7399
7731
  });
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) => {
7732
+ 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
7733
  const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7402
7734
  await runClipsList(options, clipsListCommand2);
7403
7735
  });
@@ -7405,6 +7737,34 @@ function addAdminCommands(program2) {
7405
7737
  const { clipCutCommand: clipCutCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7406
7738
  await runClipsCut(clipId, options, clipCutCommand2);
7407
7739
  });
7740
+ const adminRecordings = configureCommand(admin.command("recordings").description("Recording (unpublished set) commands"));
7741
+ adminRecordings.action(() => {
7742
+ adminRecordings.outputHelp();
7743
+ });
7744
+ 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) => {
7745
+ const { recordingCreateCommand: recordingCreateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7746
+ await recordingCreateCommand3(options);
7747
+ });
7748
+ adminRecordings.command("list").description("List every recording").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7749
+ const { recordingsListCommand: recordingsListCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7750
+ await recordingsListCommand3(options);
7751
+ });
7752
+ adminRecordings.command("get").description("Show one recording by id").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7753
+ const { recordingGetCommand: recordingGetCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7754
+ await recordingGetCommand3(id, options);
7755
+ });
7756
+ 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) => {
7757
+ const { recordingUpdateCommand: recordingUpdateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7758
+ await recordingUpdateCommand3(id, options);
7759
+ });
7760
+ adminRecordings.command("delete").description("Delete a recording (cascade its clips)").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7761
+ const { recordingDeleteCommand: recordingDeleteCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7762
+ await recordingDeleteCommand3(id, options);
7763
+ });
7764
+ 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) => {
7765
+ const { recordingPromoteCommand: recordingPromoteCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
7766
+ await recordingPromoteCommand3(id, options);
7767
+ });
7408
7768
  const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
7409
7769
  adminNewsletter.action(() => {
7410
7770
  adminNewsletter.outputHelp();
@@ -7508,7 +7868,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
7508
7868
  console.log(` mime: ${result.mime}`);
7509
7869
  }
7510
7870
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
7511
- const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7871
+ const script = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7512
7872
  if (!idOrLogId || !script || !script.trim()) {
7513
7873
  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
7874
  }
@@ -7564,7 +7924,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
7564
7924
  }
7565
7925
  }
7566
7926
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
7567
- const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7927
+ const note = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7568
7928
  if (!idOrLogId || !note || !note.trim()) {
7569
7929
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
7570
7930
  }
@@ -7710,7 +8070,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
7710
8070
  return;
7711
8071
  }
7712
8072
  const candidate = path2.join(dir, name);
7713
- return existsSync6(candidate) ? candidate : undefined;
8073
+ return existsSync8(candidate) ? candidate : undefined;
7714
8074
  };
7715
8075
  const resolveFile = (explicit, name) => {
7716
8076
  if (explicit) {
@@ -7998,7 +8358,11 @@ async function runMixtapeList(options, mixtapeListCommand2) {
7998
8358
  }
7999
8359
  }
8000
8360
  async function runClipsList(options, clipsListCommand2) {
8001
- const clips = await clipsListCommand2({ mixtapeId: options.mixtape, status: options.status });
8361
+ const clips = await clipsListCommand2({
8362
+ mixtapeId: options.mixtape,
8363
+ recordingId: options.recording,
8364
+ status: options.status
8365
+ });
8002
8366
  if (options.json) {
8003
8367
  printJson({ clips, ok: true });
8004
8368
  return;
@@ -8008,7 +8372,8 @@ async function runClipsList(options, clipsListCommand2) {
8008
8372
  return;
8009
8373
  }
8010
8374
  for (const clip of clips) {
8011
- console.log(`${clip.id} ${clip.status} ${clip.mixtapeId} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
8375
+ const source = clip.recordingId ?? clip.mixtapeId ?? "\u2014";
8376
+ console.log(`${clip.id} ${clip.status} ${source} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
8012
8377
  }
8013
8378
  }
8014
8379
  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.80.0"
35
35
  }