fluncle 0.69.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 +742 -288
  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.69.0".trim() ? "0.69.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,171 +1047,6 @@ var init_mixtape_youtube = __esm(() => {
1047
1047
  init_output();
1048
1048
  });
1049
1049
 
1050
- // src/commands/mixtapes.ts
1051
- async function mixtapeListCommand() {
1052
- const response = await adminApiGet("/api/admin/mixtapes");
1053
- return response.mixtapes;
1054
- }
1055
- async function mixtapeGetCommand(idOrLogId) {
1056
- const mixtapes = await mixtapeListCommand();
1057
- const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
1058
- if (!match) {
1059
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1060
- }
1061
- return match;
1062
- }
1063
- var init_mixtapes = __esm(() => {
1064
- init_api();
1065
- init_output();
1066
- });
1067
-
1068
- // src/commands/mixtape-mixcloud.ts
1069
- var exports_mixtape_mixcloud = {};
1070
- __export(exports_mixtape_mixcloud, {
1071
- mixtapeDescription: () => mixtapeDescription,
1072
- mixcloudSections: () => mixcloudSections,
1073
- distributeMixcloud: () => distributeMixcloud,
1074
- authMixcloudCommand: () => authMixcloudCommand
1075
- });
1076
- async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
1077
- const token = await fetchMixcloudToken();
1078
- const mixtape = await mixtapeGetCommand(mixtapeId);
1079
- const logId = mixtape.logId;
1080
- if (!logId) {
1081
- throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
1082
- }
1083
- const audio = Bun.file(audioPath);
1084
- if (!await audio.exists()) {
1085
- throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
1086
- }
1087
- const form = new FormData;
1088
- form.append("mp3", audio);
1089
- form.append("name", mixtape.title);
1090
- form.append("description", mixtapeDescription(mixtape.note, logId));
1091
- onProgress?.("Mixcloud: fetching the cover…");
1092
- const picture = await fetchCover(logId);
1093
- if (picture) {
1094
- form.append("picture", picture, "cover.png");
1095
- }
1096
- for (const [index, tag] of mixtapeTags(mixtape).entries()) {
1097
- form.append(`tags-${index}-tag`, tag);
1098
- }
1099
- const sections = mixcloudSections(mixtape.members);
1100
- for (const [index, section] of sections.entries()) {
1101
- form.append(`sections-${index}-artist`, section.artist);
1102
- form.append(`sections-${index}-song`, section.song);
1103
- form.append(`sections-${index}-start_time`, String(section.start_time));
1104
- }
1105
- if (unlisted) {
1106
- form.append("unlisted", "1");
1107
- onProgress?.("Mixcloud: uploading UNLISTED (private).");
1108
- }
1109
- const cuelessCount = mixtape.members.length - sections.length;
1110
- if (cuelessCount > 0) {
1111
- onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
1112
- }
1113
- onProgress?.("Mixcloud: uploading the master…");
1114
- const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
1115
- body: form,
1116
- method: "POST"
1117
- });
1118
- const uploadText = await uploadResponse.text();
1119
- if (!uploadResponse.ok) {
1120
- throwMixcloudError(uploadResponse.status, uploadText);
1121
- }
1122
- const result = parseUploadResult(uploadText);
1123
- if (!result.success || !result.key) {
1124
- throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
1125
- }
1126
- const externalId = result.key;
1127
- const url = `https://www.mixcloud.com${result.key}`;
1128
- onProgress?.("Mixcloud: recording the link…");
1129
- await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
1130
- externalId,
1131
- url
1132
- });
1133
- return { url };
1134
- }
1135
- async function authMixcloudCommand() {
1136
- const response = await adminApiGet("/api/admin/mixcloud/auth/start");
1137
- console.log(`Open this Mixcloud authorization URL:
1138
-
1139
- ${response.authUrl}
1140
-
1141
- After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
1142
- }
1143
- function mixtapeDescription(note, logId) {
1144
- const breadcrumb = `fluncle://${logId}`;
1145
- const body = (note ?? "").trim();
1146
- const full = body ? `${body}
1147
-
1148
- ${breadcrumb}` : breadcrumb;
1149
- if (full.length <= DESCRIPTION_MAX) {
1150
- return full;
1151
- }
1152
- const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
1153
- const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
1154
- return trimmedNote ? `${trimmedNote}
1155
-
1156
- ${breadcrumb}` : breadcrumb;
1157
- }
1158
- function mixcloudSections(members) {
1159
- return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
1160
- artist: member.artists.join(", "),
1161
- song: member.title,
1162
- start_time: Math.floor(member.startMs / 1000)
1163
- }));
1164
- }
1165
- function mixtapeTags(_mixtape) {
1166
- return ["Drum & Bass", "Fluncle"];
1167
- }
1168
- async function fetchMixcloudToken() {
1169
- try {
1170
- const response = await adminApiPost("/api/admin/mixcloud/token");
1171
- return response.accessToken;
1172
- } catch {
1173
- throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
1174
- }
1175
- }
1176
- async function fetchCover(logId) {
1177
- for (const size of ["square", "og"]) {
1178
- const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
1179
- if (!response.ok) {
1180
- continue;
1181
- }
1182
- const blob = await response.blob();
1183
- if (blob.size <= PICTURE_MAX_BYTES) {
1184
- return blob;
1185
- }
1186
- }
1187
- return;
1188
- }
1189
- function parseUploadResult(body) {
1190
- try {
1191
- const data = JSON.parse(body);
1192
- return {
1193
- key: data.result?.key,
1194
- message: data.result?.message,
1195
- success: data.result?.success === true
1196
- };
1197
- } catch {
1198
- return { message: body.slice(0, 300), success: false };
1199
- }
1200
- }
1201
- function throwMixcloudError(status, body) {
1202
- if (body.includes("An invalid access token was provided")) {
1203
- throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
1204
- }
1205
- throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
1206
- }
1207
- var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
1208
- var init_mixtape_mixcloud = __esm(() => {
1209
- init_api();
1210
- init_output();
1211
- init_mixtapes();
1212
- PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1213
- });
1214
-
1215
1050
  // src/commands/mixtape-set-video.ts
