fluncle 0.77.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 +427 -144
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -482,6 +482,20 @@ function resolveClipTracks(options) {
482
482
  startMs: member.startMs
483
483
  }));
484
484
  }
485
+ function mixcloudSections(members) {
486
+ return members.filter((member) => member.startMs != null).sort((a, b) => a.startMs - b.startMs).map((member) => ({
487
+ artist: member.artists.join(", "),
488
+ song: member.title,
489
+ start_time: Math.floor(member.startMs / 1000)
490
+ }));
491
+ }
492
+ function mixcloudSectionFields(sections) {
493
+ return sections.flatMap((section, index) => [
494
+ [`sections-${index}-artist`, section.artist],
495
+ [`sections-${index}-song`, section.song],
496
+ [`sections-${index}-start_time`, String(section.start_time)]
497
+ ]);
498
+ }
485
499
  function baseTitleMatches(findingTitle, candidateTitle) {
486
500
  const want = new Set(tokenize(stripVersionSuffix(findingTitle)));
487
501
  const have = new Set(tokenize(stripVersionSuffix(candidateTitle)));
@@ -544,7 +558,7 @@ function parseVersion(version) {
544
558
  var currentVersion;
545
559
  var init_version = __esm(() => {
546
560
  init_output();
547
- currentVersion = "0.77.0".trim() ? "0.77.0".trim() : "0.1.0";
561
+ currentVersion = "0.79.0".trim() ? "0.79.0".trim() : "0.1.0";
548
562
  });
549
563
 
550
564
  // src/update-notifier.ts
@@ -1196,9 +1210,6 @@ var exports_mixtape_mixcloud = {};
1196
1210
  __export(exports_mixtape_mixcloud, {
1197
1211
  resyncMixcloud: () => resyncMixcloud,
1198
1212
  mixtapeDescription: () => mixtapeDescription,
1199
- mixcloudSections: () => mixcloudSections,
1200
- mixcloudSectionFields: () => mixcloudSectionFields,
1201
- mixcloudEditUrl: () => mixcloudEditUrl,
1202
1213
  distributeMixcloud: () => distributeMixcloud,
1203
1214
  authMixcloudCommand: () => authMixcloudCommand
1204
1215
  });
@@ -1259,34 +1270,9 @@ async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = f
1259
1270
  });
1260
1271
  return { url };
1261
1272
  }
