fluncle 0.68.0 → 0.70.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 +745 -86
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -423,7 +423,7 @@ function parseVersion(version) {
423
423
  var currentVersion;
424
424
  var init_version = __esm(() => {
425
425
  init_output();
426
- currentVersion = "0.68.0".trim() ? "0.68.0".trim() : "0.1.0";
426
+ currentVersion = "0.70.0".trim() ? "0.70.0".trim() : "0.1.0";
427
427
  });
428
428
 
429
429
  // src/update-notifier.ts
@@ -1047,7 +1047,214 @@ var init_mixtape_youtube = __esm(() => {
1047
1047
  init_output();
1048
1048
  });
1049
1049
 
1050
+ // src/commands/mixtape-set-video.ts
1051
+ var exports_mixtape_set_video = {};
1052
+ __export(exports_mixtape_set_video, {
1053
+ stageSetVideo: () => stageSetVideo,
1054
+ renditionFfmpegArgs: () => renditionFfmpegArgs,
1055
+ planMultipart: () => planMultipart,
1056
+ buildCompleteXml: () => buildCompleteXml,
1057
+ SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
1058
+ MIN_PART_SIZE: () => MIN_PART_SIZE,
1059
+ MAX_PARTS: () => MAX_PARTS,
1060
+ DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
1061
+ });
1062
+ import { randomUUID } from "node:crypto";
1063
+ import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
1064
+ import { tmpdir } from "node:os";
1065
+ import { join as join4 } from "node:path";
1066
+ function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
1067
+ if (!Number.isInteger(contentLength) || contentLength <= 0) {
1068
+ throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
1069
+ }
1070
+ let effective = Math.max(partSize, MIN_PART_SIZE);
1071
+ if (Math.ceil(contentLength / effective) > MAX_PARTS) {
1072
+ effective = Math.ceil(contentLength / MAX_PARTS);
1073
+ }
1074
+ const parts = [];
1075
+ let start = 0;
1076
+ let partNumber = 1;
1077
+ while (start < contentLength) {
1078
+ const end = Math.min(start + effective, contentLength);
1079
+ parts.push({ end, partNumber, size: end - start, start });
1080
+ start = end;
1081
+ partNumber += 1;
1082
+ }
1083
+ return { partCount: parts.length, partSize: effective, parts };
1084
+ }
1085
+ function escapeXml(value) {
1086
+ return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
1087
+ }
1088
+ function buildCompleteXml(parts) {
1089
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
1090
+ const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
1091
+ return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
1092
+ }
1093
+ function renditionFfmpegArgs(inputPath, outputPath) {
1094
+ return [
1095
+ "-y",
1096
+ "-i",
1097
+ inputPath,
1098
+ "-vf",
1099
+ `scale=-2:${SET_VIDEO_RENDITION.height}`,
1100
+ "-c:v",
1101
+ "libx264",
1102
+ "-preset",
1103
+ "medium",
1104
+ "-crf",
1105
+ String(SET_VIDEO_RENDITION.crf),
1106
+ "-force_key_frames",
1107
+ `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
1108
+ "-pix_fmt",
1109
+ "yuv420p",
1110
+ "-c:a",
1111
+ "aac",
1112
+ "-b:a",
1113
+ SET_VIDEO_RENDITION.audioBitrate,
1114
+ "-movflags",
1115
+ "+faststart",
1116
+ outputPath
1117
+ ];
1118
+ }
1119
+ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1120
+ if (!existsSync(masterPath)) {
1121
+ throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1122
+ }
1123
+ await assertFfmpeg();
1124
+ const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
1125
+ onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
1126
+ await deriveRendition(masterPath, renditionPath);
1127
+ try {
1128
+ const size = statSync2(renditionPath).size;
1129
+ const plan = planMultipart(size);
1130
+ onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1131
+ const presign = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, { partCount: plan.partCount });
1132
+ const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1133
+ const completed = [];
1134
+ try {
1135
+ for (const part of plan.parts) {
1136
+ const url = urlByPart.get(part.partNumber);
1137
+ if (!url) {
1138
+ throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
1139
+ }
1140
+ onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
1141
+ const etag = await putPart(url, renditionPath, part);
1142
+ completed.push({ etag, partNumber: part.partNumber });
1143
+ }
1144
+ await completeUpload(presign.completeUrl, completed);
1145
+ } catch (error) {
1146
+ await abortUpload(presign.abortUrl).catch(() => {});
1147
+ throw error;
1148
+ }
1149
+ await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
1150
+ onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1151
+ return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1152
+ } finally {
1153
+ rmSync2(renditionPath, { force: true });
1154
+ }
1155
+ }
1156
+ async function putPart(url, path2, part) {
1157
+ const response = await fetch(url, {
1158
+ body: Bun.file(path2).slice(part.start, part.end),
1159
+ method: "PUT"
1160
+ });
1161
+ if (!response.ok) {
1162
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
1163
+ throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
1164
+ }
1165
+ const etag = response.headers.get("etag");
1166
+ if (!etag) {
1167
+ throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1168
+ }
1169
+ return etag;
1170
+ }
1171
+ async function completeUpload(url, parts) {
1172
+ const response = await fetch(url, {
1173
+ body: buildCompleteXml(parts),
1174
+ headers: { "content-type": "application/xml" },
1175
+ method: "POST"
1176
+ });
1177
+ const text = await response.text().catch(() => "");
1178
+ if (!response.ok || text.includes("<Error>")) {
1179
+ throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1180
+ }
1181
+ }
1182
+ async function abortUpload(url) {
1183
+ await fetch(url, { method: "DELETE" });
1184
+ }
1185
+ async function assertFfmpeg() {
1186
+ try {
1187
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1188
+ await proc.exited;
1189
+ if (proc.exitCode !== 0) {
1190
+ throw new Error("ffmpeg -version exited non-zero");
1191
+ }
1192
+ } catch {
1193
+ throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1194
+ }
1195
+ }
1196
+ async function deriveRendition(inputPath, outputPath) {
1197
+ const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1198
+ stderr: "pipe",
1199
+ stdout: "ignore"
1200
+ });
1201
+ await proc.exited;
1202
+ if (proc.exitCode !== 0) {
1203
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1204
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1205
+ }
1206
+ }
1207
+ var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1208
+ var init_mixtape_set_video = __esm(() => {
1209
+ init_api();
1210
+ init_output();
1211
+ SET_VIDEO_RENDITION = {
1212
+ audioBitrate: "192k",
1213
+ crf: 20,
1214
+ gopSeconds: 2,
1215
+ height: 1080
1216
+ };
1217
+ DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1218
+ MIN_PART_SIZE = 5 * 1024 * 1024;
1219
+ });
1220
+
1050
1221
  // src/commands/mixtapes.ts
1222
+ var exports_mixtapes = {};
1223
+ __export(exports_mixtapes, {
1224
+ mixtapesCommand: () => mixtapesCommand,
1225
+ mixtapeUpdateCommand: () => mixtapeUpdateCommand,
1226
+ mixtapePublishCommand: () => mixtapePublishCommand,
1227
+ mixtapeMembersCommand: () => mixtapeMembersCommand,
1228
+ mixtapeListCommand: () => mixtapeListCommand,
1229
+ mixtapeGetCommand: () => mixtapeGetCommand,
1230
+ mixtapeDistributeCommand: () => mixtapeDistributeCommand,
1231
+ mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1232
+ mixtapeCreateCommand: () => mixtapeCreateCommand
1233
+ });
1234
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1235
+ async function mixtapesCommand() {
1236
+ const response = await publicApiGet("/api/mixtapes");
1237
+ return response.mixtapes;
1238
+ }
1239
+ async function mixtapeCreateCommand(options) {
1240
+ return adminApiPost("/api/admin/mixtapes", buildBody(options));
1241
+ }
1242
+ async function mixtapeUpdateCommand(id, options) {
1243
+ return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody(options));
1244
+ }
1245
+ async function mixtapeMembersCommand(id, refs, options) {
1246
+ const members = refs.map((ref) => ({ ref }));
1247
+ if (options.from) {
1248
+ members.push(...parseCueFile(options.from));
1249
+ }
1250
+ return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
1251
+ }
1252
+ async function mixtapePublishCommand(id) {
1253
+ return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
1254
+ }
1255
+ async function mixtapeDeleteCommand(id) {
1256
+ return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
1257
+ }
1051
1258
  async function mixtapeListCommand() {
1052
1259
  const response = await adminApiGet("/api/admin/mixtapes");
1053
1260
  return response.mixtapes;
@@ -1060,6 +1267,206 @@ async function mixtapeGetCommand(idOrLogId) {
1060
1267
  }
1061
1268
  return match;
1062
1269
  }
1270
+ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
1271
+ const mixtape = await mixtapeGetCommand(idOrLogId);
1272
+ if (!mixtape.id) {
1273
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1274
+ }
1275
+ const both = !options.youtube && !options.mixcloud && !options.setVideo;
1276
+ const doYoutube = both || Boolean(options.youtube);
1277
+ const doMixcloud = both || Boolean(options.mixcloud);
1278
+ const doSetVideo = Boolean(options.setVideo);
1279
+ if (doYoutube && !options.video) {
1280
+ throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1281
+ }
1282
+ if (doMixcloud && !options.audio) {
1283
+ throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1284
+ }
1285
+ if (doSetVideo && !options.video) {
1286
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1287
+ }
1288
+ const mixtapeId = mixtape.id;
1289
+ let logId = mixtape.logId;
1290
+ if (!mixtape.durationMs) {
1291
+ const source = options.audio ?? options.video;
1292
+ const durationMs = source ? await probeDurationMs(source) : undefined;
1293
+ if (durationMs) {
1294
+ await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
1295
+ onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1296
+ }
1297
+ }
1298
+ if (mixtape.status === "draft") {
1299
+ onProgress("Minting the coordinate…");
1300
+ const published = await mixtapePublishCommand(mixtapeId);
1301
+ logId = published.mixtape.logId;
1302
+ onProgress(`Minted ${logId}.`);
1303
+ } else if (mixtape.status === "published") {
1304
+ onProgress(`Already published (${logId}); re-distributing.`);
1305
+ } else {
1306
+ onProgress(`Resuming distribution for ${logId}.`);
1307
+ }
1308
+ if (!logId) {
1309
+ throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
1310
+ }
1311
+ const results = [];
1312
+ if (doYoutube) {
1313
+ if (!options.video) {
1314
+ throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1315
+ }
1316
+ onProgress("YouTube: uploading video…");
1317
+ const { distributeYoutube: distributeYoutube2 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
1318
+ const result = await distributeYoutube2(mixtapeId, options.video, onProgress);
1319
+ results.push({ platform: "youtube", url: result.url });
1320
+ onProgress(`YouTube: ${result.url}`);
1321
+ }
1322
+ if (doMixcloud) {
1323
+ if (!options.audio) {
1324
+ throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1325
+ }
1326
+ onProgress("Mixcloud: uploading audio…");
1327
+ const { distributeMixcloud } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
1328
+ const result = await distributeMixcloud(mixtapeId, options.audio, onProgress, options.unlisted);
1329
+ results.push({ platform: "mixcloud", url: result.url });
1330
+ onProgress(`Mixcloud: ${result.url}`);
1331
+ }
1332
+ if (doSetVideo) {
1333
+ if (!options.video) {
1334
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1335
+ }
1336
+ onProgress("Set video: staging the 1080p rendition…");
1337
+ const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1338
+ const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1339
+ results.push({ platform: "set-video", url: result.url });
1340
+ onProgress(`Set video: ${result.url}`);
1341
+ }
1342
+ return { logId, mixtapeId, results };
1343
+ }
1344
+ async function probeDurationMs(filePath) {
1345
+ try {
1346
+ const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
1347
+ const out = await new Response(proc.stdout).text();
1348
+ await proc.exited;
1349
+ const seconds = Number.parseFloat(out.trim());
1350
+ return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
1351
+ } catch {
1352
+ return;
1353
+ }
1354
+ }
1355
+ function buildBody(options) {
1356
+ const body = {};
1357
+ if (options.note !== undefined) {
1358
+ body.note = options.note;
1359
+ }
1360
+ if (options.recordedAt !== undefined) {
1361
+ body.recordedAt = options.recordedAt;
1362
+ }
1363
+ if (options.soundcloudUrl !== undefined) {
1364
+ body.soundcloudUrl = options.soundcloudUrl;
1365
+ }
1366
+ if (options.durationMs !== undefined) {
1367
+ const parsed = parseDuration(options.durationMs);
1368
+ if (parsed === null) {
1369
+ throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
1370
+ }
1371
+ body.durationMs = parsed;
1372
+ }
1373
+ return body;
1374
+ }
1375
+ function parseCueFile(filePath) {
1376
+ if (!existsSync2(filePath)) {
1377
+ throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1378
+ }
1379
+ const text = readFileSync2(filePath, "utf-8");
1380
+ const trimmed = text.trim();
1381
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1382
+ try {
1383
+ const parsed = JSON.parse(trimmed);
1384
+ if (!Array.isArray(parsed)) {
1385
+ throw new Error("not an array");
1386
+ }
1387
+ return parsed.map((entry, index) => {
1388
+ if (typeof entry === "string") {
1389
+ return { ref: entry.trim() };
1390
+ }
1391
+ const obj = entry;
1392
+ if (typeof obj?.ref !== "string") {
1393
+ throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
1394
+ }
1395
+ const cue = { ref: obj.ref.trim() };
1396
+ if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
1397
+ cue.startMs = obj.startMs;
1398
+ }
1399
+ return cue;
1400
+ });
1401
+ } catch (error) {
1402
+ if (error instanceof CliError2) {
1403
+ throw error;
1404
+ }
1405
+ throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
1406
+ }
1407
+ }
1408
+ return parseCueSheet(text);
1409
+ }
1410
+ function parseCueSheet(text) {
1411
+ const entries = [];
1412
+ for (const line of text.split(/\r?\n/)) {
1413
+ const trimmed = line.trim();
1414
+ if (!trimmed || trimmed.startsWith("#")) {
1415
+ continue;
1416
+ }
1417
+ const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
1418
+ if (match) {
1419
+ const [, time, ref] = match;
1420
+ if (time === undefined || ref === undefined) {
1421
+ continue;
1422
+ }
1423
+ const startMs = parseDuration(time);
1424
+ if (startMs === null) {
1425
+ continue;
1426
+ }
1427
+ entries.push({ ref: ref.trim(), startMs });
1428
+ } else {
1429
+ entries.push({ ref: trimmed });
1430
+ }
1431
+ }
1432
+ return entries;
1433
+ }
1434
+ function parseDuration(input) {
1435
+ const trimmed = input.trim();
1436
+ if (!trimmed) {
1437
+ return null;
1438
+ }
1439
+ if (trimmed.includes(":")) {
1440
+ const parts = trimmed.split(":");
1441
+ if (parts.length !== 2 && parts.length !== 3) {
1442
+ return null;
1443
+ }
1444
+ const nums = parts.map((part) => Number(part));
1445
+ if (nums.some((n) => !Number.isFinite(n) || n < 0)) {
1446
+ return null;
1447
+ }
1448
+ if (parts.length === 3) {
1449
+ const [hours, minutes2, seconds2] = nums;
1450
+ if (hours === undefined || minutes2 === undefined || seconds2 === undefined) {
1451
+ return null;
1452
+ }
1453
+ if (minutes2 >= 60 || seconds2 >= 60) {
1454
+ return null;
1455
+ }
1456
+ return Math.round((hours * 3600 + minutes2 * 60 + seconds2) * 1000);
1457
+ }
1458
+ const [minutes, seconds] = nums;
1459
+ if (minutes === undefined || seconds === undefined) {
1460
+ return null;
1461
+ }
1462
+ if (seconds >= 60) {
1463
+ return null;
1464
+ }
1465
+ return Math.round((minutes * 60 + seconds) * 1000);
1466
+ }
1467
+ const value = Number(trimmed);
1468
+ return Number.isFinite(value) && value >= 0 ? value : null;
1469
+ }
1063
1470
  var init_mixtapes = __esm(() => {
1064
1471
  init_api();
1065
1472
  init_output();
@@ -1213,40 +1620,40 @@ var init_mixtape_mixcloud = __esm(() => {
1213
1620
  });
1214
1621
 
1215
1622
  // src/commands/mixtapes.ts
1216
- var exports_mixtapes = {};
1217
- __export(exports_mixtapes, {
1218
- mixtapesCommand: () => mixtapesCommand,
1219
- mixtapeUpdateCommand: () => mixtapeUpdateCommand,
1220
- mixtapePublishCommand: () => mixtapePublishCommand,
1221
- mixtapeMembersCommand: () => mixtapeMembersCommand,
1623
+ var exports_mixtapes2 = {};
1624
+ __export(exports_mixtapes2, {
1625
+ mixtapesCommand: () => mixtapesCommand2,
1626
+ mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
1627
+ mixtapePublishCommand: () => mixtapePublishCommand2,
1628
+ mixtapeMembersCommand: () => mixtapeMembersCommand2,
1222
1629
  mixtapeListCommand: () => mixtapeListCommand2,
1223
1630
  mixtapeGetCommand: () => mixtapeGetCommand2,
1224
- mixtapeDistributeCommand: () => mixtapeDistributeCommand,
1225
- mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1226
- mixtapeCreateCommand: () => mixtapeCreateCommand
1631
+ mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
1632
+ mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
1633
+ mixtapeCreateCommand: () => mixtapeCreateCommand2
1227
1634
  });
1228
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1229
- async function mixtapesCommand() {
1635
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1636
+ async function mixtapesCommand2() {
1230
1637
  const response = await publicApiGet("/api/mixtapes");
1231
1638
  return response.mixtapes;
1232
1639
  }
1233
- async function mixtapeCreateCommand(options) {
1234
- return adminApiPost("/api/admin/mixtapes", buildBody(options));
1640
+ async function mixtapeCreateCommand2(options) {
1641
+ return adminApiPost("/api/admin/mixtapes", buildBody2(options));
1235
1642
  }
1236
- async function mixtapeUpdateCommand(id, options) {
1237
- return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody(options));
1643
+ async function mixtapeUpdateCommand2(id, options) {
1644
+ return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
1238
1645
  }
1239
- async function mixtapeMembersCommand(id, refs, options) {
1646
+ async function mixtapeMembersCommand2(id, refs, options) {
1240
1647
  const members = refs.map((ref) => ({ ref }));
1241
1648
  if (options.from) {
1242
- members.push(...parseCueFile(options.from));
1649
+ members.push(...parseCueFile2(options.from));
1243
1650
  }
1244
1651
  return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
1245
1652
  }
1246
- async function mixtapePublishCommand(id) {
1653
+ async function mixtapePublishCommand2(id) {
1247
1654
  return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
1248
1655
  }
1249
- async function mixtapeDeleteCommand(id) {
1656
+ async function mixtapeDeleteCommand2(id) {
1250
1657
  return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
1251
1658
  }
1252
1659
  async function mixtapeListCommand2() {
@@ -1261,33 +1668,37 @@ async function mixtapeGetCommand2(idOrLogId) {
1261
1668
  }
1262
1669
  return match;
1263
1670
  }
1264
- async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
1671
+ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
1265
1672
  const mixtape = await mixtapeGetCommand2(idOrLogId);
1266
1673
  if (!mixtape.id) {
1267
1674
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1268
1675
  }
1269
- const both = !options.youtube && !options.mixcloud;
1676
+ const both = !options.youtube && !options.mixcloud && !options.setVideo;
1270
1677
  const doYoutube = both || Boolean(options.youtube);
1271
1678
  const doMixcloud = both || Boolean(options.mixcloud);
1679
+ const doSetVideo = Boolean(options.setVideo);
1272
1680
  if (doYoutube && !options.video) {
1273
1681
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1274
1682
  }
1275
1683
  if (doMixcloud && !options.audio) {
1276
1684
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1277
1685
  }
1686
+ if (doSetVideo && !options.video) {
1687
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1688
+ }
1278
1689
  const mixtapeId = mixtape.id;
1279
1690
  let logId = mixtape.logId;
1280
1691
  if (!mixtape.durationMs) {
1281
1692
  const source = options.audio ?? options.video;
1282
- const durationMs = source ? await probeDurationMs(source) : undefined;
1693
+ const durationMs = source ? await probeDurationMs2(source) : undefined;
1283
1694
  if (durationMs) {
1284
- await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
1695
+ await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
1285
1696
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1286
1697
  }
1287
1698
  }
1288
1699
  if (mixtape.status === "draft") {
1289
1700
  onProgress("Minting the coordinate…");
1290
- const published = await mixtapePublishCommand(mixtapeId);
1701
+ const published = await mixtapePublishCommand2(mixtapeId);
1291
1702
  logId = published.mixtape.logId;
1292
1703
  onProgress(`Minted ${logId}.`);
1293
1704
  } else if (mixtape.status === "published") {
@@ -1319,9 +1730,19 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1319
1730
  results.push({ platform: "mixcloud", url: result.url });
1320
1731
  onProgress(`Mixcloud: ${result.url}`);
1321
1732
  }
1733
+ if (doSetVideo) {
1734
+ if (!options.video) {
1735
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1736
+ }
1737
+ onProgress("Set video: staging the 1080p rendition…");
1738
+ const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1739
+ const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1740
+ results.push({ platform: "set-video", url: result.url });
1741
+ onProgress(`Set video: ${result.url}`);
1742
+ }
1322
1743
  return { logId, mixtapeId, results };
1323
1744
  }
1324
- async function probeDurationMs(filePath) {
1745
+ async function probeDurationMs2(filePath) {
1325
1746
  try {
1326
1747
  const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
1327
1748
  const out = await new Response(proc.stdout).text();
@@ -1332,7 +1753,7 @@ async function probeDurationMs(filePath) {
1332
1753
  return;
1333
1754
  }
1334
1755
  }
1335
- function buildBody(options) {
1756
+ function buildBody2(options) {
1336
1757
  const body = {};
1337
1758
  if (options.note !== undefined) {
1338
1759
  body.note = options.note;
@@ -1344,7 +1765,7 @@ function buildBody(options) {
1344
1765
  body.soundcloudUrl = options.soundcloudUrl;
1345
1766
  }
1346
1767
  if (options.durationMs !== undefined) {
1347
- const parsed = parseDuration(options.durationMs);
1768
+ const parsed = parseDuration2(options.durationMs);
1348
1769
  if (parsed === null) {
1349
1770
  throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
1350
1771
  }
@@ -1352,11 +1773,11 @@ function buildBody(options) {
1352
1773
  }
1353
1774
  return body;
1354
1775
  }
1355
- function parseCueFile(filePath) {
1356
- if (!existsSync(filePath)) {
1776
+ function parseCueFile2(filePath) {
1777
+ if (!existsSync3(filePath)) {
1357
1778
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1358
1779
  }
1359
- const text = readFileSync2(filePath, "utf-8");
1780
+ const text = readFileSync3(filePath, "utf-8");
1360
1781
  const trimmed = text.trim();
1361
1782
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1362
1783
  try {
@@ -1385,9 +1806,9 @@ function parseCueFile(filePath) {
1385
1806
  throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
1386
1807
  }
1387
1808
  }
1388
- return parseCueSheet(text);
1809
+ return parseCueSheet2(text);
1389
1810
  }
1390
- function parseCueSheet(text) {
1811
+ function parseCueSheet2(text) {
1391
1812
  const entries = [];
1392
1813
  for (const line of text.split(/\r?\n/)) {
1393
1814
  const trimmed = line.trim();
@@ -1400,7 +1821,7 @@ function parseCueSheet(text) {
1400
1821
  if (time === undefined || ref === undefined) {
1401
1822
  continue;
1402
1823
  }
1403
- const startMs = parseDuration(time);
1824
+ const startMs = parseDuration2(time);
1404
1825
  if (startMs === null) {
1405
1826
  continue;
1406
1827
  }
@@ -1411,7 +1832,7 @@ function parseCueSheet(text) {
1411
1832
  }
1412
1833
  return entries;
1413
1834
  }
1414
- function parseDuration(input) {
1835
+ function parseDuration2(input) {
1415
1836
  const trimmed = input.trim();
1416
1837
  if (!trimmed) {
1417
1838
  return null;
@@ -2138,7 +2559,7 @@ function parseVersion2(version) {
2138
2559
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2139
2560
  var init_version2 = __esm(() => {
2140
2561
  init_output();
2141
- currentVersion2 = "0.68.0".trim() ? "0.68.0".trim() : "0.1.0";
2562
+ currentVersion2 = "0.70.0".trim() ? "0.70.0".trim() : "0.1.0";
2142
2563
  });
2143
2564
 
2144
2565
  // ../../packages/registry/src/index.ts
@@ -2673,10 +3094,8 @@ var init_src = __esm(() => {
2673
3094
  ],
2674
3095
  kind: "extension",
2675
3096
  name: "extension.lens",
2676
- operatorNotes: "Fluncle Lens (apps/extension), MV3. PENDING Chrome Web Store review DARK until approval, then flip `pending` + set the assigned listing URL. The placeholder `url` is the store search for the listing; probeConfig is the post-approval /status check.",
2677
- pending: true,
2678
- probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
2679
- url: "https://chromewebstore.google.com/search/Fluncle%20Lens",
3097
+ operatorNotes: "Fluncle Lens (apps/extension), MV3, LIVE on the Chrome Web Store (published 2026-06-29). Store listing reachability is Google's, not ours, so it is not on the /status board.",
3098
+ url: "https://chromewebstore.google.com/detail/efkkceaofendabikblfjhoepgejfpakk",
2680
3099
  weights: { web: "secondary" }
2681
3100
  },
2682
3101
  {
@@ -2894,7 +3313,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
2894
3313
  const detail = (await response.text().catch(() => "")).slice(0, 300);
2895
3314
  throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
2896
3315
  }
2897
- urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
3316
+ urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
2898
3317
  }
2899
3318
  const manifest = files.render ? await readManifestFields(files.render) : {};
2900
3319
  const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
@@ -3004,7 +3423,7 @@ async function trackNoteCommand(idOrLogId, options) {
3004
3423
  const body = { note: options.note };
3005
3424
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3006
3425
  }
3007
- var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
3426
+ var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
3008
3427
  var init_track = __esm(() => {
3009
3428
  init_api();
3010
3429
  init_output();
@@ -3437,7 +3856,7 @@ __export(exports_mixtape_youtube2, {
3437
3856
  distributeYoutube: () => distributeYoutube2,
3438
3857
  authYoutubeCommand: () => authYoutubeCommand2
3439
3858
  });
3440
- import { statSync as statSync2 } from "node:fs";
3859
+ import { statSync as statSync3 } from "node:fs";
3441
3860
  async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
3442
3861
  const contentLength = fileSize2(videoPath);
3443
3862
  const contentType = "video/mp4";
@@ -3569,7 +3988,7 @@ async function resolveMixtapeId2(idOrLogId) {
3569
3988
  }
3570
3989
  function fileSize2(path2) {
3571
3990
  try {
3572
- return statSync2(path2).size;
3991
+ return statSync3(path2).size;
3573
3992
  } catch {
3574
3993
  throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
3575
3994
  }
@@ -3589,16 +4008,197 @@ var init_mixtape_youtube2 = __esm(() => {
3589
4008
  init_output();
3590
4009
  });
3591
4010
 
4011
+ // src/commands/clips.ts
4012
+ var exports_clips = {};
4013
+ __export(exports_clips, {
4014
+ setVideoUrl: () => setVideoUrl,
4015
+ escapeDrawtextValue: () => escapeDrawtextValue,
4016
+ clipsListCommand: () => clipsListCommand,
4017
+ clipFootageKey: () => clipFootageKey,
4018
+ clipCutVideoFilter: () => clipCutVideoFilter,
4019
+ clipCutFfmpegArgs: () => clipCutFfmpegArgs,
4020
+ clipCutCommand: () => clipCutCommand,
4021
+ MAX_CLIP_BYTES: () => MAX_CLIP_BYTES,
4022
+ CLIP_WIDTH: () => CLIP_WIDTH,
4023
+ CLIP_MAXRATE: () => CLIP_MAXRATE,
4024
+ CLIP_HEIGHT: () => CLIP_HEIGHT,
4025
+ CLIP_CRF: () => CLIP_CRF,
4026
+ CLIP_BUFSIZE: () => CLIP_BUFSIZE,
4027
+ CLIP_AUDIO_BITRATE: () => CLIP_AUDIO_BITRATE
4028
+ });
4029
+ import { randomUUID as randomUUID2 } from "node:crypto";
4030
+ import { rmSync as rmSync3, statSync as statSync4 } from "node:fs";
4031
+ import { tmpdir as tmpdir2 } from "node:os";
4032
+ import { join as join5 } from "node:path";
4033
+ function clipFootageKey(clipId) {
4034
+ return `${clipId}/footage.mp4`;
4035
+ }
4036
+ function setVideoUrl(logId) {
4037
+ return `${FOUND_BASE3}/${encodeURIComponent(logId)}/set.mp4`;
4038
+ }
4039
+ function escapeDrawtextValue(text) {
4040
+ return text.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/'/g, "'\\''");
4041
+ }
4042
+ function clipCutVideoFilter(options) {
4043
+ const xOffset = Math.max(0, Math.round(options.xOffset));
4044
+ const crop = `crop=ih*9/16:ih:${xOffset}:0,scale=${CLIP_WIDTH}:${CLIP_HEIGHT}`;
4045
+ const font = options.fontFile ? `:fontfile='${options.fontFile}'` : "";
4046
+ const title = escapeDrawtextValue(options.title);
4047
+ const coordinate2 = escapeDrawtextValue(`fluncle://${options.logId}`);
4048
+ const titleDraw = `drawtext=text='${title}'${font}:fontcolor=white:fontsize=46:` + `x=56:y=h-208:box=1:boxcolor=black@0.55:boxborderw=18`;
4049
+ const coordinateDraw = `drawtext=text='${coordinate2}'${font}:fontcolor=0xF4EAD7:fontsize=30:` + `x=56:y=h-132:box=1:boxcolor=black@0.55:boxborderw=12`;
4050
+ return `${crop},${titleDraw},${coordinateDraw}`;
4051
+ }
4052
+ function clipCutFfmpegArgs(options) {
4053
+ const inSeconds = (options.inMs / 1000).toFixed(3);
4054
+ const durationSeconds = ((options.outMs - options.inMs) / 1000).toFixed(3);
4055
+ const filter = clipCutVideoFilter(options);
4056
+ return [
4057
+ "-y",
4058
+ "-ss",
4059
+ inSeconds,
4060
+ "-i",
4061
+ options.setUrl,
4062
+ "-t",
4063
+ durationSeconds,
4064
+ "-vf",
4065
+ filter,
4066
+ "-c:v",
4067
+ "libx264",
4068
+ "-preset",
4069
+ "veryfast",
4070
+ "-crf",
4071
+ String(CLIP_CRF),
4072
+ "-maxrate",
4073
+ CLIP_MAXRATE,
4074
+ "-bufsize",
4075
+ CLIP_BUFSIZE,
4076
+ "-pix_fmt",
4077
+ "yuv420p",
4078
+ "-c:a",
4079
+ "aac",
4080
+ "-b:a",
4081
+ CLIP_AUDIO_BITRATE,
4082
+ "-movflags",
4083
+ "+faststart",
4084
+ options.outputPath
4085
+ ];
4086
+ }
4087
+ async function clipsListCommand(filter = {}) {
4088
+ const params = new URLSearchParams;
4089
+ if (filter.mixtapeId) {
4090
+ params.set("mixtapeId", filter.mixtapeId);
4091
+ }
4092
+ if (filter.status) {
4093
+ params.set("status", filter.status);
4094
+ }
4095
+ const query = params.toString();
4096
+ const response = await adminApiGet(`/api/admin/clips${query ? `?${query}` : ""}`);
4097
+ return response.clips;
4098
+ }
4099
+ async function clipCutCommand(clipId, onProgress = () => {}) {
4100
+ const clips = await clipsListCommand();
4101
+ const clip = clips.find((candidate) => candidate.id === clipId);
4102
+ if (!clip) {
4103
+ throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
4104
+ }
4105
+ const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
4106
+ const mixtape = await mixtapeGetCommand3(clip.mixtapeId);
4107
+ if (!mixtape.logId) {
4108
+ throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
4109
+ }
4110
+ if (!mixtape.setVideoAt) {
4111
+ throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4112
+ }
4113
+ await assertFfmpeg2();
4114
+ const setUrl = setVideoUrl(mixtape.logId);
4115
+ const outputPath = join5(tmpdir2(), `fluncle-clip-${randomUUID2()}.mp4`);
4116
+ try {
4117
+ onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms] from ${mixtape.logId}…`);
4118
+ await runClipCut({
4119
+ fontFile: process.env.CLIP_FONT_FILE,
4120
+ inMs: clip.inMs,
4121
+ logId: mixtape.logId,
4122
+ outMs: clip.outMs,
4123
+ outputPath,
4124
+ setUrl,
4125
+ title: mixtape.title,
4126
+ xOffset: clip.xOffset
4127
+ });
4128
+ const sizeBytes = statSync4(outputPath).size;
4129
+ if (sizeBytes > MAX_CLIP_BYTES) {
4130
+ throw new CliError2("clip_too_large", `The cut is ${(sizeBytes / 1e6).toFixed(1)} MB (> 100 MB Cloudflare MT ceiling) — shorten the window or lower the bitrate cap`);
4131
+ }
4132
+ onProgress(`Clip ${clipId}: uploading ${(sizeBytes / 1e6).toFixed(1)} MB…`);
4133
+ const presign = await adminApiPost(`/api/admin/clips/${encodeURIComponent(clipId)}/cut/presign`, { contentType: "video/mp4" });
4134
+ await putClip(presign.url, presign.contentType, outputPath);
4135
+ await adminApiPost(`/api/admin/clips/${encodeURIComponent(clipId)}/cut/finalize`);
4136
+ onProgress(`Clip ${clipId}: done → ${FOUND_BASE3}/${presign.key}`);
4137
+ return {
4138
+ clipId,
4139
+ key: presign.key,
4140
+ logId: mixtape.logId,
4141
+ sizeBytes,
4142
+ url: `${FOUND_BASE3}/${presign.key}`
4143
+ };
4144
+ } finally {
4145
+ rmSync3(outputPath, { force: true });
4146
+ }
4147
+ }
4148
+ async function putClip(url, contentType, path2) {
4149
+ const response = await fetch(url, {
4150
+ body: Bun.file(path2),
4151
+ headers: { "content-type": contentType },
4152
+ method: "PUT"
4153
+ });
4154
+ if (!response.ok) {
4155
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
4156
+ throw new CliError2("r2_put_failed", `R2 rejected the clip upload (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
4157
+ }
4158
+ }
4159
+ async function assertFfmpeg2() {
4160
+ try {
4161
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
4162
+ await proc.exited;
4163
+ if (proc.exitCode !== 0) {
4164
+ throw new Error("ffmpeg -version exited non-zero");
4165
+ }
4166
+ } catch {
4167
+ throw new CliError2("ffmpeg_missing", "The clip cut needs ffmpeg. Install it on the box (apt-get install -y ffmpeg).");
4168
+ }
4169
+ }
4170
+ async function runClipCut(options) {
4171
+ if (!options.setUrl) {
4172
+ throw new CliError2("missing_set_url", "The clip cut needs the set rendition URL");
4173
+ }
4174
+ const proc = Bun.spawn(["ffmpeg", ...clipCutFfmpegArgs(options)], {
4175
+ stderr: "pipe",
4176
+ stdout: "ignore"
4177
+ });
4178
+ await proc.exited;
4179
+ if (proc.exitCode !== 0) {
4180
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
4181
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to cut the clip${detail ? `: ${detail}` : ""}`);
4182
+ }
4183
+ }
4184
+ var FOUND_BASE3 = "https://found.fluncle.com", CLIP_WIDTH = 1080, CLIP_HEIGHT = 1920, CLIP_CRF = 21, CLIP_MAXRATE = "10M", CLIP_BUFSIZE = "20M", CLIP_AUDIO_BITRATE = "192k", MAX_CLIP_BYTES;
4185
+ var init_clips = __esm(() => {
4186
+ init_api();
4187
+ init_output();
4188
+ MAX_CLIP_BYTES = 100 * 1024 * 1024;
4189
+ });
4190
+
3592
4191
  // src/commands/newsletter.ts
3593
4192
  var exports_newsletter = {};
3594
4193
  __export(exports_newsletter, {
3595
4194
  newsletterUpdateCommand: () => newsletterUpdateCommand,
3596
4195
  newsletterSendCommand: () => newsletterSendCommand,
3597
4196
  newsletterListCommand: () => newsletterListCommand,
3598
- newsletterDraftCommand: () => newsletterDraftCommand
4197
+ newsletterDraftCommand: () => newsletterDraftCommand,
4198
+ newsletterDeleteCommand: () => newsletterDeleteCommand
3599
4199
  });
3600
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
3601
- function buildBody2(options, { requireContent }) {
4200
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
4201
+ function buildBody3(options, { requireContent }) {
3602
4202
  const body = {};
3603
4203
  if (options.contentFile !== undefined) {
3604
4204
  body.contentJson = readContentFile(options.contentFile);
@@ -3617,10 +4217,10 @@ function buildBody2(options, { requireContent }) {
3617
4217
  return body;
3618
4218
  }
3619
4219
  function readContentFile(filePath) {
3620
- if (!existsSync2(filePath)) {
4220
+ if (!existsSync4(filePath)) {
3621
4221
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
3622
4222
  }
3623
- const text = readFileSync3(filePath, "utf-8");
4223
+ const text = readFileSync4(filePath, "utf-8");
3624
4224
  try {
3625
4225
  return JSON.parse(text);
3626
4226
  } catch (error) {
@@ -3628,10 +4228,10 @@ function readContentFile(filePath) {
3628
4228
  }
3629
4229
  }
3630
4230
  async function newsletterDraftCommand(options) {
3631
- return adminApiPost("/api/admin/newsletter/editions", buildBody2(options, { requireContent: true }));
4231
+ return adminApiPost("/api/admin/newsletter/editions", buildBody3(options, { requireContent: true }));
3632
4232
  }
3633
4233
  async function newsletterUpdateCommand(id, options) {
3634
- return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`, buildBody2(options, { requireContent: false }));
4234
+ return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`, buildBody3(options, { requireContent: false }));
3635
4235
  }
3636
4236
  async function newsletterSendCommand(id) {
3637
4237
  return adminApiPost(`/api/admin/newsletter/editions/${encodeURIComponent(id)}/send`);
@@ -3640,6 +4240,9 @@ async function newsletterListCommand() {
3640
4240
  const response = await adminApiGet("/api/admin/newsletter/editions");
3641
4241
  return response.editions;
3642
4242
  }
4243
+ async function newsletterDeleteCommand(id) {
4244
+ return adminApiDelete(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`);
4245
+ }
3643
4246
  var init_newsletter = __esm(() => {
3644
4247
  init_api();
3645
4248
  init_output();
@@ -4254,7 +4857,7 @@ var COORD_FALLBACK2 = "—";
4254
4857
  var init_format2 = () => {};
4255
4858
 
4256
4859
  // src/cli.ts
4257
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
4860
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
4258
4861
  import path2 from "path";
4259
4862
 
4260
4863
  // ../../node_modules/commander/lib/error.js
@@ -6416,8 +7019,8 @@ function addListenCommands(program2) {
6416
7019
  await runRecent(options, recentCommand3);
6417
7020
  });
6418
7021
  program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
6419
- const { mixtapesCommand: mixtapesCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6420
- await runMixtapes(options, mixtapesCommand2);
7022
+ const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7023
+ await runMixtapes(options, mixtapesCommand3);
6421
7024
  });