1216
1051
  var exports_mixtape_set_video = {};
1217
1052
  __export(exports_mixtape_set_video, {
@@ -1317,107 +1152,508 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1317
1152
  } finally {
1318
1153
  rmSync2(renditionPath, { force: true });
1319
1154
  }
1320
- }
1321
- async function putPart(url, path2, part) {
1322
- const response = await fetch(url, {
1323
- body: Bun.file(path2).slice(part.start, part.end),
1324
- method: "PUT"
1325
- });
1326
- if (!response.ok) {
1327
- const detail = (await response.text().catch(() => "")).slice(0, 300);
1328
- throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
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
+
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
+ }
1258
+ async function mixtapeListCommand() {
1259
+ const response = await adminApiGet("/api/admin/mixtapes");
1260
+ return response.mixtapes;
1261
+ }
1262
+ async function mixtapeGetCommand(idOrLogId) {
1263
+ const mixtapes = await mixtapeListCommand();
1264
+ const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
1265
+ if (!match) {
1266
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1267
+ }
1268
+ return match;
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
+ }
1470
+ var init_mixtapes = __esm(() => {
1471
+ init_api();
1472
+ init_output();
1473
+ });
1474
+
1475
+ // src/commands/mixtape-mixcloud.ts
1476
+ var exports_mixtape_mixcloud = {};
1477
+ __export(exports_mixtape_mixcloud, {
1478
+ mixtapeDescription: () => mixtapeDescription,
1479
+ mixcloudSections: () => mixcloudSections,
1480
+ distributeMixcloud: () => distributeMixcloud,
1481
+ authMixcloudCommand: () => authMixcloudCommand
1482
+ });
1483
+ async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
1484
+ const token = await fetchMixcloudToken();
1485
+ const mixtape = await mixtapeGetCommand(mixtapeId);
1486
+ const logId = mixtape.logId;
1487
+ if (!logId) {
1488
+ throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
1489
+ }
1490
+ const audio = Bun.file(audioPath);
1491
+ if (!await audio.exists()) {
1492
+ throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
1493
+ }
1494
+ const form = new FormData;
1495
+ form.append("mp3", audio);
1496
+ form.append("name", mixtape.title);
1497
+ form.append("description", mixtapeDescription(mixtape.note, logId));
1498
+ onProgress?.("Mixcloud: fetching the cover…");
1499
+ const picture = await fetchCover(logId);
1500
+ if (picture) {
1501
+ form.append("picture", picture, "cover.png");
1502
+ }
1503
+ for (const [index, tag] of mixtapeTags(mixtape).entries()) {
1504
+ form.append(`tags-${index}-tag`, tag);
1505
+ }
1506
+ const sections = mixcloudSections(mixtape.members);
1507
+ for (const [index, section] of sections.entries()) {
1508
+ form.append(`sections-${index}-artist`, section.artist);
1509
+ form.append(`sections-${index}-song`, section.song);
1510
+ form.append(`sections-${index}-start_time`, String(section.start_time));
1511
+ }
1512
+ if (unlisted) {
1513
+ form.append("unlisted", "1");
1514
+ onProgress?.("Mixcloud: uploading UNLISTED (private).");
1329
1515
  }
1330
- const etag = response.headers.get("etag");
1331
- if (!etag) {
1332
- throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1516
+ const cuelessCount = mixtape.members.length - sections.length;
1517
+ if (cuelessCount > 0) {
1518
+ onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
1333
1519
  }
1334
- return etag;
1335
- }
1336
- async function completeUpload(url, parts) {
1337
- const response = await fetch(url, {
1338
- body: buildCompleteXml(parts),
1339
- headers: { "content-type": "application/xml" },
1520
+ onProgress?.("Mixcloud: uploading the master…");
1521
+ const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
1522
+ body: form,
1340
1523
  method: "POST"
1341
1524
  });
1342
- const text = await response.text().catch(() => "");
1343
- if (!response.ok || text.includes("<Error>")) {
1344
- throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1525
+ const uploadText = await uploadResponse.text();
1526
+ if (!uploadResponse.ok) {
1527
+ throwMixcloudError(uploadResponse.status, uploadText);
1528
+ }
1529
+ const result = parseUploadResult(uploadText);
1530
+ if (!result.success || !result.key) {
1531
+ throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
1345
1532
  }
1533
+ const externalId = result.key;
1534
+ const url = `https://www.mixcloud.com${result.key}`;
1535
+ onProgress?.("Mixcloud: recording the link…");
1536
+ await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
1537
+ externalId,
1538
+ url
1539
+ });
1540
+ return { url };
1346
1541
  }
