fluncle 0.73.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/fluncle.mjs +413 -430
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -524,7 +524,7 @@ function parseVersion(version) {
524
524
  var currentVersion;
525
525
  var init_version = __esm(() => {
526
526
  init_output();
527
- currentVersion = "0.73.0".trim() ? "0.73.0".trim() : "0.1.0";
527
+ currentVersion = "0.74.0".trim() ? "0.74.0".trim() : "0.1.0";
528
528
  });
529
529
 
530
530
  // src/update-notifier.ts
@@ -988,6 +988,24 @@ var init_recent = __esm(() => {
988
988
  init_api();
989
989
  });
990
990
 
991
+ // src/commands/mixtape-api.ts
992
+ async function mixtapeListCommand() {
993
+ const response = await adminApiGet("/api/admin/mixtapes");
994
+ return response.mixtapes;
995
+ }
996
+ async function mixtapeGetCommand(idOrLogId) {
997
+ const mixtapes = await mixtapeListCommand();
998
+ const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
999
+ if (!match) {
1000
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1001
+ }
1002
+ return match;
1003
+ }
1004
+ var init_mixtape_api = __esm(() => {
1005
+ init_api();
1006
+ init_output();
1007
+ });
1008
+
991
1009
  // src/commands/mixtape-youtube.ts
992
1010
  var exports_mixtape_youtube = {};
993
1011
  __export(exports_mixtape_youtube, {
@@ -1148,6 +1166,153 @@ var init_mixtape_youtube = __esm(() => {
1148
1166
  init_output();
1149
1167
  });
1150
1168
 
1169
+ // src/commands/mixtape-mixcloud.ts
1170
+ var exports_mixtape_mixcloud = {};
1171
+ __export(exports_mixtape_mixcloud, {
1172
+ mixtapeDescription: () => mixtapeDescription,
1173
+ mixcloudSections: () => mixcloudSections,
1174
+ distributeMixcloud: () => distributeMixcloud,
1175
+ authMixcloudCommand: () => authMixcloudCommand
1176
+ });
1177
+ async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
1178
+ const token = await fetchMixcloudToken();
1179
+ const mixtape = await mixtapeGetCommand(mixtapeId);
1180
+ const logId = mixtape.logId;
1181
+ if (!logId) {
1182
+ throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
1183
+ }
1184
+ const audio = Bun.file(audioPath);
1185
+ if (!await audio.exists()) {
1186
+ throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
1187
+ }
1188
+ const form = new FormData;
1189
+ form.append("mp3", audio);
1190
+ form.append("name", mixtape.title);
1191
+ form.append("description", mixtapeDescription(mixtape.note, logId));
1192
+ onProgress?.("Mixcloud: fetching the cover…");
1193
+ const picture = await fetchCover(logId);
1194
+ if (picture) {
1195
+ form.append("picture", picture, "cover.png");
1196
+ }
1197
+ for (const [index, tag] of mixtapeTags(mixtape).entries()) {
1198
+ form.append(`tags-${index}-tag`, tag);
1199
+ }
1200
+ const sections = mixcloudSections(mixtape.members);
1201
+ for (const [index, section] of sections.entries()) {
1202
+ form.append(`sections-${index}-artist`, section.artist);
1203
+ form.append(`sections-${index}-song`, section.song);
1204
+ form.append(`sections-${index}-start_time`, String(section.start_time));
1205
+ }
1206
+ if (unlisted) {
1207
+ form.append("unlisted", "1");
1208
+ onProgress?.("Mixcloud: uploading UNLISTED (private).");
1209
+ }
1210
+ const cuelessCount = mixtape.members.length - sections.length;
1211
+ if (cuelessCount > 0) {
1212
+ onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
1213
+ }
1214
+ onProgress?.("Mixcloud: uploading the master…");
1215
+ const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
1216
+ body: form,
1217
+ method: "POST"
1218
+ });
1219
+ const uploadText = await uploadResponse.text();
1220
+ if (!uploadResponse.ok) {
1221
+ throwMixcloudError(uploadResponse.status, uploadText);
1222
+ }
1223
+ const result = parseUploadResult(uploadText);
1224
+ if (!result.success || !result.key) {
1225
+ throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
1226
+ }
1227
+ const externalId = result.key;
1228
+ const url = `https://www.mixcloud.com${result.key}`;
1229
+ onProgress?.("Mixcloud: recording the link…");
1230
+ await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
1231
+ externalId,
1232
+ url
1233
+ });
1234
+ return { url };
1235
+ }
1236
+ async function authMixcloudCommand() {
1237
+ const response = await adminApiGet("/api/admin/mixcloud/auth/start");
1238
+ console.log(`Open this Mixcloud authorization URL:
1239
+
1240
+ ${response.authUrl}
1241
+
1242
+ After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
1243
+ }
1244
+ function mixtapeDescription(note, logId) {
1245
+ const breadcrumb = `fluncle://${logId}`;
1246
+ const body = (note ?? "").trim();
1247
+ const full = body ? `${body}
1248
+
1249
+ ${breadcrumb}` : breadcrumb;
1250
+ if (full.length <= DESCRIPTION_MAX) {
1251
+ return full;
1252
+ }
1253
+ const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
1254
+ const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
1255
+ return trimmedNote ? `${trimmedNote}
1256
+
1257
+ ${breadcrumb}` : breadcrumb;
1258
+ }
1259
+ function mixcloudSections(members) {
1260
+ return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
1261
+ artist: member.artists.join(", "),
1262
+ song: member.title,
1263
+ start_time: Math.floor(member.startMs / 1000)
1264
+ }));
1265
+ }
1266
+ function mixtapeTags(_mixtape) {
1267
+ return ["Drum & Bass", "Fluncle"];
1268
+ }
1269
+ async function fetchMixcloudToken() {
1270
+ try {
1271
+ const response = await adminApiPost("/api/admin/mixcloud/token");
1272
+ return response.accessToken;
1273
+ } catch {
1274
+ throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
1275
+ }
1276
+ }
1277
+ async function fetchCover(logId) {
1278
+ for (const size of ["square", "og"]) {
1279
+ const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
1280
+ if (!response.ok) {
1281
+ continue;
1282
+ }
1283
+ const blob = await response.blob();
1284
+ if (blob.size <= PICTURE_MAX_BYTES) {
1285
+ return blob;
1286
+ }
1287
+ }
1288
+ return;
1289
+ }
1290
+ function parseUploadResult(body) {
1291
+ try {
1292
+ const data = JSON.parse(body);
1293
+ return {
1294
+ key: data.result?.key,
1295
+ message: data.result?.message,
1296
+ success: data.result?.success === true
1297
+ };
1298
+ } catch {
1299
+ return { message: body.slice(0, 300), success: false };
1300
+ }
1301
+ }
1302
+ function throwMixcloudError(status, body) {
1303
+ if (body.includes("An invalid access token was provided")) {
1304
+ throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
1305
+ }
1306
+ throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
1307
+ }
1308
+ var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
1309
+ var init_mixtape_mixcloud = __esm(() => {
1310
+ init_api();
1311
+ init_output();
1312
+ init_mixtape_api();
1313
+ PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1314
+ });
1315
+
1151
1316
  // src/commands/mixtape-set-video.ts