6422
7025
  program2.command("open").description("Pick a track, open it in Spotify").argument("[target]").option("--app", "Open in the native app", false).option("--browser", "Open in the browser", false).option("--limit <limit>", "Number of recent tracks to choose from", "20").allowExcessArguments().action(async (target, options) => {
6423
7026
  const openCommands = await Promise.resolve().then(() => (init_open(), exports_open));
@@ -6587,41 +7190,53 @@ function addAdminCommands(program2) {
6587
7190
  adminMixtapes.outputHelp();
6588
7191
  });
6589
7192
  adminMixtapes.command("create").description("Log a new mixtape draft").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (options) => {
6590
- const { mixtapeCreateCommand: mixtapeCreateCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6591
- await runMixtapeCreate(options, mixtapeCreateCommand2);
7193
+ const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7194
+ await runMixtapeCreate(options, mixtapeCreateCommand3);
6592
7195
  });
6593
7196
  adminMixtapes.command("update").description("Update a mixtape's fields").argument("[id]").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (id, options) => {
6594
- const { mixtapeUpdateCommand: mixtapeUpdateCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6595
- await runMixtapeUpdate(id, options, mixtapeUpdateCommand2);
7197
+ const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7198
+ await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
6596
7199
  });
6597
7200
  adminMixtapes.command("members").description("Set a mixtape's tracklist (refs and/or a cue-sheet file)").argument("[id]").argument("[refs...]").option("--from <file>", "Cue-sheet or JSON file with members").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, refs, options) => {
6598
- const { mixtapeMembersCommand: mixtapeMembersCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6599
- await runMixtapeMembers(id, refs, options, mixtapeMembersCommand2);
7201
+ const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7202
+ await runMixtapeMembers(id, refs, options, mixtapeMembersCommand3);
6600
7203
  });