1347
- async function abortUpload(url) {
1348
- await fetch(url, { method: "DELETE" });
1542
+ async function authMixcloudCommand() {
1543
+ const response = await adminApiGet("/api/admin/mixcloud/auth/start");
1544
+ console.log(`Open this Mixcloud authorization URL:
1545
+
1546
+ ${response.authUrl}
1547
+
1548
+ After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
1349
1549
  }
1350
- async function assertFfmpeg() {
1550
+ function mixtapeDescription(note, logId) {
1551
+ const breadcrumb = `fluncle://${logId}`;
1552
+ const body = (note ?? "").trim();
1553
+ const full = body ? `${body}
1554
+
1555
+ ${breadcrumb}` : breadcrumb;
1556
+ if (full.length <= DESCRIPTION_MAX) {
1557
+ return full;
1558
+ }
1559
+ const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
1560
+ const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
1561
+ return trimmedNote ? `${trimmedNote}
1562
+
1563
+ ${breadcrumb}` : breadcrumb;
1564
+ }
1565
+ function mixcloudSections(members) {
1566
+ return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
1567
+ artist: member.artists.join(", "),
1568
+ song: member.title,
1569
+ start_time: Math.floor(member.startMs / 1000)
1570
+ }));
1571
+ }
1572
+ function mixtapeTags(_mixtape) {
1573
+ return ["Drum & Bass", "Fluncle"];
1574
+ }
1575
+ async function fetchMixcloudToken() {
1351
1576
  try {
1352
- const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1353
- await proc.exited;
1354
- if (proc.exitCode !== 0) {
1355
- throw new Error("ffmpeg -version exited non-zero");
1577
+ const response = await adminApiPost("/api/admin/mixcloud/token");
1578
+ return response.accessToken;
1579
+ } catch {
1580
+ throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
1581
+ }
1582
+ }
1583
+ async function fetchCover(logId) {
1584
+ for (const size of ["square", "og"]) {
1585
+ const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
1586
+ if (!response.ok) {
1587
+ continue;
1356
1588
  }
1589
+ const blob = await response.blob();
1590
+ if (blob.size <= PICTURE_MAX_BYTES) {
1591
+ return blob;
1592
+ }
1593
+ }
1594
+ return;
1595
+ }
1596
+ function parseUploadResult(body) {
1597
+ try {
1598
+ const data = JSON.parse(body);
1599
+ return {
1600
+ key: data.result?.key,
1601
+ message: data.result?.message,
1602
+ success: data.result?.success === true
1603
+ };
1357
1604
  } catch {
1358
- throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1605
+ return { message: body.slice(0, 300), success: false };
1359
1606
  }
1360
1607
  }
1361
- async function deriveRendition(inputPath, outputPath) {
1362
- const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1363
- stderr: "pipe",
1364
- stdout: "ignore"
1365
- });
1366
- await proc.exited;
1367
- if (proc.exitCode !== 0) {
1368
- const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1369
- throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1608
+ function throwMixcloudError(status, body) {
1609
+ if (body.includes("An invalid access token was provided")) {
1610
+ throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
1370
1611
  }
1612
+ throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
1371
1613
  }
1372
- var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1373
- var init_mixtape_set_video = __esm(() => {
1614
+ var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
1615
+ var init_mixtape_mixcloud = __esm(() => {
1374
1616
  init_api();
1375
1617
  init_output();
1376
- SET_VIDEO_RENDITION = {
1377
- audioBitrate: "192k",
1378
- crf: 20,
1379
- gopSeconds: 2,
1380
- height: 1080
1381
- };
1382
- DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1383
- MIN_PART_SIZE = 5 * 1024 * 1024;
1618
+ init_mixtapes();
1619
+ PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1384
1620
  });
1385
1621
 
1386
1622
  // src/commands/mixtapes.ts
1387
- var exports_mixtapes = {};
1388
- __export(exports_mixtapes, {
1389
- mixtapesCommand: () => mixtapesCommand,
1390
- mixtapeUpdateCommand: () => mixtapeUpdateCommand,
1391
- mixtapePublishCommand: () => mixtapePublishCommand,
1392
- mixtapeMembersCommand: () => mixtapeMembersCommand,
1623
+ var exports_mixtapes2 = {};
1624
+ __export(exports_mixtapes2, {
1625
+ mixtapesCommand: () => mixtapesCommand2,
1626
+ mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
1627
+ mixtapePublishCommand: () => mixtapePublishCommand2,
1628
+ mixtapeMembersCommand: () => mixtapeMembersCommand2,
1393
1629
  mixtapeListCommand: () => mixtapeListCommand2,
1394
1630
  mixtapeGetCommand: () => mixtapeGetCommand2,
1395
- mixtapeDistributeCommand: () => mixtapeDistributeCommand,
1396
- mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1397
- mixtapeCreateCommand: () => mixtapeCreateCommand
1631
+ mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
1632
+ mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
1633
+ mixtapeCreateCommand: () => mixtapeCreateCommand2
1398
1634
  });
1399
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1400
- async function mixtapesCommand() {
1635
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1636
+ async function mixtapesCommand2() {
1401
1637
  const response = await publicApiGet("/api/mixtapes");
1402
1638
  return response.mixtapes;
1403
1639
  }
1404
- async function mixtapeCreateCommand(options) {
1405
- return adminApiPost("/api/admin/mixtapes", buildBody(options));
1640
+ async function mixtapeCreateCommand2(options) {
1641
+ return adminApiPost("/api/admin/mixtapes", buildBody2(options));
1406
1642
  }
1407
- async function mixtapeUpdateCommand(id, options) {
1408
- 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));
1409
1645
  }
1410
- async function mixtapeMembersCommand(id, refs, options) {
1646
+ async function mixtapeMembersCommand2(id, refs, options) {
1411
1647
  const members = refs.map((ref) => ({ ref }));
1412
1648
  if (options.from) {
1413
- members.push(...parseCueFile(options.from));
1649
+ members.push(...parseCueFile2(options.from));
1414
1650
  }
1415
1651
  return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
1416
1652
  }
1417
- async function mixtapePublishCommand(id) {
1653
+ async function mixtapePublishCommand2(id) {
1418
1654
  return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
1419
1655
  }
1420
- async function mixtapeDeleteCommand(id) {
1656
+ async function mixtapeDeleteCommand2(id) {
1421
1657
  return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
1422
1658
  }
1423
1659
  async function mixtapeListCommand2() {
@@ -1432,7 +1668,7 @@ async function mixtapeGetCommand2(idOrLogId) {
1432
1668
  }
1433
1669
  return match;
1434
1670
  }
1435
- async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
1671
+ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
1436
1672
  const mixtape = await mixtapeGetCommand2(idOrLogId);
1437
1673
  if (!mixtape.id) {
1438
1674
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
@@ -1454,15 +1690,15 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1454
1690
  let logId = mixtape.logId;
1455
1691
  if (!mixtape.durationMs) {
1456
1692
  const source = options.audio ?? options.video;
1457
- const durationMs = source ? await probeDurationMs(source) : undefined;
1693
+ const durationMs = source ? await probeDurationMs2(source) : undefined;
1458
1694
  if (durationMs) {
1459
- await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
1695
+ await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
1460
1696
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1461
1697
  }
1462
1698
  }
1463
1699
  if (mixtape.status === "draft") {
1464
1700
  onProgress("Minting the coordinate…");
1465
- const published = await mixtapePublishCommand(mixtapeId);
1701
+ const published = await mixtapePublishCommand2(mixtapeId);
1466
1702
  logId = published.mixtape.logId;
1467
1703
  onProgress(`Minted ${logId}.`);
1468
1704
  } else if (mixtape.status === "published") {
@@ -1506,7 +1742,7 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1506
1742
  }
1507
1743
  return { logId, mixtapeId, results };
1508
1744
  }
1509
- async function probeDurationMs(filePath) {
1745
+ async function probeDurationMs2(filePath) {
1510
1746
  try {
1511
1747
  const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
1512
1748
  const out = await new Response(proc.stdout).text();
@@ -1517,7 +1753,7 @@ async function probeDurationMs(filePath) {
1517
1753
  return;
1518
1754
  }
1519
1755
  }
1520
- function buildBody(options) {
1756
+ function buildBody2(options) {
1521
1757
  const body = {};
1522
1758
  if (options.note !== undefined) {
1523
1759
  body.note = options.note;
@@ -1529,7 +1765,7 @@ function buildBody(options) {
1529
1765
  body.soundcloudUrl = options.soundcloudUrl;
1530
1766
  }
1531
1767
  if (options.durationMs !== undefined) {
1532
- const parsed = parseDuration(options.durationMs);
1768
+ const parsed = parseDuration2(options.durationMs);
1533
1769
  if (parsed === null) {
1534
1770
  throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
1535
1771
  }
@@ -1537,11 +1773,11 @@ function buildBody(options) {
1537
1773
  }
1538
1774
  return body;
1539
1775
  }
1540
- function parseCueFile(filePath) {
1541
- if (!existsSync2(filePath)) {
1776
+ function parseCueFile2(filePath) {
1777
+ if (!existsSync3(filePath)) {
1542
1778
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1543
1779
  }
1544
- const text = readFileSync2(filePath, "utf-8");
1780
+ const text = readFileSync3(filePath, "utf-8");
1545
1781
  const trimmed = text.trim();
1546
1782
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1547
1783
  try {
@@ -1570,9 +1806,9 @@ function parseCueFile(filePath) {
1570
1806
  throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
1571
1807
  }
1572
1808
  }
1573
- return parseCueSheet(text);
1809
+ return parseCueSheet2(text);
1574
1810
  }
1575
- function parseCueSheet(text) {
1811
+ function parseCueSheet2(text) {
1576
1812
  const entries = [];
1577
1813
  for (const line of text.split(/\r?\n/)) {
1578
1814
  const trimmed = line.trim();
@@ -1585,7 +1821,7 @@ function parseCueSheet(text) {
1585
1821
  if (time === undefined || ref === undefined) {
1586
1822
  continue;
1587
1823
  }
1588
- const startMs = parseDuration(time);
1824
+ const startMs = parseDuration2(time);
1589
1825
  if (startMs === null) {
1590
1826
  continue;
1591
1827
  }
@@ -1596,7 +1832,7 @@ function parseCueSheet(text) {
1596
1832
  }
1597
1833
  return entries;
1598
1834
  }
1599
- function parseDuration(input) {
1835
+ function parseDuration2(input) {
1600
1836
  const trimmed = input.trim();
1601
1837
  if (!trimmed) {
1602
1838
  return null;
@@ -2323,7 +2559,7 @@ function parseVersion2(version) {
2323
2559
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2324
2560
  var init_version2 = __esm(() => {
2325
2561
  init_output();
2326
- currentVersion2 = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
2562
+ currentVersion2 = "0.70.0".trim() ? "0.70.0".trim() : "0.1.0";
2327
2563
  });
2328
2564
 
2329
2565
  // ../../packages/registry/src/index.ts
@@ -3772,6 +4008,186 @@ var init_mixtape_youtube2 = __esm(() => {
3772
4008
  init_output();
3773
4009
  });
3774
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
+
3775
4191
  // src/commands/newsletter.ts
3776
4192
  var exports_newsletter = {};
3777
4193
  __export(exports_newsletter, {
@@ -3781,8 +4197,8 @@ __export(exports_newsletter, {
3781
4197
  newsletterDraftCommand: () => newsletterDraftCommand,
3782
4198
  newsletterDeleteCommand: () => newsletterDeleteCommand
3783
4199
  });
3784
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3785
- function buildBody2(options, { requireContent }) {
4200
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
4201
+ function buildBody3(options, { requireContent }) {
3786
4202
  const body = {};
3787
4203
  if (options.contentFile !== undefined) {
3788
4204
  body.contentJson = readContentFile(options.contentFile);
@@ -3801,10 +4217,10 @@ function buildBody2(options, { requireContent }) {
3801
4217
  return body;
3802
4218
  }
3803
4219
  function readContentFile(filePath) {
3804
- if (!existsSync3(filePath)) {
4220
+ if (!existsSync4(filePath)) {
3805
4221
  throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
3806
4222
  }
3807
- const text = readFileSync3(filePath, "utf-8");
4223
+ const text = readFileSync4(filePath, "utf-8");
3808
4224
  try {
3809
4225
  return JSON.parse(text);
3810
4226
  } catch (error) {
@@ -3812,10 +4228,10 @@ function readContentFile(filePath) {
3812
4228
  }
3813
4229
  }
3814
4230
  async function newsletterDraftCommand(options) {
3815
- return adminApiPost("/api/admin/newsletter/editions", buildBody2(options, { requireContent: true }));
4231
+ return adminApiPost("/api/admin/newsletter/editions", buildBody3(options, { requireContent: true }));
3816
4232
  }
3817
4233
  async function newsletterUpdateCommand(id, options) {
3818
- 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 }));
3819
4235
  }
3820
4236
  async function newsletterSendCommand(id) {
3821
4237
  return adminApiPost(`/api/admin/newsletter/editions/${encodeURIComponent(id)}/send`);
@@ -4441,7 +4857,7 @@ var COORD_FALLBACK2 = "—";
4441
4857
  var init_format2 = () => {};
4442
4858
 
4443
4859
  // src/cli.ts
4444
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
4860
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
4445
4861
  import path2 from "path";
4446
4862
 
4447
4863
  // ../../node_modules/commander/lib/error.js
@@ -6603,8 +7019,8 @@ function addListenCommands(program2) {
6603
7019
  await runRecent(options, recentCommand3);
6604
7020
  });
6605
7021
  program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
6606
- const { mixtapesCommand: mixtapesCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6607
- await runMixtapes(options, mixtapesCommand2);
7022
+ const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7023
+ await runMixtapes(options, mixtapesCommand3);
6608
7024
  });
6609
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) => {
6610
7026
  const openCommands = await Promise.resolve().then(() => (init_open(), exports_open));
@@ -6774,41 +7190,53 @@ function addAdminCommands(program2) {
6774
7190
  adminMixtapes.outputHelp();
6775
7191
  });
6776
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) => {
6777
- const { mixtapeCreateCommand: mixtapeCreateCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6778
- await runMixtapeCreate(options, mixtapeCreateCommand2);
7193
+ const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7194
+ await runMixtapeCreate(options, mixtapeCreateCommand3);
6779
7195
  });
6780
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) => {
6781
- const { mixtapeUpdateCommand: mixtapeUpdateCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6782
- await runMixtapeUpdate(id, options, mixtapeUpdateCommand2);
7197
+ const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7198
+ await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
6783
7199
  });
6784
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) => {
6785
- const { mixtapeMembersCommand: mixtapeMembersCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6786
- 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);
6787
7203
  });
6788
7204
  adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
6789
- const { mixtapePublishCommand: mixtapePublishCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6790
- await runMixtapePublish(id, options, mixtapePublishCommand2);
7205
+ const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7206
+ await runMixtapePublish(id, options, mixtapePublishCommand3);
6791
7207
  });
6792
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) => {
6793
- const { mixtapeDeleteCommand: mixtapeDeleteCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6794
- await runMixtapeDelete(id, options, mixtapeDeleteCommand2);
7209
+ const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7210
+ await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
6795
7211
  });
6796
7212
  adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
6797
- const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
7213
+ const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
6798
7214
  await runMixtapeList(options, mixtapeListCommand3);
6799
7215
  });
6800
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) => {
6801
- const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
7217
+ const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
6802
7218
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
6803
7219
  });
6804
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) => {
6805
- const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
6806
- await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
7221
+ const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7222
+ await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
6807
7223
  });
6808
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) => {
6809
7225
  const { publishYoutubeCommand: publishYoutubeCommand3 } = await Promise.resolve().then(() => (init_mixtape_youtube2(), exports_mixtape_youtube2));
6810
7226
  await runMixtapePublishYoutube(idOrLogId, options, publishYoutubeCommand3);
6811
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
+ });
6812
7240
  const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
6813
7241
  adminNewsletter.action(() => {
6814
7242
  adminNewsletter.outputHelp();
@@ -6912,7 +7340,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
6912
7340
  console.log(` mime: ${result.mime}`);
6913
7341
  }
6914
7342
  async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
6915
- const script = options.scriptFile ? readFileSync4(options.scriptFile, "utf8") : options.script;
7343
+ const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
6916
7344
  if (!idOrLogId || !script || !script.trim()) {
6917
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]");
6918
7346
  }
@@ -6968,7 +7396,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
6968
7396
  }
6969
7397
  }
6970
7398
  async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
6971
- const note = options.scriptFile ? readFileSync4(options.scriptFile, "utf8") : options.script;
7399
+ const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
6972
7400
  if (!idOrLogId || !note || !note.trim()) {
6973
7401
  throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
6974
7402
  }
@@ -7114,7 +7542,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
7114
7542
  return;
7115
7543
  }
7116
7544
  const candidate = path2.join(dir, name);
7117
- return existsSync4(candidate) ? candidate : undefined;
7545
+ return existsSync5(candidate) ? candidate : undefined;
7118
7546
  };
7119
7547
  const resolveFile = (explicit, name) => {
7120
7548
  if (explicit) {
@@ -7286,56 +7714,56 @@ async function runTrackPurgeVideo(idOrLogId, options, trackPurgeVideoCommand2) {
7286
7714
  }
7287
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.`);
7288
7716
  }
7289
- async function runMixtapeCreate(options, mixtapeCreateCommand2) {
7290
- const result = await mixtapeCreateCommand2(options);
7717
+ async function runMixtapeCreate(options, mixtapeCreateCommand3) {
7718
+ const result = await mixtapeCreateCommand3(options);
7291
7719
  if (options.json) {
7292
7720
  printJson(result);
7293
7721
  return;
7294
7722
  }
7295
7723
  console.log(`Logged draft ${result.mixtape.id}. It stays a draft until you publish it.`);
7296
7724
  }
7297
- async function runMixtapeUpdate(id, options, mixtapeUpdateCommand2) {
7725
+ async function runMixtapeUpdate(id, options, mixtapeUpdateCommand3) {
7298
7726
  if (!id) {
7299
7727
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes update <id>");
7300
7728
  }
7301
- const result = await mixtapeUpdateCommand2(id, options);
7729
+ const result = await mixtapeUpdateCommand3(id, options);
7302
7730
  if (options.json) {
7303
7731
  printJson(result);
7304
7732
  return;
7305
7733
  }
7306
7734
  console.log(`Saved ${result.mixtape.id}.`);
7307
7735
  }
7308
- async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand2) {
7736
+ async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand3) {
7309
7737
  if (!id) {
7310
7738
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes members <id> [refs...]");
7311
7739
  }
7312
7740
  if (refs.length === 0 && !options.from) {
7313
7741
  throw new Error("Provide refs as arguments or a cue-sheet file with --from");
7314
7742
  }
7315
- const result = await mixtapeMembersCommand2(id, refs, options);
7743
+ const result = await mixtapeMembersCommand3(id, refs, options);
7316
7744
  if (options.json) {
7317
7745
  printJson(result);
7318
7746
  return;
7319
7747
  }
7320
7748
  console.log(`Saved the tracklist: ${result.mixtape.memberCount} bangers on ${result.mixtape.id}.`);
7321
7749
  }
7322
- async function runMixtapePublish(id, options, mixtapePublishCommand2) {
7750
+ async function runMixtapePublish(id, options, mixtapePublishCommand3) {
7323
7751
  if (!id) {
7324
7752
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes publish <id>");
7325
7753
  }
7326
- const result = await mixtapePublishCommand2(id);
7754
+ const result = await mixtapePublishCommand3(id);
7327
7755
  if (options.json) {
7328
7756
  printJson(result);
7329
7757
  return;
7330
7758
  }
7331
7759
  console.log(`Minted ${result.mixtape.logId} (fluncle://${result.mixtape.logId}) \u2014 distributing. ` + "Run `distribute` to push it to the platforms.");
7332
7760
  }
7333
- async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2) {
7761
+ async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3) {
7334
7762
  if (!idOrLogId) {
7335
7763
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes distribute <idOrLogId> --video <mp4> --audio <file>");
7336
7764
  }
7337
7765
  const onProgress = options.json ? () => {} : (message) => console.log(message);
7338
- const result = await mixtapeDistributeCommand2(idOrLogId, options, onProgress);
7766
+ const result = await mixtapeDistributeCommand3(idOrLogId, options, onProgress);
7339
7767
  if (options.json) {
7340
7768
  printJson(result);
7341
7769
  return;
@@ -7356,14 +7784,14 @@ async function runMixtapePublishYoutube(idOrLogId, options, publishYoutubeComman
7356
7784
  }
7357
7785
  console.log(`YouTube video is now public: ${result.url}`);
7358
7786
  }
7359
- async function runMixtapeDelete(id, options, mixtapeDeleteCommand2) {
7787
+ async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
7360
7788
  if (!id) {
7361
7789
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes delete <id> --yes");
7362
7790
  }
7363
7791
  if (!options.yes) {
7364
7792
  throw new Error("Pass --yes to confirm the discard. Published mixtapes can't be deleted.");
7365
7793
  }
7366
- await mixtapeDeleteCommand2(id);
7794
+ await mixtapeDeleteCommand3(id);
7367
7795
  if (options.json) {
7368
7796
  printJson({ id, ok: true });
7369
7797
  return;
@@ -7386,6 +7814,32 @@ async function runMixtapeList(options, mixtapeListCommand3) {
7386
7814
  console.log(`${coordinate3} ${status} ${mixtape.memberCount} bangers ${mixtape.title}`);
7387
7815
  }
7388
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
+ }
7389
7843
  async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand3) {
7390
7844
  if (!idOrLogId) {
7391
7845
  throw new Error("Missing mixtape id or log id. Usage: fluncle admin mixtapes get <id|logId>");
@@ -7533,8 +7987,8 @@ async function runRecent(options, recentCommand3) {
7533
7987
  console.log(trackRows2(tracks).join(`
7534
7988
  `));
7535
7989
  }
7536
- async function runMixtapes(options, mixtapesCommand2) {
7537
- const mixtapes = await mixtapesCommand2();
7990
+ async function runMixtapes(options, mixtapesCommand3) {
7991
+ const mixtapes = await mixtapesCommand3();
7538
7992
  if (options.json) {
7539
7993
  printJson({ mixtapes, ok: true });
7540
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.69.0"
34
+ "version": "0.70.0"
35
35
  }