1152
1317
  var exports_mixtape_set_video = {};
1153
1318
  __export(exports_mixtape_set_video, {
@@ -1350,392 +1515,14 @@ async function mixtapeMembersCommand(id, refs, options) {
1350
1515
  }
1351
1516
  return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
1352
1517
  }
1353
- async function mixtapePublishCommand(id) {
1354
- return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
1355
- }
1356
- async function mixtapeDeleteCommand(id) {
1357
- return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
1358
- }
1359
- async function mixtapeListCommand() {
1360
- const response = await adminApiGet("/api/admin/mixtapes");
1361
- return response.mixtapes;
1362
- }
1363
- async function mixtapeGetCommand(idOrLogId) {
1364
- const mixtapes = await mixtapeListCommand();
1365
- const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
1366
- if (!match) {
1367
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1368
- }
1369
- return match;
1370
- }
1371
- async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
1372
- const mixtape = await mixtapeGetCommand(idOrLogId);
1373
- if (!mixtape.id) {
1374
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1375
- }
1376
- const both = !options.youtube && !options.mixcloud && !options.setVideo;
1377
- const doYoutube = both || Boolean(options.youtube);
1378
- const doMixcloud = both || Boolean(options.mixcloud);
1379
- const doSetVideo = Boolean(options.setVideo);
1380
- if (doYoutube && !options.video) {
1381
- throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1382
- }
1383
- if (doMixcloud && !options.audio) {
1384
- throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1385
- }
1386
- if (doSetVideo && !options.video) {
1387
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1388
- }
1389
- const mixtapeId = mixtape.id;
1390
- let logId = mixtape.logId;
1391
- if (!mixtape.durationMs) {
1392
- const source = options.audio ?? options.video;
1393
- const durationMs = source ? await probeDurationMs(source) : undefined;
1394
- if (durationMs) {
1395
- await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
1396
- onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1397
- }
1398
- }
1399
- if (mixtape.status === "draft") {
1400
- onProgress("Minting the coordinate…");
1401
- const published = await mixtapePublishCommand(mixtapeId);
1402
- logId = published.mixtape.logId;
1403
- onProgress(`Minted ${logId}.`);
1404
- } else if (mixtape.status === "published") {
1405
- onProgress(`Already published (${logId}); re-distributing.`);
1406
- } else {
1407
- onProgress(`Resuming distribution for ${logId}.`);
1408
- }
1409
- if (!logId) {
1410
- throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
1411
- }
1412
- const results = [];
1413
- if (doYoutube) {
1414
- if (!options.video) {
1415
- throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1416
- }
1417
- onProgress("YouTube: uploading video…");
1418
- const { distributeYoutube: distributeYoutube2 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
1419
- const result = await distributeYoutube2(mixtapeId, options.video, onProgress);
1420
- results.push({ platform: "youtube", url: result.url });
1421
- onProgress(`YouTube: ${result.url}`);
1422
- }
1423
- if (doMixcloud) {
1424
- if (!options.audio) {
1425
- throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1426
- }
1427
- onProgress("Mixcloud: uploading audio…");
1428
- const { distributeMixcloud } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
1429
- const result = await distributeMixcloud(mixtapeId, options.audio, onProgress, options.unlisted);
1430
- results.push({ platform: "mixcloud", url: result.url });
1431
- onProgress(`Mixcloud: ${result.url}`);
1432
- }
1433
- if (doSetVideo) {
1434
- if (!options.video) {
1435
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1436
- }
1437
- onProgress("Set video: staging the 1080p rendition…");
1438
- const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1439
- const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1440
- results.push({ platform: "set-video", url: result.url });
1441
- onProgress(`Set video: ${result.url}`);
1442
- }
1443
- return { logId, mixtapeId, results };
1444
- }
1445
- async function probeDurationMs(filePath) {
1446
- try {
1447
- const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
1448
- const out = await new Response(proc.stdout).text();
1449
- await proc.exited;
1450
- const seconds = Number.parseFloat(out.trim());
1451
- return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
1452
- } catch {
1453
- return;
1454
- }
1455
- }
1456
- function buildBody(options) {
1457
- const body = {};
1458
- if (options.note !== undefined) {
1459
- body.note = options.note;
1460
- }
1461
- if (options.recordedAt !== undefined) {
1462
- body.recordedAt = options.recordedAt;
1463
- }
1464
- if (options.soundcloudUrl !== undefined) {
1465
- body.soundcloudUrl = options.soundcloudUrl;
1466
- }
1467
- if (options.durationMs !== undefined) {
1468
- const parsed = parseDuration(options.durationMs);
1469
- if (parsed === null) {
1470
- throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
1471
- }
1472
- body.durationMs = parsed;
1473
- }
1474
- return body;
1475
- }
1476
- function parseCueFile(filePath) {
1477
- if (!existsSync2(filePath)) {
1478
- throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1479
- }
1480
- const text = readFileSync2(filePath, "utf-8");
1481
- const trimmed = text.trim();
1482
- if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1483
- try {
1484
- const parsed = JSON.parse(trimmed);
1485
- if (!Array.isArray(parsed)) {
1486
- throw new Error("not an array");
1487
- }
1488
- return parsed.map((entry, index) => {
1489
- if (typeof entry === "string") {
1490
- return { ref: entry.trim() };
1491
- }
1492
- const obj = entry;
1493
- if (typeof obj?.ref !== "string") {
1494
- throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
1495
- }
1496
- const cue = { ref: obj.ref.trim() };
1497
- if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
1498
- cue.startMs = obj.startMs;
1499
- }
1500
- return cue;
1501
- });
1502
- } catch (error) {
1503
- if (error instanceof CliError2) {
1504
- throw error;
1505
- }
1506
- throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
1507
- }
1508
- }
1509
- return parseCueSheet(text);
1510
- }
1511
- function parseCueSheet(text) {
1512
- const entries = [];
1513
- for (const line of text.split(/\r?\n/)) {
1514
- const trimmed = line.trim();
1515
- if (!trimmed || trimmed.startsWith("#")) {
1516
- continue;
1517
- }
1518
- const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
1519
- if (match) {
1520
- const [, time, ref] = match;
1521
- if (time === undefined || ref === undefined) {
1522
- continue;
1523
- }
1524
- const startMs = parseDuration(time);
1525
- if (startMs === null) {
1526
- continue;
1527
- }
1528
- entries.push({ ref: ref.trim(), startMs });
1529
- } else {
1530
- entries.push({ ref: trimmed });
1531
- }
1532
- }
1533
- return entries;
1534
- }
1535
- var init_mixtapes = __esm(() => {
1536
- init_util();
1537
- init_api();
1538
- init_output();
1539
- });
1540
-
1541
- // src/commands/mixtape-mixcloud.ts
1542
- var exports_mixtape_mixcloud = {};
1543
- __export(exports_mixtape_mixcloud, {
1544
- mixtapeDescription: () => mixtapeDescription,
1545
- mixcloudSections: () => mixcloudSections,
1546
- distributeMixcloud: () => distributeMixcloud,
1547
- authMixcloudCommand: () => authMixcloudCommand
1548
- });
1549
- async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
1550
- const token = await fetchMixcloudToken();
1551
- const mixtape = await mixtapeGetCommand(mixtapeId);
1552
- const logId = mixtape.logId;
1553
- if (!logId) {
1554
- throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
1555
- }
1556
- const audio = Bun.file(audioPath);
1557
- if (!await audio.exists()) {
1558
- throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
1559
- }
1560
- const form = new FormData;
1561
- form.append("mp3", audio);
1562
- form.append("name", mixtape.title);
1563
- form.append("description", mixtapeDescription(mixtape.note, logId));
1564
- onProgress?.("Mixcloud: fetching the cover…");
1565
- const picture = await fetchCover(logId);
1566
- if (picture) {
1567
- form.append("picture", picture, "cover.png");
1568
- }
1569
- for (const [index, tag] of mixtapeTags(mixtape).entries()) {
1570
- form.append(`tags-${index}-tag`, tag);
1571
- }
1572
- const sections = mixcloudSections(mixtape.members);
1573
- for (const [index, section] of sections.entries()) {
1574
- form.append(`sections-${index}-artist`, section.artist);
1575
- form.append(`sections-${index}-song`, section.song);
1576
- form.append(`sections-${index}-start_time`, String(section.start_time));
1577
- }
1578
- if (unlisted) {
1579
- form.append("unlisted", "1");
1580
- onProgress?.("Mixcloud: uploading UNLISTED (private).");
1581
- }
1582
- const cuelessCount = mixtape.members.length - sections.length;
1583
- if (cuelessCount > 0) {
1584
- onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
1585
- }
1586
- onProgress?.("Mixcloud: uploading the master…");
1587
- const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
1588
- body: form,
1589
- method: "POST"
1590
- });
1591
- const uploadText = await uploadResponse.text();
1592
- if (!uploadResponse.ok) {
1593
- throwMixcloudError(uploadResponse.status, uploadText);
1594
- }
1595
- const result = parseUploadResult(uploadText);
1596
- if (!result.success || !result.key) {
1597
- throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
1598
- }
1599
- const externalId = result.key;
1600
- const url = `https://www.mixcloud.com${result.key}`;
1601
- onProgress?.("Mixcloud: recording the link…");
1602
- await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
1603
- externalId,
1604
- url
1605
- });
1606
- return { url };
1607
- }
1608
- async function authMixcloudCommand() {
1609
- const response = await adminApiGet("/api/admin/mixcloud/auth/start");
1610
- console.log(`Open this Mixcloud authorization URL:
1611
-
1612
- ${response.authUrl}
1613
-
1614
- After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
1615
- }
1616
- function mixtapeDescription(note, logId) {
1617
- const breadcrumb = `fluncle://${logId}`;
1618
- const body = (note ?? "").trim();
1619
- const full = body ? `${body}
1620
-
1621
- ${breadcrumb}` : breadcrumb;
1622
- if (full.length <= DESCRIPTION_MAX) {
1623
- return full;
1624
- }
1625
- const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
1626
- const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
1627
- return trimmedNote ? `${trimmedNote}
1628
-
1629
- ${breadcrumb}` : breadcrumb;
1630
- }
1631
- function mixcloudSections(members) {
1632
- return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
1633
- artist: member.artists.join(", "),
1634
- song: member.title,
1635
- start_time: Math.floor(member.startMs / 1000)
1636
- }));
1637
- }
1638
- function mixtapeTags(_mixtape) {
1639
- return ["Drum & Bass", "Fluncle"];
1640
- }
1641
- async function fetchMixcloudToken() {
1642
- try {
1643
- const response = await adminApiPost("/api/admin/mixcloud/token");
1644
- return response.accessToken;
1645
- } catch {
1646
- throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
1647
- }
1648
- }
1649
- async function fetchCover(logId) {
1650
- for (const size of ["square", "og"]) {
1651
- const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
1652
- if (!response.ok) {
1653
- continue;
1654
- }
1655
- const blob = await response.blob();
1656
- if (blob.size <= PICTURE_MAX_BYTES) {
1657
- return blob;
1658
- }
1659
- }
1660
- return;
1661
- }
1662
- function parseUploadResult(body) {
1663
- try {
1664
- const data = JSON.parse(body);
1665
- return {
1666
- key: data.result?.key,
1667
- message: data.result?.message,
1668
- success: data.result?.success === true
1669
- };
1670
- } catch {
1671
- return { message: body.slice(0, 300), success: false };
1672
- }
1673
- }
1674
- function throwMixcloudError(status, body) {
1675
- if (body.includes("An invalid access token was provided")) {
1676
- throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
1677
- }
1678
- throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
1679
- }
1680
- var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
1681
- var init_mixtape_mixcloud = __esm(() => {
1682
- init_api();
1683
- init_output();
1684
- init_mixtapes();
1685
- PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1686
- });
1687
-
1688
- // src/commands/mixtapes.ts
1689
- var exports_mixtapes2 = {};
1690
- __export(exports_mixtapes2, {
1691
- mixtapesCommand: () => mixtapesCommand2,
1692
- mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
1693
- mixtapePublishCommand: () => mixtapePublishCommand2,
1694
- mixtapeMembersCommand: () => mixtapeMembersCommand2,
1695
- mixtapeListCommand: () => mixtapeListCommand2,
1696
- mixtapeGetCommand: () => mixtapeGetCommand2,
1697
- mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
1698
- mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
1699
- mixtapeCreateCommand: () => mixtapeCreateCommand2
1700
- });
1701
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1702
- async function mixtapesCommand2() {
1703
- const response = await publicApiGet("/api/mixtapes");
1704
- return response.mixtapes;
1705
- }
1706
- async function mixtapeCreateCommand2(options) {
1707
- return adminApiPost("/api/admin/mixtapes", buildBody2(options));
1708
- }
1709
- async function mixtapeUpdateCommand2(id, options) {
1710
- return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
1711
- }
1712
- async function mixtapeMembersCommand2(id, refs, options) {
1713
- const members = refs.map((ref) => ({ ref }));
1714
- if (options.from) {
1715
- members.push(...parseCueFile2(options.from));
1716
- }
1717
- return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
1718
- }
1719
- async function mixtapePublishCommand2(id) {
1518
+ async function mixtapePublishCommand(id) {
1720
1519
  return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
1721
1520
  }
1722
- async function mixtapeDeleteCommand2(id) {
1521
+ async function mixtapeDeleteCommand(id) {
1723
1522
  return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
1724
1523
  }
1725
- async function mixtapeListCommand2() {
1726
- const response = await adminApiGet("/api/admin/mixtapes");
1727
- return response.mixtapes;
1728
- }
1729
- async function mixtapeGetCommand2(idOrLogId) {
1730
- const mixtapes = await mixtapeListCommand2();
1731
- const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
1732
- if (!match) {
1733
- throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1734
- }
1735
- return match;
1736
- }
1737
- async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
1738
- const mixtape = await mixtapeGetCommand2(idOrLogId);
1524
+ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
1525
+ const mixtape = await mixtapeGetCommand(idOrLogId);
1739
1526
  if (!mixtape.id) {
1740
1527
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1741
1528
  }
@@ -1756,15 +1543,15 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
1756
1543
  let logId = mixtape.logId;
1757
1544
  if (!mixtape.durationMs) {
1758
1545
  const source = options.audio ?? options.video;
1759
- const durationMs = source ? await probeDurationMs2(source) : undefined;
1546
+ const durationMs = source ? await probeDurationMs(source) : undefined;
1760
1547
  if (durationMs) {
1761
- await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
1548
+ await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
1762
1549
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1763
1550
  }
1764
1551
  }
1765
1552
  if (mixtape.status === "draft") {
1766
1553
  onProgress("Minting the coordinate…");
1767
- const published = await mixtapePublishCommand2(mixtapeId);
1554
+ const published = await mixtapePublishCommand(mixtapeId);
1768
1555
  logId = published.mixtape.logId;
1769
1556
  onProgress(`Minted ${logId}.`);
1770
1557
  } else if (mixtape.status === "published") {
@@ -1808,7 +1595,7 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
1808
1595
  }
1809
1596
  return { logId, mixtapeId, results };
1810
1597
  }
1811
- async function probeDurationMs2(filePath) {
1598
+ async function probeDurationMs(filePath) {
1812
1599
  try {
1813
1600
  const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
1814
1601
  const out = await new Response(proc.stdout).text();
@@ -1819,7 +1606,7 @@ async function probeDurationMs2(filePath) {
1819
1606
  return;
1820
1607
  }
1821
1608
  }
1822
- function buildBody2(options) {
1609
+ function buildBody(options) {
1823
1610
  const body = {};
1824
1611
  if (options.note !== undefined) {
1825
1612
  body.note = options.note;
@@ -1839,11 +1626,11 @@ function buildBody2(options) {
1839
1626
  }
1840
1627
  return body;
1841
1628
  }
1842
- function parseCueFile2(filePath) {
1843
- if (!existsSync3(filePath)) {
1629
+ function parseCueFile(filePath) {
1630
+ if (!existsSync2(filePath)) {
1844
1631
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1845
1632
  }
1846
- const text = readFileSync3(filePath, "utf-8");
1633
+ const text = readFileSync2(filePath, "utf-8");
1847
1634
  const trimmed = text.trim();
1848
1635
  if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
1849
1636
  try {
@@ -1872,9 +1659,9 @@ function parseCueFile2(filePath) {
1872
1659
  throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
1873
1660
  }
1874
1661
  }
1875
- return parseCueSheet2(text);
1662
+ return parseCueSheet(text);
1876
1663
  }
1877
- function parseCueSheet2(text) {
1664
+ function parseCueSheet(text) {
1878
1665
  const entries = [];
1879
1666
  for (const line of text.split(/\r?\n/)) {
1880
1667
  const trimmed = line.trim();
@@ -1898,9 +1685,10 @@ function parseCueSheet2(text) {
1898
1685
  }
1899
1686
  return entries;
1900
1687
  }
1901
- var init_mixtapes2 = __esm(() => {
1688
+ var init_mixtapes = __esm(() => {
1902
1689
  init_util();
1903
1690
  init_api();
1691
+ init_mixtape_api();
1904
1692
  init_output();
1905
1693
  });
1906
1694
 
@@ -2593,7 +2381,7 @@ function parseVersion2(version) {
2593
2381
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2594
2382
  var init_version2 = __esm(() => {
2595
2383
  init_output();
2596
- currentVersion2 = "0.73.0".trim() ? "0.73.0".trim() : "0.1.0";
2384
+ currentVersion2 = "0.74.0".trim() ? "0.74.0".trim() : "0.1.0";
2597
2385
  });
2598
2386
 
2599
2387
  // ../../packages/registry/src/index.ts
@@ -3975,6 +3763,214 @@ var init_mixtape_youtube2 = __esm(() => {
3975
3763
  init_output();
3976
3764
  });
3977
3765
 
3766
+ // src/commands/mixtapes.ts
3767
+ var exports_mixtapes2 = {};
3768
+ __export(exports_mixtapes2, {
3769
+ mixtapesCommand: () => mixtapesCommand2,
3770
+ mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
3771
+ mixtapePublishCommand: () => mixtapePublishCommand2,
3772
+ mixtapeMembersCommand: () => mixtapeMembersCommand2,
3773
+ mixtapeListCommand: () => mixtapeListCommand,
3774
+ mixtapeGetCommand: () => mixtapeGetCommand,
3775
+ mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
3776
+ mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
3777
+ mixtapeCreateCommand: () => mixtapeCreateCommand2
3778
+ });
3779
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
3780
+ async function mixtapesCommand2() {
3781
+ const response = await publicApiGet("/api/mixtapes");
3782
+ return response.mixtapes;
3783
+ }
3784
+ async function mixtapeCreateCommand2(options) {
3785
+ return adminApiPost("/api/admin/mixtapes", buildBody2(options));
3786
+ }
3787
+ async function mixtapeUpdateCommand2(id, options) {
3788
+ return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
3789
+ }
3790
+ async function mixtapeMembersCommand2(id, refs, options) {
3791
+ const members = refs.map((ref) => ({ ref }));
3792
+ if (options.from) {
3793
+ members.push(...parseCueFile2(options.from));
3794
+ }
3795
+ return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
3796
+ }
3797
+ async function mixtapePublishCommand2(id) {
3798
+ return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
3799
+ }
3800
+ async function mixtapeDeleteCommand2(id) {
3801
+ return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
3802
+ }
3803
+ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
3804
+ const mixtape = await mixtapeGetCommand(idOrLogId);
3805
+ if (!mixtape.id) {
3806
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
3807
+ }
3808
+ const both = !options.youtube && !options.mixcloud && !options.setVideo;
3809
+ const doYoutube = both || Boolean(options.youtube);
3810
+ const doMixcloud = both || Boolean(options.mixcloud);
3811
+ const doSetVideo = Boolean(options.setVideo);
3812
+ if (doYoutube && !options.video) {
3813
+ throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
3814
+ }
3815
+ if (doMixcloud && !options.audio) {
3816
+ throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
3817
+ }
3818
+ if (doSetVideo && !options.video) {
3819
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
3820
+ }
3821
+ const mixtapeId = mixtape.id;
3822
+ let logId = mixtape.logId;
3823
+ if (!mixtape.durationMs) {
3824
+ const source = options.audio ?? options.video;
3825
+ const durationMs = source ? await probeDurationMs2(source) : undefined;
3826
+ if (durationMs) {
3827
+ await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
3828
+ onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
3829
+ }
3830
+ }
3831
+ if (mixtape.status === "draft") {
3832
+ onProgress("Minting the coordinate…");
3833
+ const published = await mixtapePublishCommand2(mixtapeId);
3834
+ logId = published.mixtape.logId;
3835
+ onProgress(`Minted ${logId}.`);
3836
+ } else if (mixtape.status === "published") {
3837
+ onProgress(`Already published (${logId}); re-distributing.`);
3838
+ } else {
3839
+ onProgress(`Resuming distribution for ${logId}.`);
3840
+ }
3841
+ if (!logId) {
3842
+ throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
3843
+ }
3844
+ const results = [];
3845
+ if (doYoutube) {
3846
+ if (!options.video) {
3847
+ throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
3848
+ }
3849
+ onProgress("YouTube: uploading video…");
3850
+ const { distributeYoutube: distributeYoutube3 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
3851
+ const result = await distributeYoutube3(mixtapeId, options.video, onProgress);
3852
+ results.push({ platform: "youtube", url: result.url });
3853
+ onProgress(`YouTube: ${result.url}`);
3854
+ }
3855
+ if (doMixcloud) {
3856
+ if (!options.audio) {
3857
+ throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
3858
+ }
3859
+ onProgress("Mixcloud: uploading audio…");
3860
+ const { distributeMixcloud: distributeMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
3861
+ const result = await distributeMixcloud2(mixtapeId, options.audio, onProgress, options.unlisted);
3862
+ results.push({ platform: "mixcloud", url: result.url });
3863
+ onProgress(`Mixcloud: ${result.url}`);
3864
+ }
3865
+ if (doSetVideo) {
3866
+ if (!options.video) {
3867
+ throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
3868
+ }
3869
+ onProgress("Set video: staging the 1080p rendition…");
3870
+ const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
3871
+ const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
3872
+ results.push({ platform: "set-video", url: result.url });
3873
+ onProgress(`Set video: ${result.url}`);
3874
+ }
3875
+ return { logId, mixtapeId, results };
3876
+ }
3877
+ async function probeDurationMs2(filePath) {
3878
+ try {
3879
+ const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
3880
+ const out = await new Response(proc.stdout).text();
3881
+ await proc.exited;
3882
+ const seconds = Number.parseFloat(out.trim());
3883
+ return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
3884
+ } catch {
3885
+ return;
3886
+ }
3887
+ }
3888
+ function buildBody2(options) {
3889
+ const body = {};
3890
+ if (options.note !== undefined) {
3891
+ body.note = options.note;
3892
+ }
3893
+ if (options.recordedAt !== undefined) {
3894
+ body.recordedAt = options.recordedAt;
3895
+ }
3896
+ if (options.soundcloudUrl !== undefined) {
3897
+ body.soundcloudUrl = options.soundcloudUrl;
3898
+ }
3899
+ if (options.durationMs !== undefined) {
3900
+ const parsed = parseDuration(options.durationMs);
3901
+ if (parsed === null) {
3902
+ throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
3903
+ }
3904
+ body.durationMs = parsed;
3905
+ }
3906
+ return body;
3907
+ }
3908
+ function parseCueFile2(filePath) {
3909
+ if (!existsSync3(filePath)) {
3910
+ throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
3911
+ }
3912
+ const text = readFileSync3(filePath, "utf-8");
3913
+ const trimmed = text.trim();
3914
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
3915
+ try {
3916
+ const parsed = JSON.parse(trimmed);
3917
+ if (!Array.isArray(parsed)) {
3918
+ throw new Error("not an array");
3919
+ }
3920
+ return parsed.map((entry, index) => {
3921
+ if (typeof entry === "string") {
3922
+ return { ref: entry.trim() };
3923
+ }
3924
+ const obj = entry;
3925
+ if (typeof obj?.ref !== "string") {
3926
+ throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
3927
+ }
3928
+ const cue = { ref: obj.ref.trim() };
3929
+ if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
3930
+ cue.startMs = obj.startMs;
3931
+ }
3932
+ return cue;
3933
+ });
3934
+ } catch (error) {
3935
+ if (error instanceof CliError2) {
3936
+ throw error;
3937
+ }
3938
+ throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
3939
+ }
3940
+ }
3941
+ return parseCueSheet2(text);
3942
+ }
3943
+ function parseCueSheet2(text) {
3944
+ const entries = [];
3945
+ for (const line of text.split(/\r?\n/)) {
3946
+ const trimmed = line.trim();
3947
+ if (!trimmed || trimmed.startsWith("#")) {
3948
+ continue;
3949
+ }
3950
+ const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
3951
+ if (match) {
3952
+ const [, time, ref] = match;
3953
+ if (time === undefined || ref === undefined) {
3954
+ continue;
3955
+ }
3956
+ const startMs = parseDuration(time);
3957
+ if (startMs === null) {
3958
+ continue;
3959
+ }
3960
+ entries.push({ ref: ref.trim(), startMs });
3961
+ } else {
3962
+ entries.push({ ref: trimmed });
3963
+ }
3964
+ }
3965
+ return entries;
3966
+ }
3967
+ var init_mixtapes2 = __esm(() => {
3968
+ init_util();
3969
+ init_api();
3970
+ init_mixtape_api();
3971
+ init_output();
3972
+ });
3973
+
3978
3974
  // src/commands/clips.ts
3979
3975
  var exports_clips = {};
3980
3976
  __export(exports_clips, {
@@ -4069,8 +4065,8 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4069
4065
  if (!clip) {
4070
4066
  throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
4071
4067
  }
4072
- const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
4073
- const mixtape = await mixtapeGetCommand3(clip.mixtapeId);
4068
+ const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
4069
+ const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
4074
4070
  if (!mixtape.logId) {
4075
4071
  throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
4076
4072
  }
@@ -4511,7 +4507,7 @@ var MIXCLOUD_API2 = "https://api.mixcloud.com", DESCRIPTION_MAX2 = 1000, PICTURE
4511
4507
  var init_mixtape_mixcloud2 = __esm(() => {
4512
4508
  init_api();
4513
4509
  init_output();
4514
- init_mixtapes();
4510
+ init_mixtape_api();
4515
4511
  PICTURE_MAX_BYTES2 = 10 * 1024 * 1024;
4516
4512
  });
4517
4513
 
@@ -6992,7 +6988,7 @@ function addListenCommands(program2) {
6992
6988
  await runRecent(options, recentCommand3);
6993
6989
  });
6994
6990
  program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
6995
- const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
6991
+ const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
6996
6992
  await runMixtapes(options, mixtapesCommand3);
6997
6993
  });
6998
6994
  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) => {
@@ -7046,7 +7042,7 @@ function addMetaCommands(program2) {
7046
7042
  });
7047
7043
  }
7048
7044
  function addTrackCommands(program2) {
7049
- const tracks = configureCommand(program2.command("tracks", { hidden: true }).alias("track").description("Public track lookups"));
7045
+ const tracks = configureCommand(program2.command("tracks", { hidden: true }).description("Public track lookups"));
7050
7046
  tracks.command("get").description("Look up one finding by id or Log ID").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7051
7047
  const { trackGetCommand: trackGetCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
7052
7048
  await runTrackGet(idOrLogId, options, trackGetCommand2);
@@ -7060,7 +7056,7 @@ function addAdminCommands(program2) {
7060
7056
  admin.command("help", { hidden: true }).description("display help for command").action(() => {
7061
7057
  admin.outputHelp();
7062
7058
  });
7063
- const adminTracks = configureCommand(admin.command("tracks").alias("track").description("Track admin commands"));
7059
+ const adminTracks = configureCommand(admin.command("tracks").description("Track admin commands"));
7064
7060
  adminTracks.action(() => {
7065
7061
  adminTracks.outputHelp();
7066
7062
  });
@@ -7068,15 +7064,7 @@ function addAdminCommands(program2) {
7068
7064
  const { addCommand: addCommand3 } = await Promise.resolve().then(() => (init_add(), exports_add));
7069
7065
  await runAdd(spotifyUrl, options, addCommand3);
7070
7066
  });
7071
- admin.command("add", { hidden: true }).description("Publish a Spotify track (alias of `admin tracks publish`)").argument("[spotifyUrl]").option("--note <text>", "Operator note").option("--dry-run", "Preview without publishing", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (spotifyUrl, options) => {
7072
- const { addCommand: addCommand3 } = await Promise.resolve().then(() => (init_add(), exports_add));
7073
- await runAdd(spotifyUrl, options, addCommand3);
7074
- });
7075
- adminTracks.command("queue").description("Findings awaiting a video, oldest first (the next to film is first)").option("--limit <limit>", "Number of findings to show", "10").option("--has-context", "No-op: the render queue is always context-gated").option("--has-observation", "Only findings that already have a spoken observation").option("--json", "Print JSON", false).action(async (options) => {
7076
- const { queueCommand: queueCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
7077
- await runAdminQueue(options, queueCommand2);
7078
- });
7079
- admin.command("queue", { hidden: true }).description("Render queue (alias of `admin tracks queue`)").option("--limit <limit>", "Number of findings to show", "10").option("--has-context", "No-op: the render queue is always context-gated").option("--has-observation", "Only findings that already have a spoken observation").option("--json", "Print JSON", false).action(async (options) => {
7067
+ adminTracks.command("queue").description("Findings awaiting a video, oldest first (the next to film is first)").option("--limit <limit>", "Number of findings to show", "10").option("--has-observation", "Only findings that already have a spoken observation").option("--json", "Print JSON", false).action(async (options) => {
7080
7068
  const { queueCommand: queueCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
7081
7069
  await runAdminQueue(options, queueCommand2);
7082
7070
  });
@@ -7093,10 +7081,6 @@ function addAdminCommands(program2) {
7093
7081
  const { vehiclesCommand: vehiclesCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
7094
7082
  await runAdminVehicles(options, vehiclesCommand2);
7095
7083
  });
7096
- admin.command("vehicles", { hidden: true }).description("Video vehicles (alias of `admin tracks vehicles`)").option("--limit <limit>", "Number of vehicles to show", "10").option("--json", "Print JSON", false).action(async (options) => {
7097
- const { vehiclesCommand: vehiclesCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
7098
- await runAdminVehicles(options, vehiclesCommand2);
7099
- });
7100
7084
  const adminTrack = adminTracks;
7101
7085
  adminTrack.command("update").description("Certify a track into the archive").argument("[trackId]").option("--bpm <number>", "Track BPM").option("--features <json>", "Audio feature JSON").option("--json", "Print JSON", false).option("--key <key>", "Musical key").option("--note <text>", "Operator note").option("--status <status>", "Enrichment status").option("--video-url <url>", "Rendered video URL").allowExcessArguments().action(async (trackId, options) => {
7102
7086
  const { trackUpdateCommand: trackUpdateCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
@@ -7163,35 +7147,35 @@ function addAdminCommands(program2) {
7163
7147
  adminMixtapes.outputHelp();
7164
7148
  });
7165
7149
  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) => {
7166
- const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7150
+ const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7167
7151
  await runMixtapeCreate(options, mixtapeCreateCommand3);
7168
7152
  });
7169
7153
  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) => {
7170
- const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7154
+ const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7171
7155
  await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
7172
7156
  });
7173
7157
  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) => {
7174
- const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7158
+ const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7175
7159
  await runMixtapeMembers(id, refs, options, mixtapeMembersCommand3);
7176
7160
  });
7177
7161
  adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
7178
- const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7162
+ const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7179
7163
  await runMixtapePublish(id, options, mixtapePublishCommand3);
7180
7164
  });
7181
7165
  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) => {
7182
- const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7166
+ const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7183
7167
  await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
7184
7168
  });
7185
7169
  adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
7186
- const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7187
- await runMixtapeList(options, mixtapeListCommand3);
7170
+ const { mixtapeListCommand: mixtapeListCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7171
+ await runMixtapeList(options, mixtapeListCommand2);
7188
7172
  });
7189
7173
  adminMixtapes.command("get").description("Show one mixtape by id or log id").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7190
- const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7191
- await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
7174
+ const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7175
+ await runMixtapeGet(idOrLogId, options, mixtapeGetCommand2);
7192
7176
  });
7193
7177
  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) => {
7194
- const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
7178
+ const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7195
7179
  await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
7196
7180
  });
7197
7181
  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) => {
@@ -7277,7 +7261,7 @@ function addAdminCommands(program2) {
7277
7261
  const { authLastfmCommand: authLastfmCommand2 } = await Promise.resolve().then(() => (init_auth_lastfm(), exports_auth_lastfm));
7278
7262
  await authLastfmCommand2(options);
7279
7263
  });
7280
- const backfill = configureCommand(admin.command("backfills").alias("backfill").description("Backfill operator-only archives"));
7264
+ const backfill = configureCommand(admin.command("backfills").description("Backfill operator-only archives"));
7281
7265
  backfill.action(() => {
7282
7266
  backfill.outputHelp();
7283
7267
  });
@@ -7771,8 +7755,8 @@ async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
7771
7755
  }