6601
7204
  adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
6602
- const { mixtapePublishCommand: mixtapePublishCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6603
- await runMixtapePublish(id, options, mixtapePublishCommand2);
7205
+ const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7206
+ await runMixtapePublish(id, options, mixtapePublishCommand3);
6604
7207
  });
6605
7208
  adminMixtapes.command("delete").description("Discard a mixtape draft (published mixtapes can't be deleted)").argument("[id]").option("--json", "Print JSON", false).option("--yes", "Skip confirmation", false).allowExcessArguments().action(async (id, options) => {
6606
- const { mixtapeDeleteCommand: mixtapeDeleteCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6607
- await runMixtapeDelete(id, options, mixtapeDeleteCommand2);
7209
+ const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7210
+ await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
6608
7211
  });
6609
7212
  adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
6610
- const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
7213
+ const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
6611
7214
  await runMixtapeList(options, mixtapeListCommand3);
6612
7215
  });
6613
7216
  adminMixtapes.command("get").description("Show one mixtape by id or log id").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6614
- const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
7217
+ const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
6615
7218
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
6616
7219
  });
6617
- adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6618
- const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6619
- await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
7220
+ adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--set-video", "Also stage a 1080p set-video rendition (from --video) to R2 + flip setVideoAt for the /log player").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7221
+ const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7222
+ await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
6620
7223
  });