1262
- async function resyncMixcloud(mixtapeId, onProgress) {
1263
- const token = await fetchMixcloudToken();
1264
- const mixtape = await mixtapeGetCommand(mixtapeId);
1265
- const sections = mixcloudSections(mixtape.members);
1266
- if (sections.length === 0) {
1267
- throw new CliError2("mixcloud_no_cues", "No cued members to sync — mark cues on the mixtape first.");
1268
- }
1269
- const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
1270
- const mixcloud = social.posts.find((post) => post.platform === "mixcloud");
1271
- const key = mixcloud?.externalId;
1272
- if (!key) {
1273
- throw new CliError2("mixcloud_not_distributed", "No Mixcloud cloudcast to re-sync — distribute the mixtape first.");
1274
- }
1275
- const form = new FormData;
1276
- for (const [name, value] of mixcloudSectionFields(sections)) {
1277
- form.append(name, value);
1278
- }
1279
- onProgress?.(`Mixcloud: re-syncing ${sections.length} sections (no re-upload)…`);
1280
- const response = await fetch(`${mixcloudEditUrl(key)}?access_token=${encodeURIComponent(token)}`, { body: form, method: "POST" });
1281
- const text = await response.text();
1282
- if (!response.ok) {
1283
- throwMixcloudError(response.status, text);
1284
- }
1285
- const result = parseUploadResult(text);
1286
- if (!result.success) {
1287
- throw new CliError2("mixcloud_edit_rejected", `Mixcloud rejected the section edit: ${result.message ?? text.slice(0, 300)}`);
1288
- }
1289
- return { url: mixcloud?.url ?? `https://www.mixcloud.com${key}` };
1273
+ async function resyncMixcloud(mixtapeId) {
1274
+ const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/resync`);
1275
+ return { url: response.url };
1290
1276
  }
1291
1277
  async function authMixcloudCommand() {
1292
1278
  const response = await adminApiGet("/api/admin/mixcloud/auth/start");
@@ -1311,25 +1297,6 @@ ${breadcrumb}` : breadcrumb;
1311
1297
 
1312
1298
  ${breadcrumb}` : breadcrumb;
1313
1299
  }
1314
- function mixcloudSections(members) {
1315
- return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
1316
- artist: member.artists.join(", "),
1317
- song: member.title,
1318
- start_time: Math.floor(member.startMs / 1000)
1319
- }));
1320
- }
1321
- function mixcloudSectionFields(sections) {
1322
- return sections.flatMap((section, index) => [
1323
- [`sections-${index}-artist`, section.artist],
1324
- [`sections-${index}-song`, section.song],
1325
- [`sections-${index}-start_time`, String(section.start_time)]
1326
- ]);
1327
- }
1328
- function mixcloudEditUrl(key) {
1329
- const withLeading = key.startsWith("/") ? key : `/${key}`;
1330
- const path2 = withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
1331
- return `${MIXCLOUD_API}/upload${path2}edit/`;
1332
- }
1333
1300
  function mixtapeTags(_mixtape) {
1334
1301
  return ["Drum & Bass", "Fluncle"];
1335
1302
  }
@@ -1374,6 +1341,7 @@ function throwMixcloudError(status, body) {
1374
1341
  }
1375
1342
  var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
1376
1343
  var init_mixtape_mixcloud = __esm(() => {
1344
+ init_util();
1377
1345
  init_api();
1378
1346
  init_output();
1379
1347
  init_mixtape_api();
@@ -1383,6 +1351,7 @@ var init_mixtape_mixcloud = __esm(() => {
1383
1351
  // src/commands/mixtape-set-video.ts
1384
1352
  var exports_mixtape_set_video = {};
1385
1353
  __export(exports_mixtape_set_video, {
1354
+ uploadRenditionMultipart: () => uploadRenditionMultipart,
1386
1355
  stageSetVideo: () => stageSetVideo,
1387
1356
  renditionFfmpegArgs: () => renditionFfmpegArgs,
1388
1357
  planMultipart: () => planMultipart,
@@ -1449,7 +1418,7 @@ function renditionFfmpegArgs(inputPath, outputPath) {
1449
1418
  outputPath
1450
1419
  ];
1451
1420
  }
1452
- async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1421
+ async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
1453
1422
  if (!existsSync(masterPath)) {
1454
1423
  throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1455
1424
  }
@@ -1461,7 +1430,9 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1461
1430
  const size = statSync2(renditionPath).size;
1462
1431
  const plan = planMultipart(size);
1463
1432
  onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1464
- 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
+ });
1465
1436
  const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1466
1437
  const completed = [];
1467
1438
  try {
@@ -1479,13 +1450,19 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1479
1450
  await abortUpload(presign.abortUrl).catch(() => {});
1480
1451
  throw error;
1481
1452
  }
1482
- await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
1483
- onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1484
1453
  return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1485
1454
  } finally {
1486
1455
  rmSync2(renditionPath, { force: true });
1487
1456
  }
1488
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
+ }
1489
1466
  async function putPart(url, path2, part) {
1490
1467
  const response = await fetch(url, {
1491
1468
  body: Bun.file(path2).slice(part.start, part.end),
@@ -1695,7 +1672,7 @@ async function mixtapeResyncCommand(idOrLogId, options, onProgress = () => {}) {
1695
1672
  if (doMixcloud) {
1696
1673
  onProgress("Mixcloud: re-syncing sections…");
1697
1674
  const { resyncMixcloud: resyncMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
1698
- const result = await resyncMixcloud2(mixtapeId, onProgress);
1675
+ const result = await resyncMixcloud2(mixtapeId);
1699
1676
  results.push({ platform: "mixcloud", url: result.url });
1700
1677
  onProgress(`Mixcloud: ${result.url}`);
1701
1678
  }
@@ -2487,7 +2464,7 @@ function parseVersion2(version) {
2487
2464
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2488
2465
  var init_version2 = __esm(() => {
2489
2466
  init_output();
2490
- currentVersion2 = "0.77.0".trim() ? "0.77.0".trim() : "0.1.0";
2467
+ currentVersion2 = "0.79.0".trim() ? "0.79.0".trim() : "0.1.0";
2491
2468
  });
2492
2469
 
2493
2470
  // ../../packages/registry/src/index.ts
@@ -3874,6 +3851,145 @@ var init_mixtape_youtube2 = __esm(() => {
3874
3851
  init_output();
3875
3852
  });
3876
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
+
3877
3993
  // src/commands/mixtapes.ts
3878
3994
  var exports_mixtapes2 = {};
3879
3995
  __export(exports_mixtapes2, {
@@ -3888,7 +4004,7 @@ __export(exports_mixtapes2, {
3888
4004
  mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
3889
4005
  mixtapeCreateCommand: () => mixtapeCreateCommand2
3890
4006
  });
3891
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
4007
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
3892
4008
  async function mixtapesCommand2() {
3893
4009
  const response = await publicApiGet("/api/mixtapes");
3894
4010
  return response.mixtapes;
@@ -4018,7 +4134,7 @@ async function mixtapeResyncCommand2(idOrLogId, options, onProgress = () => {})
4018
4134
  if (doMixcloud) {
4019
4135
  onProgress("Mixcloud: re-syncing sections…");
4020
4136
  const { resyncMixcloud: resyncMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
4021
- const result = await resyncMixcloud2(mixtapeId, onProgress);
4137
+ const result = await resyncMixcloud2(mixtapeId);
4022
4138
  results.push({ platform: "mixcloud", url: result.url });
4023
4139
  onProgress(`Mixcloud: ${result.url}`);
4024
4140
  }
@@ -4056,10 +4172,10 @@ function buildBody2(options) {
4056
4172
  return body;
4057
4173
  }
4058
4174
  function parseCueFile2(filePath) {
4059
- if (!existsSync3(filePath)) {
4175
+ if (!existsSync4(filePath)) {
4060
4176
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
4061
4177
  }
4062
- const text = readFileSync3(filePath, "utf-8");
4178
+ const text = readFileSync4(filePath, "utf-8");
4063
4179
  const trimmed = text.trim();
4064
4180
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
4065
4181
  try {
@@ -4156,7 +4272,7 @@ __export(exports_clips, {
4156
4272
  BOX_CLIP_FONT_FILE: () => BOX_CLIP_FONT_FILE
4157
4273
  });
4158
4274
  import { randomUUID as randomUUID2 } from "node:crypto";
4159
- 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";
4160
4276
  import { tmpdir as tmpdir2 } from "node:os";
4161
4277
  import { join as join5 } from "node:path";
4162
4278
  function clipFootageKey(clipId) {
@@ -4167,7 +4283,7 @@ function resolveFontFile(envVar, bakedPath) {
4167
4283
  if (explicit) {
4168
4284
  return explicit;
4169
4285
  }
4170
- return existsSync4(bakedPath) ? bakedPath : undefined;
4286
+ return existsSync5(bakedPath) ? bakedPath : undefined;
4171
4287
  }
4172
4288
  function resolveClipFontFile() {
4173
4289
  return resolveFontFile("CLIP_FONT_FILE", BOX_CLIP_FONT_FILE);
@@ -4217,24 +4333,32 @@ function resolveTitleLines(options, y) {
4217
4333
  function clipCutFilterComplex(options) {
4218
4334
  const xOffset = Math.max(0, Math.round(options.xOffset));
4219
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);
4220
4338
  const coordinateBox = Math.round(CLIP_COORDINATE_SIZE * 1.18);
4221
- const titleBottomOffset = CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP;
4339
+ const titleBottomOffset = hasCoordinate ? CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP : CLIP_SAFE_BOTTOM;
4222
4340
  const titleY = `h-${titleBottomOffset}-th`;
4223
4341
  const titleLines = resolveTitleLines(options, titleY);
4224
- const coordinate2 = {
4342
+ const coordinate2 = hasCoordinate ? {
4225
4343
  fontFile: options.oxaniumFontFile,
4226
4344
  size: CLIP_COORDINATE_SIZE,
4227
- text: `fluncle://${options.logId}`,
4345
+ text: `fluncle://${logId}`,
4228
4346
  x: CLIP_MARGIN_X,
4229
4347
  y: `h-${CLIP_SAFE_BOTTOM}-th`
4230
- };
4348
+ } : undefined;
4231
4349
  const haloGlyphs = [
4232
4350
  ...titleLines.map((line) => brandDrawtext({ ...line, color: CLIP_HALO_COLOR })),
4233
- brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })
4351
+ ...coordinate2 ? [brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })] : []
4234
4352
  ].join(",");