7772
7756
  console.log(`Discarded draft ${id}.`);
7773
7757
  }
7774
- async function runMixtapeList(options, mixtapeListCommand3) {
7775
- const mixtapes = await mixtapeListCommand3();
7758
+ async function runMixtapeList(options, mixtapeListCommand2) {
7759
+ const mixtapes = await mixtapeListCommand2();
7776
7760
  if (options.json) {
7777
7761
  printJson({ mixtapes, ok: true });
7778
7762
  return;
@@ -7813,11 +7797,11 @@ async function runClipsCut(clipId, options, clipCutCommand2) {
7813
7797
  }
7814
7798
  console.log(`Cut ${result.clipId} \u2192 ${result.url} (${(result.sizeBytes / 1e6).toFixed(1)} MB).`);
7815
7799
  }
7816
- async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand3) {
7800
+ async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand2) {
7817
7801
  if (!idOrLogId) {
7818
7802
  throw new Error("Missing mixtape id or log id. Usage: fluncle admin mixtapes get <id|logId>");
7819
7803
  }
7820
- const mixtape = await mixtapeGetCommand3(idOrLogId);
7804
+ const mixtape = await mixtapeGetCommand2(idOrLogId);
7821
7805
  if (options.json) {
7822
7806
  printJson(mixtape);
7823
7807
  return;
@@ -7984,7 +7968,6 @@ function parseListLimit(value) {
7984
7968
  async function runAdminQueue(options, queueCommand2) {
7985
7969
  const limit = parseListLimit(options.limit);
7986
7970
  const tracks = await queueCommand2(limit, {
7987
- hasContext: options.hasContext ? true : undefined,
7988
7971
  hasObservation: options.hasObservation ? true : undefined
7989
7972
  });
7990
7973
  if (options.json) {
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.73.0"
34
+ "version": "0.74.0"
35
35
  }