6621
7224
  adminMixtapes.command("publish-youtube").description("Flip a distributed mixtape's YouTube video from unlisted to public").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
6622
7225
  const { publishYoutubeCommand: publishYoutubeCommand3 } = await Promise.resolve().then(() => (init_mixtape_youtube2(), exports_mixtape_youtube2));
6623
7226
  await runMixtapePublishYoutube(idOrLogId, options, publishYoutubeCommand3);
6624
7227
  });
7228
+ const adminClips = configureCommand(admin.command("clips").description("Mixtape clip (Fluncle Studio) commands"));
7229
+ adminClips.action(() => {
7230
+ adminClips.outputHelp();
7231
+ });
7232
+ 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) => {
7233
+ const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7234
+ await runClipsList(options, clipsListCommand2);
7235
+ });
7236
+ adminClips.command("cut").description("Cut one clip's framed 9:16 footage from its set rendition, then ship it").argument("[clipId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (clipId, options) => {
7237
+ const { clipCutCommand: clipCutCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
7238
+ await runClipsCut(clipId, options, clipCutCommand2);
7239
+ });
6625
7240
  const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
6626
7241
  adminNewsletter.action(() => {
6627
7242
  adminNewsletter.outputHelp();
@@ -6642,6 +7257,10 @@ function addAdminCommands(program2) {
6642
7257
  const { newsletterListCommand: newsletterListCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
6643
7258
  await runNewsletterList(options, newsletterListCommand2);
6644
7259
  });
7260
+ adminNewsletter.command("delete").description("Delete an edition (draft or sent) \u2014 OPERATOR only; reopens the send window").argument("[id]").option("--yes", "Confirm the delete", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7261
+ const { newsletterDeleteCommand: newsletterDeleteCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
7262
+ await runNewsletterDelete(id, options, newsletterDeleteCommand2);
7263
+ });
6645
7264
  const submissions = configureCommand(admin.command("submissions").description("Review listener submissions"));
6646
7265
  submissions.option("--json", "Print JSON", false).action(async (options) => {
6647
7266
  const { listSubmissionsCommand: listSubmissionsCommand2 } = await Promise.resolve().then(() => (init_submissions(), exports_submissions));
@@ -6721,7 +7340,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
6721
7340
  console.log(` mime: ${result.mime}`);
6722
7341
  }
6723
7342
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
6724
- const script = options.scriptFile ? readFileSync4(options.scriptFile, "utf8") : options.script;
7343
+ const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
6725
7344
  if (!idOrLogId || !script || !script.trim()) {
6726
7345
  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]");
6727
7346
  }
@@ -6777,7 +7396,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
6777
7396
  }
6778
7397
  }
