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.
- package/bin/fluncle.mjs +745 -86
- 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.
|
|
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) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[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
|
|
1217
|
-
__export(
|
|
1218
|
-
mixtapesCommand: () =>
|
|
1219
|
-
mixtapeUpdateCommand: () =>
|
|
1220
|
-
mixtapePublishCommand: () =>
|
|
1221
|
-
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: () =>
|
|
1225
|
-
mixtapeDeleteCommand: () =>
|
|
1226
|
-
mixtapeCreateCommand: () =>
|
|
1631
|
+
mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
|
|
1632
|
+
mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
|
|
1633
|
+
mixtapeCreateCommand: () => mixtapeCreateCommand2
|
|
1227
1634
|
});
|
|
1228
|
-
import { existsSync, readFileSync as
|
|
1229
|
-
async function
|
|
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
|
|
1234
|
-
return adminApiPost("/api/admin/mixtapes",
|
|
1640
|
+
async function mixtapeCreateCommand2(options) {
|
|
1641
|
+
return adminApiPost("/api/admin/mixtapes", buildBody2(options));
|
|
1235
1642
|
}
|
|
1236
|
-
async function
|
|
1237
|
-
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`,
|
|
1643
|
+
async function mixtapeUpdateCommand2(id, options) {
|
|
1644
|
+
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
|
|
1238
1645
|
}
|
|
1239
|
-
async function
|
|
1646
|
+
async function mixtapeMembersCommand2(id, refs, options) {
|
|
1240
1647
|
const members = refs.map((ref) => ({ ref }));
|
|
1241
1648
|
if (options.from) {
|
|
1242
|
-
members.push(...
|
|
1649
|
+
members.push(...parseCueFile2(options.from));
|
|
1243
1650
|
}
|
|
1244
1651
|
return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
|
|
1245
1652
|
}
|
|
1246
|
-
async function
|
|
1653
|
+
async function mixtapePublishCommand2(id) {
|
|
1247
1654
|
return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
|
|
1248
1655
|
}
|
|
1249
|
-
async function
|
|
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
|
|
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
|
|
1693
|
+
const durationMs = source ? await probeDurationMs2(source) : undefined;
|
|
1283
1694
|
if (durationMs) {
|
|
1284
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1356
|
-
if (!
|
|
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 =
|
|
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
|
|
1809
|
+
return parseCueSheet2(text);
|
|
1389
1810
|
}
|
|
1390
|
-
function
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
|
2677
|
-
|
|
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] = `${
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
3601
|
-
function
|
|
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 (!
|
|
4220
|
+
if (!existsSync4(filePath)) {
|
|
3621
4221
|
throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
|
|
3622
4222
|
}
|
|
3623
|
-
const text =
|
|
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",
|
|
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)}`,
|
|
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
|
|
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:
|
|
6420
|
-
await runMixtapes(options,
|
|
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:
|
|
6591
|
-
await runMixtapeCreate(options,
|
|
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:
|
|
6595
|
-
await runMixtapeUpdate(id, options,
|
|
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:
|
|
6599
|
-
await runMixtapeMembers(id, refs, options,
|
|
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:
|
|
6603
|
-
await runMixtapePublish(id, options,
|
|
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:
|
|
6607
|
-
await runMixtapeDelete(id, options,
|
|
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(),
|
|
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(),
|
|
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:
|
|
6619
|
-
await runMixtapeDistribute(idOrLogId, options,
|
|
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 ?
|
|
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 ?
|
|
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
|
|
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,
|
|
7099
|
-
const result = await
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
7332
|
-
const mixtapes = await
|
|
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