4235
4353
  const sharpGlyphs = [
4236
4354
  ...titleLines.map((line) => brandDrawtext({ ...line, borderw: CLIP_SHARP_BORDERW, color: CLIP_TITLE_COLOR })),
4237
- 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
+ ] : []
4238
4362
  ].join(",");
4239
4363
  return [
4240
4364
  `[0:v]${crop},setsar=1[base]`,
@@ -4288,6 +4412,9 @@ function clipCutFfmpegArgs(options) {
4288
4412
  }
4289
4413
  async function clipsListCommand(filter = {}) {
4290
4414
  const params = new URLSearchParams;
4415
+ if (filter.recordingId) {
4416
+ params.set("recordingId", filter.recordingId);
4417
+ }
4291
4418
  if (filter.mixtapeId) {
4292
4419
  params.set("mixtapeId", filter.mixtapeId);
4293
4420
  }
@@ -4298,11 +4425,27 @@ async function clipsListCommand(filter = {}) {
4298
4425
  const response = await adminApiGet(`/api/admin/clips${query ? `?${query}` : ""}`);
4299
4426
  return response.clips;
4300
4427
  }
4301
- async function clipCutCommand(clipId, onProgress = () => {}) {
4302
- const clips = await clipsListCommand();
4303
- const clip = clips.find((candidate) => candidate.id === clipId);
4304
- if (!clip) {
4305
- 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`);
4306
4449
  }
4307
4450
  const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
4308
4451
  const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
@@ -4312,22 +4455,36 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4312
4455
  if (!mixtape.setVideoAt) {
4313
4456
  throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4314
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);
4315
4473
  await assertFfmpeg2();
4316
- const setUrl = setVideoUrl(mixtape.logId);
4317
4474
  const outputPath = join5(tmpdir2(), `fluncle-clip-${randomUUID2()}.mp4`);
4318
4475
  try {
4319
- 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}` : ""}…`);
4320
4477
  await runClipCut({
4321
4478
  inMs: clip.inMs,
4322
- logId: mixtape.logId,
4323
- members: mixtape.members,
4479
+ logId: source.logId,
4480
+ members: source.members,
4324
4481
  outMs: clip.outMs,
4325
4482
  outputPath,
4326
4483
  oxaniumFontFile: resolveClipFontFile(),
4327
4484
  sansFontFile: resolveClipSansFontFile(),
4328
- setDurationMs: mixtape.durationMs ?? 0,
4329
- setUrl,
4330
- title: mixtape.title,
4485
+ setDurationMs: source.setDurationMs,
4486
+ setUrl: source.setUrl,
4487
+ title: source.title,
4331
4488
  xOffset: clip.xOffset
4332
4489
  });
4333
4490
  const sizeBytes = statSync4(outputPath).size;
@@ -4342,7 +4499,7 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4342
4499
  return {
4343
4500
  clipId,
4344
4501
  key: presign.key,
4345
- logId: mixtape.logId,
4502
+ logId: source.logId,
4346
4503
  sizeBytes,
4347
4504
  url: `${FOUND_BASE3}/${presign.key}`
4348
4505
  };
@@ -4394,6 +4551,145 @@ var init_clips = __esm(() => {
4394
4551
  MAX_CLIP_BYTES = 100 * 1024 * 1024;
4395
4552
  });
4396
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
+
4397
4693
  // src/commands/newsletter.ts
4398
4694
  var exports_newsletter = {};
4399
4695
  __export(exports_newsletter, {
@@ -4403,7 +4699,7 @@ __export(exports_newsletter, {
4403
4699
  newsletterDraftCommand: () => newsletterDraftCommand,
4404
4700
  newsletterDeleteCommand: () => newsletterDeleteCommand
4405
4701
  });
4406
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
4702
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
4407
4703
  function buildBody3(options, { requireContent }) {
4408
4704
  const body = {};
4409
4705
  if (options.contentFile !== undefined) {
@@ -4423,10 +4719,10 @@ function buildBody3(options, { requireContent }) {
4423
4719
  return body;
4424
4720
  }
4425
4721
  function readContentFile(filePath) {
4426
- if (!existsSync5(filePath)) {
4722
+ if (!existsSync7(filePath)) {
4427
4723
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
4428
4724
  }
4429
- const text = readFileSync4(filePath, "utf-8");
4725
+ const text = readFileSync6(filePath, "utf-8");
4430
4726
  try {
4431
4727
  return JSON.parse(text);
4432
4728
  } catch (error) {
@@ -4612,9 +4908,6 @@ var exports_mixtape_mixcloud2 = {};
4612
4908
  __export(exports_mixtape_mixcloud2, {
4613
4909
  resyncMixcloud: () => resyncMixcloud2,
4614
4910
  mixtapeDescription: () => mixtapeDescription2,
4615
- mixcloudSections: () => mixcloudSections2,
4616
- mixcloudSectionFields: () => mixcloudSectionFields2,
4617
- mixcloudEditUrl: () => mixcloudEditUrl2,
4618
4911
  distributeMixcloud: () => distributeMixcloud2,
4619
4912
  authMixcloudCommand: () => authMixcloudCommand2
4620
4913
  });
@@ -4641,8 +4934,8 @@ async function distributeMixcloud2(mixtapeId, audioPath, onProgress, unlisted =
4641
4934
  for (const [index, tag] of mixtapeTags2(mixtape).entries()) {
4642
4935
  form.append(`tags-${index}-tag`, tag);
4643
4936
  }
4644
- const sections = mixcloudSections2(mixtape.members);
4645
- for (const [name, value] of mixcloudSectionFields2(sections)) {
4937
+ const sections = mixcloudSections(mixtape.members);
4938
+ for (const [name, value] of mixcloudSectionFields(sections)) {
4646
4939
  form.append(name, value);
4647
4940
  }
4648
4941
  if (unlisted) {
@@ -4675,34 +4968,9 @@ async function distributeMixcloud2(mixtapeId, audioPath, onProgress, unlisted =
4675
4968
  });
4676
4969
  return { url };
4677
4970
  }
4678
- async function resyncMixcloud2(mixtapeId, onProgress) {
4679
- const token = await fetchMixcloudToken2();
4680
- const mixtape = await mixtapeGetCommand(mixtapeId);
4681
- const sections = mixcloudSections2(mixtape.members);
4682
- if (sections.length === 0) {
4683
- throw new CliError2("mixcloud_no_cues", "No cued members to sync — mark cues on the mixtape first.");
4684
- }
4685
- const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
4686
- const mixcloud = social.posts.find((post) => post.platform === "mixcloud");
4687
- const key = mixcloud?.externalId;
4688
- if (!key) {
4689
- throw new CliError2("mixcloud_not_distributed", "No Mixcloud cloudcast to re-sync — distribute the mixtape first.");
4690
- }
4691
- const form = new FormData;
4692
- for (const [name, value] of mixcloudSectionFields2(sections)) {
4693
- form.append(name, value);
4694
- }
4695
- onProgress?.(`Mixcloud: re-syncing ${sections.length} sections (no re-upload)…`);
4696
- const response = await fetch(`${mixcloudEditUrl2(key)}?access_token=${encodeURIComponent(token)}`, { body: form, method: "POST" });
4697
- const text = await response.text();
4698
- if (!response.ok) {
4699
- throwMixcloudError2(response.status, text);
4700
- }
4701
- const result = parseUploadResult2(text);
4702
- if (!result.success) {
4703
- throw new CliError2("mixcloud_edit_rejected", `Mixcloud rejected the section edit: ${result.message ?? text.slice(0, 300)}`);
4704
- }
4705
- return { url: mixcloud?.url ?? `https://www.mixcloud.com${key}` };
4971
+ async function resyncMixcloud2(mixtapeId) {
4972
+ const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/resync`);
4973
+ return { url: response.url };
4706
4974
  }
4707
4975
  async function authMixcloudCommand2() {
4708
4976
  const response = await adminApiGet("/api/admin/mixcloud/auth/start");
@@ -4727,25 +4995,6 @@ ${breadcrumb}` : breadcrumb;
4727
4995
 
4728
4996
  ${breadcrumb}` : breadcrumb;
4729
4997
  }
4730
- function mixcloudSections2(members) {
4731
- return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
4732
- artist: member.artists.join(", "),
4733
- song: member.title,
4734
- start_time: Math.floor(member.startMs / 1000)
4735
- }));
4736
- }
4737
- function mixcloudSectionFields2(sections) {
4738
- return sections.flatMap((section, index) => [
4739
- [`sections-${index}-artist`, section.artist],
4740
- [`sections-${index}-song`, section.song],
4741
- [`sections-${index}-start_time`, String(section.start_time)]
4742
- ]);
4743
- }
4744
- function mixcloudEditUrl2(key) {
4745
- const withLeading = key.startsWith("/") ? key : `/${key}`;
4746
- const path2 = withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
4747
- return `${MIXCLOUD_API2}/upload${path2}edit/`;
4748
- }
4749
4998
  function mixtapeTags2(_mixtape) {
4750
4999
  return ["Drum & Bass", "Fluncle"];
4751
5000
  }
@@ -4790,6 +5039,7 @@ function throwMixcloudError2(status, body) {
4790
5039
  }
4791
5040
  var MIXCLOUD_API2 = "https://api.mixcloud.com", DESCRIPTION_MAX2 = 1000, PICTURE_MAX_BYTES2, COVER_BASE2 = "https://www.fluncle.com/api/mixtape-cover";
4792
5041
  var init_mixtape_mixcloud2 = __esm(() => {
5042
+ init_util();
4793
5043
  init_api();
4794
5044
  init_output();
4795
5045
  init_mixtape_api();
@@ -5107,7 +5357,7 @@ var init_format2 = __esm(() => {
5107
5357
  });
5108
5358
 
5109
5359
  // src/cli.ts
5110
- import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
5360
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
5111
5361
  import path2 from "path";
5112
5362
 
5113
5363
  // ../../node_modules/commander/lib/error.js
@@ -7475,7 +7725,7 @@ function addAdminCommands(program2) {
7475
7725
  adminClips.action(() => {
7476
7726
  adminClips.outputHelp();
7477
7727
  });
7478
- 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) => {
7479
7729
  const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7480
7730
  await runClipsList(options, clipsListCommand2);
7481
7731
  });
@@ -7483,6 +7733,34 @@ function addAdminCommands(program2) {
7483
7733
  const { clipCutCommand: clipCutCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7484
7734
  await runClipsCut(clipId, options, clipCutCommand2);
7485
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
+ });
7486
7764
  const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
7487
7765
  adminNewsletter.action(() => {
7488
7766
  adminNewsletter.outputHelp();
@@ -7586,7 +7864,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
7586
7864
  console.log(` mime: ${result.mime}`);
7587
7865
  }
7588
7866
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
7589
- const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7867
+ const script = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7590
7868
  if (!idOrLogId || !script || !script.trim()) {
7591
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]");
7592
7870
  }
@@ -7642,7 +7920,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
7642
7920
  }
7643
7921
  }
7644
7922
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
7645
- const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
7923
+ const note = options.scriptFile ? readFileSync7(options.scriptFile, "utf8") : options.script;
7646
7924
  if (!idOrLogId || !note || !note.trim()) {
7647
7925
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
7648
7926
  }
@@ -7788,7 +8066,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
7788
8066
  return;
7789
8067
  }
7790
8068
  const candidate = path2.join(dir, name);
7791
- return existsSync6(candidate) ? candidate : undefined;
8069
+ return existsSync8(candidate) ? candidate : undefined;
7792
8070
  };
7793
8071
  const resolveFile = (explicit, name) => {
7794
8072
  if (explicit) {
@@ -8076,7 +8354,11 @@ async function runMixtapeList(options, mixtapeListCommand2) {
8076
8354
  }
8077
8355
  }
8078
8356
  async function runClipsList(options, clipsListCommand2) {
8079
- 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
+ });
8080
8362
  if (options.json) {
8081
8363
  printJson({ clips, ok: true });
8082
8364
  return;
@@ -8086,7 +8368,8 @@ async function runClipsList(options, clipsListCommand2) {
8086
8368
  return;
8087
8369
  }
8088
8370
  for (const clip of clips) {
8089
- 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}`);
8090
8373
  }
8091
8374
  }
8092
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.77.0"
34
+ "version": "0.79.0"
35
35
  }