6779
7398
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
6780
- const note = options.scriptFile ? readFileSync4(options.scriptFile, "utf8") : options.script;
7399
+ const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
6781
7400
  if (!idOrLogId || !note || !note.trim()) {
6782
7401
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
6783
7402
  }
@@ -6923,7 +7542,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
6923
7542
  return;
6924
7543
  }
6925
7544
  const candidate = path2.join(dir, name);
6926
- return existsSync3(candidate) ? candidate : undefined;
7545
+ return existsSync5(candidate) ? candidate : undefined;
6927
7546
  };
6928
7547
  const resolveFile = (explicit, name) => {
6929
7548
  if (explicit) {
@@ -7095,56 +7714,56 @@ async function runTrackPurgeVideo(idOrLogId, options, trackPurgeVideoCommand2) {
7095
7714
  }
7096
7715
  console.log(result.noVideo ? `${result.logId} has no video \u2014 nothing to purge.` : `Purging the stale renditions for ${result.logId} from the edge. The next play picks up the fresh render.`);
7097
7716
  }
7098
- async function runMixtapeCreate(options, mixtapeCreateCommand2) {
7099
- const result = await mixtapeCreateCommand2(options);
7717
+ async function runMixtapeCreate(options, mixtapeCreateCommand3) {
7718
+ const result = await mixtapeCreateCommand3(options);
7100
7719
  if (options.json) {
7101
7720
  printJson(result);
7102
7721
  return;
7103
7722
  }
7104
7723
  console.log(`Logged draft ${result.mixtape.id}. It stays a draft until you publish it.`);
7105
7724
  }
7106
- async function runMixtapeUpdate(id, options, mixtapeUpdateCommand2) {
7725
+ async function runMixtapeUpdate(id, options, mixtapeUpdateCommand3) {
7107
7726
  if (!id) {
7108
7727
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes update <id>");
7109
7728
  }
7110
- const result = await mixtapeUpdateCommand2(id, options);
7729
+ const result = await mixtapeUpdateCommand3(id, options);
7111
7730
  if (options.json) {
7112
7731
  printJson(result);
7113
7732
  return;
7114
7733
  }
7115
7734
  console.log(`Saved ${result.mixtape.id}.`);
7116
7735
  }
7117
- async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand2) {
7736
+ async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand3) {
7118
7737
  if (!id) {
7119
7738
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes members <id> [refs...]");
7120
7739
  }
7121
7740
  if (refs.length === 0 && !options.from) {
7122
7741
  throw new Error("Provide refs as arguments or a cue-sheet file with --from");
7123
7742
  }
7124
- const result = await mixtapeMembersCommand2(id, refs, options);
7743
+ const result = await mixtapeMembersCommand3(id, refs, options);
7125
7744
  if (options.json) {
7126
7745
  printJson(result);
7127
7746
  return;
7128
7747
  }
7129
7748
  console.log(`Saved the tracklist: ${result.mixtape.memberCount} bangers on ${result.mixtape.id}.`);
7130
7749
  }
7131
- async function runMixtapePublish(id, options, mixtapePublishCommand2) {
7750
+ async function runMixtapePublish(id, options, mixtapePublishCommand3) {
7132
7751
  if (!id) {
7133
7752
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes publish <id>");
7134
7753
  }
7135
- const result = await mixtapePublishCommand2(id);
7754
+ const result = await mixtapePublishCommand3(id);
7136
7755
  if (options.json) {
7137
7756
  printJson(result);
7138
7757
  return;
7139
7758
  }
7140
7759
  console.log(`Minted ${result.mixtape.logId} (fluncle://${result.mixtape.logId}) \u2014 distributing. ` + "Run `distribute` to push it to the platforms.");
7141
7760
  }
7142
- async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2) {
7761
+ async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3) {
7143
7762
  if (!idOrLogId) {
7144
7763
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes distribute <idOrLogId> --video <mp4> --audio <file>");
7145
7764
  }
7146
7765
  const onProgress = options.json ? () => {} : (message) => console.log(message);
7147
- const result = await mixtapeDistributeCommand2(idOrLogId, options, onProgress);
7766
+ const result = await mixtapeDistributeCommand3(idOrLogId, options, onProgress);
7148
7767
  if (options.json) {
7149
7768
  printJson(result);
7150
7769
  return;
@@ -7165,14 +7784,14 @@ async function runMixtapePublishYoutube(idOrLogId, options, publishYoutubeComman
7165
7784
  }
7166
7785
  console.log(`YouTube video is now public: ${result.url}`);
7167
7786
  }
7168
- async function runMixtapeDelete(id, options, mixtapeDeleteCommand2) {
7787
+ async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
7169
7788
  if (!id) {
7170
7789
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes delete <id> --yes");
7171
7790
  }
7172
7791
  if (!options.yes) {
7173
7792
  throw new Error("Pass --yes to confirm the discard. Published mixtapes can't be deleted.");
7174
7793
  }
7175
- await mixtapeDeleteCommand2(id);
7794
+ await mixtapeDeleteCommand3(id);
7176
7795
  if (options.json) {
7177
7796
  printJson({ id, ok: true });
7178
7797
  return;
@@ -7195,6 +7814,32 @@ async function runMixtapeList(options, mixtapeListCommand3) {
7195
7814
  console.log(`${coordinate3} ${status} ${mixtape.memberCount} bangers ${mixtape.title}`);
7196
7815
  }
7197
7816
  }
7817
+ async function runClipsList(options, clipsListCommand2) {
7818
+ const clips = await clipsListCommand2({ mixtapeId: options.mixtape, status: options.status });
7819
+ if (options.json) {
7820
+ printJson({ clips, ok: true });
7821
+ return;
7822
+ }
7823
+ if (clips.length === 0) {
7824
+ console.log("No clips.");
7825
+ return;
7826
+ }
7827
+ for (const clip of clips) {
7828
+ console.log(`${clip.id} ${clip.status} ${clip.mixtapeId} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
7829
+ }
7830
+ }
7831
+ async function runClipsCut(clipId, options, clipCutCommand2) {
7832
+ if (!clipId) {
7833
+ throw new Error("Missing clip id. Usage: fluncle admin clips cut <clipId>");
7834
+ }
7835
+ const onProgress = options.json ? () => {} : (message) => console.log(message);
7836
+ const result = await clipCutCommand2(clipId, onProgress);
7837
+ if (options.json) {
7838
+ printJson({ ok: true, ...result });
7839
+ return;
7840
+ }
7841
+ console.log(`Cut ${result.clipId} \u2192 ${result.url} (${(result.sizeBytes / 1e6).toFixed(1)} MB).`);
7842
+ }
7198
7843
  async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand3) {
7199
7844
  if (!idOrLogId) {
7200
7845
  throw new Error("Missing mixtape id or log id. Usage: fluncle admin mixtapes get <id|logId>");
@@ -7260,6 +7905,20 @@ async function runNewsletterSend(id, options, newsletterSendCommand2) {
7260
7905
  const number = result.edition.number;
7261
7906
  console.log(number === undefined ? `Sent edition ${result.edition.id}.` : `Sent edition #${number} \u2014 it's out to the list and in the archive.`);
7262
7907
  }
7908
+ async function runNewsletterDelete(id, options, newsletterDeleteCommand2) {
7909
+ if (!id) {
7910
+ throw new Error("Missing edition id. Usage: fluncle admin newsletter delete <id> --yes");
7911
+ }
7912
+ if (!options.yes) {
7913
+ throw new Error("Pass --yes to confirm \u2014 this hard-deletes the edition (draft or sent).");
7914
+ }
7915
+ const result = await newsletterDeleteCommand2(id);
7916
+ if (options.json) {
7917
+ printJson({ ...result, ok: true });
7918
+ return;
7919
+ }
7920
+ console.log(`Deleted edition ${result.id}. The send window reopens to cover its finds.`);
7921
+ }
7263
7922
  async function runNewsletterList(options, newsletterListCommand2) {
7264
7923
  const editions = await newsletterListCommand2();
7265
7924
  if (options.json) {
@@ -7328,8 +7987,8 @@ async function runRecent(options, recentCommand3) {
7328
7987
  console.log(trackRows2(tracks).join(`
7329
7988
  `));
7330
7989
  }
7331
- async function runMixtapes(options, mixtapesCommand2) {
7332
- const mixtapes = await mixtapesCommand2();
7990
+ async function runMixtapes(options, mixtapesCommand3) {
7991
+ const mixtapes = await mixtapesCommand3();
7333
7992
  if (options.json) {
7334
7993
  printJson({ mixtapes, ok: true });
7335
7994
  return;
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.68.0"
34
+ "version": "0.70.0"
35
35
  }