fluncle 0.84.0 → 0.86.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 +305 -253
- package/package.json +1 -1
package/bin/fluncle.mjs
CHANGED
|
@@ -538,7 +538,7 @@ function parseVersion(version) {
|
|
|
538
538
|
var currentVersion;
|
|
539
539
|
var init_version = __esm(() => {
|
|
540
540
|
init_output();
|
|
541
|
-
currentVersion = "0.
|
|
541
|
+
currentVersion = "0.86.0".trim() ? "0.86.0".trim() : "0.1.0";
|
|
542
542
|
});
|
|
543
543
|
|
|
544
544
|
// src/update-notifier.ts
|
|
@@ -1329,186 +1329,6 @@ var init_mixtape_mixcloud = __esm(() => {
|
|
|
1329
1329
|
PICTURE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1330
1330
|
});
|
|
1331
1331
|
|
|
1332
|
-
// src/commands/mixtape-set-video.ts
|
|
1333
|
-
var exports_mixtape_set_video = {};
|
|
1334
|
-
__export(exports_mixtape_set_video, {
|
|
1335
|
-
uploadRenditionMultipart: () => uploadRenditionMultipart,
|
|
1336
|
-
stageSetVideo: () => stageSetVideo,
|
|
1337
|
-
renditionFfmpegArgs: () => renditionFfmpegArgs,
|
|
1338
|
-
planMultipart: () => planMultipart,
|
|
1339
|
-
buildCompleteXml: () => buildCompleteXml,
|
|
1340
|
-
SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
|
|
1341
|
-
MIN_PART_SIZE: () => MIN_PART_SIZE,
|
|
1342
|
-
MAX_PARTS: () => MAX_PARTS,
|
|
1343
|
-
DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
|
|
1344
|
-
});
|
|
1345
|
-
import { randomUUID } from "node:crypto";
|
|
1346
|
-
import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
|
|
1347
|
-
import { tmpdir } from "node:os";
|
|
1348
|
-
import { join as join4 } from "node:path";
|
|
1349
|
-
function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
|
|
1350
|
-
if (!Number.isInteger(contentLength) || contentLength <= 0) {
|
|
1351
|
-
throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
|
|
1352
|
-
}
|
|
1353
|
-
let effective = Math.max(partSize, MIN_PART_SIZE);
|
|
1354
|
-
if (Math.ceil(contentLength / effective) > MAX_PARTS) {
|
|
1355
|
-
effective = Math.ceil(contentLength / MAX_PARTS);
|
|
1356
|
-
}
|
|
1357
|
-
const parts = [];
|
|
1358
|
-
let start = 0;
|
|
1359
|
-
let partNumber = 1;
|
|
1360
|
-
while (start < contentLength) {
|
|
1361
|
-
const end = Math.min(start + effective, contentLength);
|
|
1362
|
-
parts.push({ end, partNumber, size: end - start, start });
|
|
1363
|
-
start = end;
|
|
1364
|
-
partNumber += 1;
|
|
1365
|
-
}
|
|
1366
|
-
return { partCount: parts.length, partSize: effective, parts };
|
|
1367
|
-
}
|
|
1368
|
-
function escapeXml(value) {
|
|
1369
|
-
return value.replace(/[<>&'"]/g, (char) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[char] ?? char);
|
|
1370
|
-
}
|
|
1371
|
-
function buildCompleteXml(parts) {
|
|
1372
|
-
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
1373
|
-
const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
|
|
1374
|
-
return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
|
|
1375
|
-
}
|
|
1376
|
-
function renditionFfmpegArgs(inputPath, outputPath) {
|
|
1377
|
-
return [
|
|
1378
|
-
"-y",
|
|
1379
|
-
"-i",
|
|
1380
|
-
inputPath,
|
|
1381
|
-
"-vf",
|
|
1382
|
-
`scale=-2:${SET_VIDEO_RENDITION.height}`,
|
|
1383
|
-
"-c:v",
|
|
1384
|
-
"libx264",
|
|
1385
|
-
"-preset",
|
|
1386
|
-
"medium",
|
|
1387
|
-
"-crf",
|
|
1388
|
-
String(SET_VIDEO_RENDITION.crf),
|
|
1389
|
-
"-force_key_frames",
|
|
1390
|
-
`expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
|
|
1391
|
-
"-pix_fmt",
|
|
1392
|
-
"yuv420p",
|
|
1393
|
-
"-c:a",
|
|
1394
|
-
"aac",
|
|
1395
|
-
"-b:a",
|
|
1396
|
-
SET_VIDEO_RENDITION.audioBitrate,
|
|
1397
|
-
"-movflags",
|
|
1398
|
-
"+faststart",
|
|
1399
|
-
outputPath
|
|
1400
|
-
];
|
|
1401
|
-
}
|
|
1402
|
-
async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
|
|
1403
|
-
if (!existsSync(masterPath)) {
|
|
1404
|
-
throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
|
|
1405
|
-
}
|
|
1406
|
-
await assertFfmpeg();
|
|
1407
|
-
const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
|
|
1408
|
-
onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
|
|
1409
|
-
await deriveRendition(masterPath, renditionPath);
|
|
1410
|
-
try {
|
|
1411
|
-
const size = statSync2(renditionPath).size;
|
|
1412
|
-
const plan = planMultipart(size);
|
|
1413
|
-
onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
|
|
1414
|
-
const presign = await adminApiPost(presignPath, {
|
|
1415
|
-
partCount: plan.partCount
|
|
1416
|
-
});
|
|
1417
|
-
const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
|
|
1418
|
-
const completed = [];
|
|
1419
|
-
try {
|
|
1420
|
-
for (const part of plan.parts) {
|
|
1421
|
-
const url = urlByPart.get(part.partNumber);
|
|
1422
|
-
if (!url) {
|
|
1423
|
-
throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
|
|
1424
|
-
}
|
|
1425
|
-
onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
|
|
1426
|
-
const etag = await putPart(url, renditionPath, part);
|
|
1427
|
-
completed.push({ etag, partNumber: part.partNumber });
|
|
1428
|
-
}
|
|
1429
|
-
await completeUpload(presign.completeUrl, completed);
|
|
1430
|
-
} catch (error) {
|
|
1431
|
-
await abortUpload(presign.abortUrl).catch(() => {});
|
|
1432
|
-
throw error;
|
|
1433
|
-
}
|
|
1434
|
-
return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
|
|
1435
|
-
} finally {
|
|
1436
|
-
rmSync2(renditionPath, { force: true });
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
|
|
1440
|
-
const result = await uploadRenditionMultipart(masterPath, `/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, onProgress);
|
|
1441
|
-
await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, {
|
|
1442
|
-
setVideoAt: new Date().toISOString()
|
|
1443
|
-
});
|
|
1444
|
-
onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
|
|
1445
|
-
return result;
|
|
1446
|
-
}
|
|
1447
|
-
async function putPart(url, path2, part) {
|
|
1448
|
-
const response = await fetch(url, {
|
|
1449
|
-
body: Bun.file(path2).slice(part.start, part.end),
|
|
1450
|
-
method: "PUT"
|
|
1451
|
-
});
|
|
1452
|
-
if (!response.ok) {
|
|
1453
|
-
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1454
|
-
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
1455
|
-
}
|
|
1456
|
-
const etag = response.headers.get("etag");
|
|
1457
|
-
if (!etag) {
|
|
1458
|
-
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
1459
|
-
}
|
|
1460
|
-
return etag;
|
|
1461
|
-
}
|
|
1462
|
-
async function completeUpload(url, parts) {
|
|
1463
|
-
const response = await fetch(url, {
|
|
1464
|
-
body: buildCompleteXml(parts),
|
|
1465
|
-
headers: { "content-type": "application/xml" },
|
|
1466
|
-
method: "POST"
|
|
1467
|
-
});
|
|
1468
|
-
const text = await response.text().catch(() => "");
|
|
1469
|
-
if (!response.ok || text.includes("<Error>")) {
|
|
1470
|
-
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
async function abortUpload(url) {
|
|
1474
|
-
await fetch(url, { method: "DELETE" });
|
|
1475
|
-
}
|
|
1476
|
-
async function assertFfmpeg() {
|
|
1477
|
-
try {
|
|
1478
|
-
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
1479
|
-
await proc.exited;
|
|
1480
|
-
if (proc.exitCode !== 0) {
|
|
1481
|
-
throw new Error("ffmpeg -version exited non-zero");
|
|
1482
|
-
}
|
|
1483
|
-
} catch {
|
|
1484
|
-
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
async function deriveRendition(inputPath, outputPath) {
|
|
1488
|
-
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
1489
|
-
stderr: "pipe",
|
|
1490
|
-
stdout: "ignore"
|
|
1491
|
-
});
|
|
1492
|
-
await proc.exited;
|
|
1493
|
-
if (proc.exitCode !== 0) {
|
|
1494
|
-
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
1495
|
-
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
1499
|
-
var init_mixtape_set_video = __esm(() => {
|
|
1500
|
-
init_api();
|
|
1501
|
-
init_output();
|
|
1502
|
-
SET_VIDEO_RENDITION = {
|
|
1503
|
-
audioBitrate: "192k",
|
|
1504
|
-
crf: 20,
|
|
1505
|
-
gopSeconds: 2,
|
|
1506
|
-
height: 1080
|
|
1507
|
-
};
|
|
1508
|
-
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1509
|
-
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
1332
|
// src/commands/mixtapes.ts
|
|
1513
1333
|
var exports_mixtapes = {};
|
|
1514
1334
|
__export(exports_mixtapes, {
|
|
@@ -1523,7 +1343,7 @@ __export(exports_mixtapes, {
|
|
|
1523
1343
|
mixtapeDeleteCommand: () => mixtapeDeleteCommand,
|
|
1524
1344
|
mixtapeCreateCommand: () => mixtapeCreateCommand
|
|
1525
1345
|
});
|
|
1526
|
-
import { existsSync
|
|
1346
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1527
1347
|
async function mixtapesCommand() {
|
|
1528
1348
|
const response = await publicApiGet("/api/mixtapes");
|
|
1529
1349
|
return response.mixtapes;
|
|
@@ -1552,21 +1372,24 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1552
1372
|
if (!mixtape.id) {
|
|
1553
1373
|
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1554
1374
|
}
|
|
1555
|
-
|
|
1375
|
+
if (mixtape.status === "draft") {
|
|
1376
|
+
throw new CliError2("mixtape_not_promoted", `${mixtape.id} is still a draft — promote its recording first:
|
|
1377
|
+
` + " fluncle admin recordings promote <recordingId>");
|
|
1378
|
+
}
|
|
1379
|
+
const both = !options.youtube && !options.mixcloud;
|
|
1556
1380
|
const doYoutube = both || Boolean(options.youtube);
|
|
1557
1381
|
const doMixcloud = both || Boolean(options.mixcloud);
|
|
1558
|
-
const doSetVideo = Boolean(options.setVideo);
|
|
1559
1382
|
if (doYoutube && !options.video) {
|
|
1560
1383
|
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1561
1384
|
}
|
|
1562
1385
|
if (doMixcloud && !options.audio) {
|
|
1563
1386
|
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1564
1387
|
}
|
|
1565
|
-
if (doSetVideo && !options.video) {
|
|
1566
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1567
|
-
}
|
|
1568
1388
|
const mixtapeId = mixtape.id;
|
|
1569
|
-
|
|
1389
|
+
const logId = mixtape.logId;
|
|
1390
|
+
if (!logId) {
|
|
1391
|
+
throw new CliError2("missing_log_id", "The mixtape has no Log ID — was it promoted successfully?");
|
|
1392
|
+
}
|
|
1570
1393
|
if (!mixtape.durationMs) {
|
|
1571
1394
|
const source = options.audio ?? options.video;
|
|
1572
1395
|
const durationMs = source ? await probeDurationMs(source) : undefined;
|
|
@@ -1575,18 +1398,10 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1575
1398
|
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
1576
1399
|
}
|
|
1577
1400
|
}
|
|
1578
|
-
if (mixtape.status === "
|
|
1579
|
-
onProgress("Minting the coordinate…");
|
|
1580
|
-
const published = await mixtapePublishCommand(mixtapeId);
|
|
1581
|
-
logId = published.mixtape.logId;
|
|
1582
|
-
onProgress(`Minted ${logId}.`);
|
|
1583
|
-
} else if (mixtape.status === "published") {
|
|
1401
|
+
if (mixtape.status === "published") {
|
|
1584
1402
|
onProgress(`Already published (${logId}); re-distributing.`);
|
|
1585
1403
|
} else {
|
|
1586
|
-
onProgress(`
|
|
1587
|
-
}
|
|
1588
|
-
if (!logId) {
|
|
1589
|
-
throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
|
|
1404
|
+
onProgress(`Distributing ${logId} (promoted — coordinate already minted).`);
|
|
1590
1405
|
}
|
|
1591
1406
|
const results = [];
|
|
1592
1407
|
if (doYoutube) {
|
|
@@ -1609,16 +1424,6 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1609
1424
|
results.push({ platform: "mixcloud", url: result.url });
|
|
1610
1425
|
onProgress(`Mixcloud: ${result.url}`);
|
|
1611
1426
|
}
|
|
1612
|
-
if (doSetVideo) {
|
|
1613
|
-
if (!options.video) {
|
|
1614
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1615
|
-
}
|
|
1616
|
-
onProgress("Set video: staging the 1080p rendition…");
|
|
1617
|
-
const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
|
|
1618
|
-
const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
|
|
1619
|
-
results.push({ platform: "set-video", url: result.url });
|
|
1620
|
-
onProgress(`Set video: ${result.url}`);
|
|
1621
|
-
}
|
|
1622
1427
|
return { logId, mixtapeId, results };
|
|
1623
1428
|
}
|
|
1624
1429
|
async function mixtapeResyncCommand(idOrLogId, options, onProgress = () => {}) {
|
|
@@ -1691,7 +1496,7 @@ function buildBody(options) {
|
|
|
1691
1496
|
return body;
|
|
1692
1497
|
}
|
|
1693
1498
|
function parseCueFile(filePath) {
|
|
1694
|
-
if (!
|
|
1499
|
+
if (!existsSync(filePath)) {
|
|
1695
1500
|
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1696
1501
|
}
|
|
1697
1502
|
const text = readFileSync2(filePath, "utf-8");
|
|
@@ -2446,7 +2251,7 @@ function parseVersion2(version) {
|
|
|
2446
2251
|
var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
|
|
2447
2252
|
var init_version2 = __esm(() => {
|
|
2448
2253
|
init_output();
|
|
2449
|
-
currentVersion2 = "0.
|
|
2254
|
+
currentVersion2 = "0.86.0".trim() ? "0.86.0".trim() : "0.1.0";
|
|
2450
2255
|
});
|
|
2451
2256
|
|
|
2452
2257
|
// ../../packages/registry/src/index.ts
|
|
@@ -3200,7 +3005,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
|
|
|
3200
3005
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
3201
3006
|
throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
|
|
3202
3007
|
}
|
|
3203
|
-
urls[spec.field] = `${
|
|
3008
|
+
urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
|
|
3204
3009
|
}
|
|
3205
3010
|
const manifest = files.render ? await readManifestFields(files.render) : {};
|
|
3206
3011
|
const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
|
|
@@ -3311,7 +3116,7 @@ async function trackNoteCommand(idOrLogId, options) {
|
|
|
3311
3116
|
const body = { note: options.note };
|
|
3312
3117
|
return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
|
|
3313
3118
|
}
|
|
3314
|
-
var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high",
|
|
3119
|
+
var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
|
|
3315
3120
|
var init_track = __esm(() => {
|
|
3316
3121
|
init_api();
|
|
3317
3122
|
init_output();
|
|
@@ -3705,7 +3510,7 @@ __export(exports_mixtape_youtube2, {
|
|
|
3705
3510
|
distributeYoutube: () => distributeYoutube2,
|
|
3706
3511
|
authYoutubeCommand: () => authYoutubeCommand2
|
|
3707
3512
|
});
|
|
3708
|
-
import { statSync as
|
|
3513
|
+
import { statSync as statSync2 } from "node:fs";
|
|
3709
3514
|
async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
|
|
3710
3515
|
const contentLength = fileSize2(videoPath);
|
|
3711
3516
|
const contentType = "video/mp4";
|
|
@@ -3841,7 +3646,7 @@ async function resolveMixtapeId2(idOrLogId) {
|
|
|
3841
3646
|
}
|
|
3842
3647
|
function fileSize2(path2) {
|
|
3843
3648
|
try {
|
|
3844
|
-
return
|
|
3649
|
+
return statSync2(path2).size;
|
|
3845
3650
|
} catch {
|
|
3846
3651
|
throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
|
|
3847
3652
|
}
|
|
@@ -3861,11 +3666,172 @@ var init_mixtape_youtube2 = __esm(() => {
|
|
|
3861
3666
|
init_output();
|
|
3862
3667
|
});
|
|
3863
3668
|
|
|
3669
|
+
// src/commands/mixtape-set-video.ts
|
|
3670
|
+
import { randomUUID } from "node:crypto";
|
|
3671
|
+
import { existsSync as existsSync2, rmSync as rmSync2, statSync as statSync3 } from "node:fs";
|
|
3672
|
+
import { tmpdir } from "node:os";
|
|
3673
|
+
import { join as join4 } from "node:path";
|
|
3674
|
+
function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
|
|
3675
|
+
if (!Number.isInteger(contentLength) || contentLength <= 0) {
|
|
3676
|
+
throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
|
|
3677
|
+
}
|
|
3678
|
+
let effective = Math.max(partSize, MIN_PART_SIZE);
|
|
3679
|
+
if (Math.ceil(contentLength / effective) > MAX_PARTS) {
|
|
3680
|
+
effective = Math.ceil(contentLength / MAX_PARTS);
|
|
3681
|
+
}
|
|
3682
|
+
const parts = [];
|
|
3683
|
+
let start = 0;
|
|
3684
|
+
let partNumber = 1;
|
|
3685
|
+
while (start < contentLength) {
|
|
3686
|
+
const end = Math.min(start + effective, contentLength);
|
|
3687
|
+
parts.push({ end, partNumber, size: end - start, start });
|
|
3688
|
+
start = end;
|
|
3689
|
+
partNumber += 1;
|
|
3690
|
+
}
|
|
3691
|
+
return { partCount: parts.length, partSize: effective, parts };
|
|
3692
|
+
}
|
|
3693
|
+
function escapeXml(value) {
|
|
3694
|
+
return value.replace(/[<>&'"]/g, (char) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[char] ?? char);
|
|
3695
|
+
}
|
|
3696
|
+
function buildCompleteXml(parts) {
|
|
3697
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
3698
|
+
const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
|
|
3699
|
+
return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
|
|
3700
|
+
}
|
|
3701
|
+
function renditionFfmpegArgs(inputPath, outputPath) {
|
|
3702
|
+
return [
|
|
3703
|
+
"-y",
|
|
3704
|
+
"-i",
|
|
3705
|
+
inputPath,
|
|
3706
|
+
"-vf",
|
|
3707
|
+
`scale=-2:${SET_VIDEO_RENDITION.height}`,
|
|
3708
|
+
"-c:v",
|
|
3709
|
+
"libx264",
|
|
3710
|
+
"-preset",
|
|
3711
|
+
"medium",
|
|
3712
|
+
"-crf",
|
|
3713
|
+
String(SET_VIDEO_RENDITION.crf),
|
|
3714
|
+
"-force_key_frames",
|
|
3715
|
+
`expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
|
|
3716
|
+
"-pix_fmt",
|
|
3717
|
+
"yuv420p",
|
|
3718
|
+
"-c:a",
|
|
3719
|
+
"aac",
|
|
3720
|
+
"-b:a",
|
|
3721
|
+
SET_VIDEO_RENDITION.audioBitrate,
|
|
3722
|
+
"-movflags",
|
|
3723
|
+
"+faststart",
|
|
3724
|
+
outputPath
|
|
3725
|
+
];
|
|
3726
|
+
}
|
|
3727
|
+
async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
|
|
3728
|
+
if (!existsSync2(masterPath)) {
|
|
3729
|
+
throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
|
|
3730
|
+
}
|
|
3731
|
+
await assertFfmpeg();
|
|
3732
|
+
const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
|
|
3733
|
+
onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
|
|
3734
|
+
await deriveRendition(masterPath, renditionPath);
|
|
3735
|
+
try {
|
|
3736
|
+
const size = statSync3(renditionPath).size;
|
|
3737
|
+
const plan = planMultipart(size);
|
|
3738
|
+
onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
|
|
3739
|
+
const presign = await adminApiPost(presignPath, {
|
|
3740
|
+
partCount: plan.partCount
|
|
3741
|
+
});
|
|
3742
|
+
const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
|
|
3743
|
+
const completed = [];
|
|
3744
|
+
try {
|
|
3745
|
+
for (const part of plan.parts) {
|
|
3746
|
+
const url = urlByPart.get(part.partNumber);
|
|
3747
|
+
if (!url) {
|
|
3748
|
+
throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
|
|
3749
|
+
}
|
|
3750
|
+
onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
|
|
3751
|
+
const etag = await putPart(url, renditionPath, part);
|
|
3752
|
+
completed.push({ etag, partNumber: part.partNumber });
|
|
3753
|
+
}
|
|
3754
|
+
await completeUpload(presign.completeUrl, completed);
|
|
3755
|
+
} catch (error) {
|
|
3756
|
+
await abortUpload(presign.abortUrl).catch(() => {});
|
|
3757
|
+
throw error;
|
|
3758
|
+
}
|
|
3759
|
+
return { key: presign.key, url: `${FOUND_BASE2}/${presign.key}` };
|
|
3760
|
+
} finally {
|
|
3761
|
+
rmSync2(renditionPath, { force: true });
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
async function putPart(url, path2, part) {
|
|
3765
|
+
const response = await fetch(url, {
|
|
3766
|
+
body: Bun.file(path2).slice(part.start, part.end),
|
|
3767
|
+
method: "PUT"
|
|
3768
|
+
});
|
|
3769
|
+
if (!response.ok) {
|
|
3770
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
3771
|
+
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
3772
|
+
}
|
|
3773
|
+
const etag = response.headers.get("etag");
|
|
3774
|
+
if (!etag) {
|
|
3775
|
+
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
3776
|
+
}
|
|
3777
|
+
return etag;
|
|
3778
|
+
}
|
|
3779
|
+
async function completeUpload(url, parts) {
|
|
3780
|
+
const response = await fetch(url, {
|
|
3781
|
+
body: buildCompleteXml(parts),
|
|
3782
|
+
headers: { "content-type": "application/xml" },
|
|
3783
|
+
method: "POST"
|
|
3784
|
+
});
|
|
3785
|
+
const text = await response.text().catch(() => "");
|
|
3786
|
+
if (!response.ok || text.includes("<Error>")) {
|
|
3787
|
+
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
async function abortUpload(url) {
|
|
3791
|
+
await fetch(url, { method: "DELETE" });
|
|
3792
|
+
}
|
|
3793
|
+
async function assertFfmpeg() {
|
|
3794
|
+
try {
|
|
3795
|
+
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
3796
|
+
await proc.exited;
|
|
3797
|
+
if (proc.exitCode !== 0) {
|
|
3798
|
+
throw new Error("ffmpeg -version exited non-zero");
|
|
3799
|
+
}
|
|
3800
|
+
} catch {
|
|
3801
|
+
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
async function deriveRendition(inputPath, outputPath) {
|
|
3805
|
+
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
3806
|
+
stderr: "pipe",
|
|
3807
|
+
stdout: "ignore"
|
|
3808
|
+
});
|
|
3809
|
+
await proc.exited;
|
|
3810
|
+
if (proc.exitCode !== 0) {
|
|
3811
|
+
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
3812
|
+
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
var FOUND_BASE2 = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
3816
|
+
var init_mixtape_set_video = __esm(() => {
|
|
3817
|
+
init_api();
|
|
3818
|
+
init_output();
|
|
3819
|
+
SET_VIDEO_RENDITION = {
|
|
3820
|
+
audioBitrate: "192k",
|
|
3821
|
+
crf: 20,
|
|
3822
|
+
gopSeconds: 2,
|
|
3823
|
+
height: 1080
|
|
3824
|
+
};
|
|
3825
|
+
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
3826
|
+
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
3827
|
+
});
|
|
3828
|
+
|
|
3864
3829
|
// src/commands/recordings.ts
|
|
3865
3830
|
var exports_recordings = {};
|
|
3866
3831
|
__export(exports_recordings, {
|
|
3867
3832
|
recordingsListCommand: () => recordingsListCommand,
|
|
3868
3833
|
recordingUpdateCommand: () => recordingUpdateCommand,
|
|
3834
|
+
recordingReplaceCuesCommand: () => recordingReplaceCuesCommand,
|
|
3869
3835
|
recordingPromoteCommand: () => recordingPromoteCommand,
|
|
3870
3836
|
recordingGetCommand: () => recordingGetCommand,
|
|
3871
3837
|
recordingGet: () => recordingGet,
|
|
@@ -3878,12 +3844,21 @@ async function recordingGet(id) {
|
|
|
3878
3844
|
return response.recording;
|
|
3879
3845
|
}
|
|
3880
3846
|
function formatRecordingSummary(recording) {
|
|
3847
|
+
const kind = recording.hasVideo ? `take v${recording.version}` : "plan";
|
|
3881
3848
|
const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
|
|
3882
3849
|
const cues = recording.tracklist.length;
|
|
3883
|
-
return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
|
|
3850
|
+
return `${recording.id} [${kind}] ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
|
|
3884
3851
|
}
|
|
3885
3852
|
async function recordingsListCommand(options = {}) {
|
|
3886
|
-
const
|
|
3853
|
+
const query = new URLSearchParams;
|
|
3854
|
+
if (options.kind) {
|
|
3855
|
+
query.set("kind", options.kind);
|
|
3856
|
+
}
|
|
3857
|
+
if (options.parentId) {
|
|
3858
|
+
query.set("parentId", options.parentId);
|
|
3859
|
+
}
|
|
3860
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
3861
|
+
const response = await adminApiGet(`/api/admin/recordings${suffix}`);
|
|
3887
3862
|
if (options.json) {
|
|
3888
3863
|
printJson2({ ok: true, recordings: response.recordings });
|
|
3889
3864
|
return;
|
|
@@ -3912,9 +3887,22 @@ async function recordingGetCommand(id, options = {}) {
|
|
|
3912
3887
|
}
|
|
3913
3888
|
}
|
|
3914
3889
|
async function recordingCreateCommand(options = {}) {
|
|
3890
|
+
if (options.plan) {
|
|
3891
|
+
const created2 = await adminApiPost("/api/admin/recordings", {
|
|
3892
|
+
kind: "plan",
|
|
3893
|
+
recordedAt: options.recordedAt
|
|
3894
|
+
});
|
|
3895
|
+
const recording2 = created2.recording;
|
|
3896
|
+
if (options.json) {
|
|
3897
|
+
printJson2({ ok: true, recording: recording2 });
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
console.log(`Plan ${recording2.id} created — handle "${recording2.title}"`);
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3915
3903
|
const title = options.title?.trim();
|
|
3916
3904
|
if (!title) {
|
|
3917
|
-
throw new CliError2("missing_title", "A recording needs a --title");
|
|
3905
|
+
throw new CliError2("missing_title", "A recording needs a --title (or --plan for a videoless plan)");
|
|
3918
3906
|
}
|
|
3919
3907
|
if (!options.video) {
|
|
3920
3908
|
throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
|
|
@@ -3951,6 +3939,9 @@ async function recordingUpdateCommand(id, options = {}) {
|
|
|
3951
3939
|
if (options.recordedAt !== undefined) {
|
|
3952
3940
|
body.recordedAt = options.recordedAt;
|
|
3953
3941
|
}
|
|
3942
|
+
if (options.parentId !== undefined) {
|
|
3943
|
+
body.parentId = options.parentId;
|
|
3944
|
+
}
|
|
3954
3945
|
if (options.tracklistFile !== undefined) {
|
|
3955
3946
|
if (!existsSync3(options.tracklistFile)) {
|
|
3956
3947
|
throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
|
|
@@ -3962,7 +3953,7 @@ async function recordingUpdateCommand(id, options = {}) {
|
|
|
3962
3953
|
}
|
|
3963
3954
|
}
|
|
3964
3955
|
if (Object.keys(body).length === 0) {
|
|
3965
|
-
throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
|
|
3956
|
+
throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, --parent-id, or --tracklist-file");
|
|
3966
3957
|
}
|
|
3967
3958
|
const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
|
|
3968
3959
|
if (options.json) {
|
|
@@ -3994,6 +3985,29 @@ async function recordingPromoteCommand(id, options = {}) {
|
|
|
3994
3985
|
}
|
|
3995
3986
|
console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
|
|
3996
3987
|
}
|
|
3988
|
+
async function recordingReplaceCuesCommand(id, options = {}) {
|
|
3989
|
+
if (!id) {
|
|
3990
|
+
throw new CliError2("missing_id", "Missing recording id for: replace-cues");
|
|
3991
|
+
}
|
|
3992
|
+
if (!options.cuesFile) {
|
|
3993
|
+
throw new CliError2("missing_cues_file", "replace-cues needs a --cues-file <file> (the ordered cue array)");
|
|
3994
|
+
}
|
|
3995
|
+
if (!existsSync3(options.cuesFile)) {
|
|
3996
|
+
throw new CliError2("file_not_found", `Cues file not found: ${options.cuesFile}`);
|
|
3997
|
+
}
|
|
3998
|
+
let cues;
|
|
3999
|
+
try {
|
|
4000
|
+
cues = JSON.parse(readFileSync3(options.cuesFile, "utf8"));
|
|
4001
|
+
} catch {
|
|
4002
|
+
throw new CliError2("invalid_cues", `Cues file is not valid JSON: ${options.cuesFile}`);
|
|
4003
|
+
}
|
|
4004
|
+
const response = await adminApiPut(`/api/admin/recordings/${encodeURIComponent(id)}/cues`, { cues });
|
|
4005
|
+
if (options.json) {
|
|
4006
|
+
printJson2({ ok: true, recording: response.recording });
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
console.log(`Replaced cues for ${formatRecordingSummary(response.recording)}`);
|
|
4010
|
+
}
|
|
3997
4011
|
var init_recordings = __esm(() => {
|
|
3998
4012
|
init_api();
|
|
3999
4013
|
init_output();
|
|
@@ -4043,21 +4057,24 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
|
|
|
4043
4057
|
if (!mixtape.id) {
|
|
4044
4058
|
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
4045
4059
|
}
|
|
4046
|
-
|
|
4060
|
+
if (mixtape.status === "draft") {
|
|
4061
|
+
throw new CliError2("mixtape_not_promoted", `${mixtape.id} is still a draft — promote its recording first:
|
|
4062
|
+
` + " fluncle admin recordings promote <recordingId>");
|
|
4063
|
+
}
|
|
4064
|
+
const both = !options.youtube && !options.mixcloud;
|
|
4047
4065
|
const doYoutube = both || Boolean(options.youtube);
|
|
4048
4066
|
const doMixcloud = both || Boolean(options.mixcloud);
|
|
4049
|
-
const doSetVideo = Boolean(options.setVideo);
|
|
4050
4067
|
if (doYoutube && !options.video) {
|
|
4051
4068
|
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
4052
4069
|
}
|
|
4053
4070
|
if (doMixcloud && !options.audio) {
|
|
4054
4071
|
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
4055
4072
|
}
|
|
4056
|
-
if (doSetVideo && !options.video) {
|
|
4057
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
4058
|
-
}
|
|
4059
4073
|
const mixtapeId = mixtape.id;
|
|
4060
|
-
|
|
4074
|
+
const logId = mixtape.logId;
|
|
4075
|
+
if (!logId) {
|
|
4076
|
+
throw new CliError2("missing_log_id", "The mixtape has no Log ID — was it promoted successfully?");
|
|
4077
|
+
}
|
|
4061
4078
|
if (!mixtape.durationMs) {
|
|
4062
4079
|
const source = options.audio ?? options.video;
|
|
4063
4080
|
const durationMs = source ? await probeDurationMs2(source) : undefined;
|
|
@@ -4066,18 +4083,10 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
|
|
|
4066
4083
|
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
4067
4084
|
}
|
|
4068
4085
|
}
|
|
4069
|
-
if (mixtape.status === "
|
|
4070
|
-
onProgress("Minting the coordinate…");
|
|
4071
|
-
const published = await mixtapePublishCommand2(mixtapeId);
|
|
4072
|
-
logId = published.mixtape.logId;
|
|
4073
|
-
onProgress(`Minted ${logId}.`);
|
|
4074
|
-
} else if (mixtape.status === "published") {
|
|
4086
|
+
if (mixtape.status === "published") {
|
|
4075
4087
|
onProgress(`Already published (${logId}); re-distributing.`);
|
|
4076
4088
|
} else {
|
|
4077
|
-
onProgress(`
|
|
4078
|
-
}
|
|
4079
|
-
if (!logId) {
|
|
4080
|
-
throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
|
|
4089
|
+
onProgress(`Distributing ${logId} (promoted — coordinate already minted).`);
|
|
4081
4090
|
}
|
|
4082
4091
|
const results = [];
|
|
4083
4092
|
if (doYoutube) {
|
|
@@ -4100,16 +4109,6 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
|
|
|
4100
4109
|
results.push({ platform: "mixcloud", url: result.url });
|
|
4101
4110
|
onProgress(`Mixcloud: ${result.url}`);
|
|
4102
4111
|
}
|
|
4103
|
-
if (doSetVideo) {
|
|
4104
|
-
if (!options.video) {
|
|
4105
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
4106
|
-
}
|
|
4107
|
-
onProgress("Set video: staging the 1080p rendition…");
|
|
4108
|
-
const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
|
|
4109
|
-
const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
|
|
4110
|
-
results.push({ platform: "set-video", url: result.url });
|
|
4111
|
-
onProgress(`Set video: ${result.url}`);
|
|
4112
|
-
}
|
|
4113
4112
|
return { logId, mixtapeId, results };
|
|
4114
4113
|
}
|
|
4115
4114
|
async function mixtapeResyncCommand2(idOrLogId, options, onProgress = () => {}) {
|
|
@@ -4352,7 +4351,7 @@ async function resolveClipSource(clip) {
|
|
|
4352
4351
|
throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
|
|
4353
4352
|
}
|
|
4354
4353
|
if (!mixtape.setVideoAt) {
|
|
4355
|
-
throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`
|
|
4354
|
+
throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`recordings promote <recordingId>\` first`);
|
|
4356
4355
|
}
|
|
4357
4356
|
return { setUrl: setVideoUrl(mixtape.logId) };
|
|
4358
4357
|
}
|
|
@@ -4441,6 +4440,7 @@ var exports_recordings2 = {};
|
|
|
4441
4440
|
__export(exports_recordings2, {
|
|
4442
4441
|
recordingsListCommand: () => recordingsListCommand2,
|
|
4443
4442
|
recordingUpdateCommand: () => recordingUpdateCommand2,
|
|
4443
|
+
recordingReplaceCuesCommand: () => recordingReplaceCuesCommand2,
|
|
4444
4444
|
recordingPromoteCommand: () => recordingPromoteCommand2,
|
|
4445
4445
|
recordingGetCommand: () => recordingGetCommand2,
|
|
4446
4446
|
recordingGet: () => recordingGet2,
|
|
@@ -4453,12 +4453,21 @@ async function recordingGet2(id) {
|
|
|
4453
4453
|
return response.recording;
|
|
4454
4454
|
}
|
|
4455
4455
|
function formatRecordingSummary2(recording) {
|
|
4456
|
+
const kind = recording.hasVideo ? `take v${recording.version}` : "plan";
|
|
4456
4457
|
const promoted = recording.logId ? ` → fluncle://${recording.logId}` : " (un-promoted)";
|
|
4457
4458
|
const cues = recording.tracklist.length;
|
|
4458
|
-
return `${recording.id} ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
|
|
4459
|
+
return `${recording.id} [${kind}] ${recording.title}${promoted} [${cues} cue${cues === 1 ? "" : "s"}]`;
|
|
4459
4460
|
}
|
|
4460
4461
|
async function recordingsListCommand2(options = {}) {
|
|
4461
|
-
const
|
|
4462
|
+
const query = new URLSearchParams;
|
|
4463
|
+
if (options.kind) {
|
|
4464
|
+
query.set("kind", options.kind);
|
|
4465
|
+
}
|
|
4466
|
+
if (options.parentId) {
|
|
4467
|
+
query.set("parentId", options.parentId);
|
|
4468
|
+
}
|
|
4469
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
4470
|
+
const response = await adminApiGet(`/api/admin/recordings${suffix}`);
|
|
4462
4471
|
if (options.json) {
|
|
4463
4472
|
printJson2({ ok: true, recordings: response.recordings });
|
|
4464
4473
|
return;
|
|
@@ -4487,9 +4496,22 @@ async function recordingGetCommand2(id, options = {}) {
|
|
|
4487
4496
|
}
|
|
4488
4497
|
}
|
|
4489
4498
|
async function recordingCreateCommand2(options = {}) {
|
|
4499
|
+
if (options.plan) {
|
|
4500
|
+
const created2 = await adminApiPost("/api/admin/recordings", {
|
|
4501
|
+
kind: "plan",
|
|
4502
|
+
recordedAt: options.recordedAt
|
|
4503
|
+
});
|
|
4504
|
+
const recording2 = created2.recording;
|
|
4505
|
+
if (options.json) {
|
|
4506
|
+
printJson2({ ok: true, recording: recording2 });
|
|
4507
|
+
return;
|
|
4508
|
+
}
|
|
4509
|
+
console.log(`Plan ${recording2.id} created — handle "${recording2.title}"`);
|
|
4510
|
+
return;
|
|
4511
|
+
}
|
|
4490
4512
|
const title = options.title?.trim();
|
|
4491
4513
|
if (!title) {
|
|
4492
|
-
throw new CliError2("missing_title", "A recording needs a --title");
|
|
4514
|
+
throw new CliError2("missing_title", "A recording needs a --title (or --plan for a videoless plan)");
|
|
4493
4515
|
}
|
|
4494
4516
|
if (!options.video) {
|
|
4495
4517
|
throw new CliError2("missing_video", "A recording needs a --video <file> to stage");
|
|
@@ -4526,6 +4548,9 @@ async function recordingUpdateCommand2(id, options = {}) {
|
|
|
4526
4548
|
if (options.recordedAt !== undefined) {
|
|
4527
4549
|
body.recordedAt = options.recordedAt;
|
|
4528
4550
|
}
|
|
4551
|
+
if (options.parentId !== undefined) {
|
|
4552
|
+
body.parentId = options.parentId;
|
|
4553
|
+
}
|
|
4529
4554
|
if (options.tracklistFile !== undefined) {
|
|
4530
4555
|
if (!existsSync5(options.tracklistFile)) {
|
|
4531
4556
|
throw new CliError2("file_not_found", `Tracklist file not found: ${options.tracklistFile}`);
|
|
@@ -4537,7 +4562,7 @@ async function recordingUpdateCommand2(id, options = {}) {
|
|
|
4537
4562
|
}
|
|
4538
4563
|
}
|
|
4539
4564
|
if (Object.keys(body).length === 0) {
|
|
4540
|
-
throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, or --tracklist-file");
|
|
4565
|
+
throw new CliError2("no_fields", "Nothing to update — pass --title, --recorded-at, --parent-id, or --tracklist-file");
|
|
4541
4566
|
}
|
|
4542
4567
|
const response = await adminApiPatch(`/api/admin/recordings/${encodeURIComponent(id)}`, body);
|
|
4543
4568
|
if (options.json) {
|
|
@@ -4569,6 +4594,29 @@ async function recordingPromoteCommand2(id, options = {}) {
|
|
|
4569
4594
|
}
|
|
4570
4595
|
console.log(recording.logId ? `Promoted recording ${id} → mixtape fluncle://${recording.logId}` : `Promoted recording ${id}`);
|
|
4571
4596
|
}
|
|
4597
|
+
async function recordingReplaceCuesCommand2(id, options = {}) {
|
|
4598
|
+
if (!id) {
|
|
4599
|
+
throw new CliError2("missing_id", "Missing recording id for: replace-cues");
|
|
4600
|
+
}
|
|
4601
|
+
if (!options.cuesFile) {
|
|
4602
|
+
throw new CliError2("missing_cues_file", "replace-cues needs a --cues-file <file> (the ordered cue array)");
|
|
4603
|
+
}
|
|
4604
|
+
if (!existsSync5(options.cuesFile)) {
|
|
4605
|
+
throw new CliError2("file_not_found", `Cues file not found: ${options.cuesFile}`);
|
|
4606
|
+
}
|
|
4607
|
+
let cues;
|
|
4608
|
+
try {
|
|
4609
|
+
cues = JSON.parse(readFileSync5(options.cuesFile, "utf8"));
|
|
4610
|
+
} catch {
|
|
4611
|
+
throw new CliError2("invalid_cues", `Cues file is not valid JSON: ${options.cuesFile}`);
|
|
4612
|
+
}
|
|
4613
|
+
const response = await adminApiPut(`/api/admin/recordings/${encodeURIComponent(id)}/cues`, { cues });
|
|
4614
|
+
if (options.json) {
|
|
4615
|
+
printJson2({ ok: true, recording: response.recording });
|
|
4616
|
+
return;
|
|
4617
|
+
}
|
|
4618
|
+
console.log(`Replaced cues for ${formatRecordingSummary2(response.recording)}`);
|
|
4619
|
+
}
|
|
4572
4620
|
var init_recordings2 = __esm(() => {
|
|
4573
4621
|
init_api();
|
|
4574
4622
|
init_output();
|
|
@@ -7598,7 +7646,7 @@ function addAdminCommands(program2) {
|
|
|
7598
7646
|
const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7599
7647
|
await runMixtapeGet(idOrLogId, options, mixtapeGetCommand2);
|
|
7600
7648
|
});
|
|
7601
|
-
adminMixtapes.command("distribute").description("
|
|
7649
|
+
adminMixtapes.command("distribute").description("Push a promoted mixtape to YouTube (video) and Mixcloud (audio). The mixtape must already be promoted (`recordings promote`) \u2014 distribute is push-only.").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) => {
|
|
7602
7650
|
const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7603
7651
|
await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
|
|
7604
7652
|
});
|
|
@@ -7626,11 +7674,11 @@ function addAdminCommands(program2) {
|
|
|
7626
7674
|
adminRecordings.action(() => {
|
|
7627
7675
|
adminRecordings.outputHelp();
|
|
7628
7676
|
});
|
|
7629
|
-
adminRecordings.command("create").description("Create a recording
|
|
7677
|
+
adminRecordings.command("create").description("Create a recording (--video to stage a take, or --plan for a videoless plan)").option("--plan", "Create a videoless PLAN (server mints a Galaxy-vocab handle)", false).option("--title <text>", "Recording title (a take)").option("--video <file>", "Set-video master to stage (a 1080p rendition is derived)").option("--recorded-at <date>", "Recorded date (ISO)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
|
|
7630
7678
|
const { recordingCreateCommand: recordingCreateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7631
7679
|
await recordingCreateCommand3(options);
|
|
7632
7680
|
});
|
|
7633
|
-
adminRecordings.command("list").description("List
|
|
7681
|
+
adminRecordings.command("list").description("List recordings (--kind plan|take, --parent-id <plan> for a plan's takes)").option("--kind <kind>", "Filter by kind: plan | take").option("--parent-id <id>", "List the takes attached to this plan").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
|
|
7634
7682
|
const { recordingsListCommand: recordingsListCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7635
7683
|
await recordingsListCommand3(options);
|
|
7636
7684
|
});
|
|
@@ -7638,10 +7686,14 @@ function addAdminCommands(program2) {
|
|
|
7638
7686
|
const { recordingGetCommand: recordingGetCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7639
7687
|
await recordingGetCommand3(id, options);
|
|
7640
7688
|
});
|
|
7641
|
-
adminRecordings.command("update").description("Update a recording's title
|
|
7689
|
+
adminRecordings.command("update").description("Update a recording's title/recorded date/tracklist, or attach it to a plan").argument("[id]").option("--title <text>", "Recording title").option("--recorded-at <date>", "Recorded date (ISO)").option("--parent-id <id>", "Attach this take to its plan (assigns the take's version)").option("--tracklist-file <file>", "JSON file with the whole cue tracklist array").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
7642
7690
|
const { recordingUpdateCommand: recordingUpdateCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7643
7691
|
await recordingUpdateCommand3(id, options);
|
|
7644
7692
|
});
|
|
7693
|
+
adminRecordings.command("replace-cues").description("Replace a recording's whole cue tracklist from a JSON file (--cues-file)").argument("[id]").option("--cues-file <file>", "JSON file with the ordered cue array").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
7694
|
+
const { recordingReplaceCuesCommand: recordingReplaceCuesCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7695
|
+
await recordingReplaceCuesCommand3(id, options);
|
|
7696
|
+
});
|
|
7645
7697
|
adminRecordings.command("delete").description("Delete a recording (cascade its clips)").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
7646
7698
|
const { recordingDeleteCommand: recordingDeleteCommand3 } = await Promise.resolve().then(() => (init_recordings2(), exports_recordings2));
|
|
7647
7699
|
await recordingDeleteCommand3(id, options);
|
package/package.json
CHANGED