fluncle 0.72.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.
- package/bin/fluncle.mjs +675 -719
- package/package.json +1 -1
package/bin/fluncle.mjs
CHANGED
|
@@ -380,6 +380,107 @@ function formatError(error) {
|
|
|
380
380
|
}
|
|
381
381
|
return String(error);
|
|
382
382
|
}
|
|
383
|
+
function parseDuration(input) {
|
|
384
|
+
const trimmed = input.trim();
|
|
385
|
+
if (!trimmed) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
if (trimmed.includes(":")) {
|
|
389
|
+
const parts = trimmed.split(":");
|
|
390
|
+
if (parts.length !== 2 && parts.length !== 3) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const nums = parts.map((part) => Number(part));
|
|
394
|
+
if (nums.some((n) => !Number.isFinite(n) || n < 0)) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
if (parts.length === 3) {
|
|
398
|
+
const [hours, minutes2, seconds2] = nums;
|
|
399
|
+
if (hours === undefined || minutes2 === undefined || seconds2 === undefined) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
if (minutes2 >= 60 || seconds2 >= 60) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return Math.round((hours * 3600 + minutes2 * 60 + seconds2) * 1000);
|
|
406
|
+
}
|
|
407
|
+
const [minutes, seconds] = nums;
|
|
408
|
+
if (minutes === undefined || seconds === undefined) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (seconds >= 60) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
return Math.round((minutes * 60 + seconds) * 1000);
|
|
415
|
+
}
|
|
416
|
+
const value = Number(trimmed);
|
|
417
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
418
|
+
}
|
|
419
|
+
function isRemix(title) {
|
|
420
|
+
return REMIX_MARKER.test(title);
|
|
421
|
+
}
|
|
422
|
+
function stripVersionSuffix(title) {
|
|
423
|
+
const parts = title.split(/\s+-\s+/);
|
|
424
|
+
if (parts.length > 1 && VERSION_MARKER.test(parts[parts.length - 1] ?? "")) {
|
|
425
|
+
return parts.slice(0, -1).join(" - ").trim();
|
|
426
|
+
}
|
|
427
|
+
return title.trim();
|
|
428
|
+
}
|
|
429
|
+
function tokenize(value) {
|
|
430
|
+
return value.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean);
|
|
431
|
+
}
|
|
432
|
+
function versionTokens(title) {
|
|
433
|
+
const parts = title.split(/\s+-\s+/);
|
|
434
|
+
const tail = parts.length > 1 ? parts[parts.length - 1] ?? "" : "";
|
|
435
|
+
if (parts.length > 1 && VERSION_MARKER.test(tail)) {
|
|
436
|
+
return new Set(tokenize(tail));
|
|
437
|
+
}
|
|
438
|
+
const bracketed = /[([]([^)\]]*?)[)\]]/.exec(title);
|
|
439
|
+
if (bracketed?.[1] && VERSION_MARKER.test(bracketed[1])) {
|
|
440
|
+
return new Set(tokenize(bracketed[1]));
|
|
441
|
+
}
|
|
442
|
+
return new Set;
|
|
443
|
+
}
|
|
444
|
+
function versionMatches(findingTitle, candidateTitle) {
|
|
445
|
+
const findingIsRemix = isRemix(findingTitle);
|
|
446
|
+
const candidateIsRemix = isRemix(candidateTitle);
|
|
447
|
+
if (findingIsRemix) {
|
|
448
|
+
if (!candidateIsRemix) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
const want = [...versionTokens(findingTitle)].filter((t) => !VERSION_STOPWORDS.has(t));
|
|
452
|
+
if (want.length === 0) {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
const have = versionTokens(candidateTitle);
|
|
456
|
+
for (const token of want) {
|
|
457
|
+
if (!have.has(token)) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
return !candidateIsRemix;
|
|
464
|
+
}
|
|
465
|
+
function baseTitleMatches(findingTitle, candidateTitle) {
|
|
466
|
+
const want = new Set(tokenize(stripVersionSuffix(findingTitle)));
|
|
467
|
+
const have = new Set(tokenize(stripVersionSuffix(candidateTitle)));
|
|
468
|
+
if (want.size === 0) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
for (const token of want) {
|
|
472
|
+
if (!have.has(token)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
var VERSION_MARKER, REMIX_MARKER, VERSION_STOPWORDS;
|
|
479
|
+
var init_util = __esm(() => {
|
|
480
|
+
VERSION_MARKER = /\b(mix|edit|version|remix|dub|vip|bootleg|rework|re-?edit|flip|refix|remaster(?:ed)?|instrumental)\b/i;
|
|
481
|
+
REMIX_MARKER = /\b(remix|bootleg|vip|rework|re-?edit|flip|refix)\b/i;
|
|
482
|
+
VERSION_STOPWORDS = new Set(["mix", "the", "and", "feat", "ft", "edit", "version", "remix"]);
|
|
483
|
+
});
|
|
383
484
|
|
|
384
485
|
// src/output.ts
|
|
385
486
|
function isJsonFailure(value) {
|
|
@@ -423,7 +524,7 @@ function parseVersion(version) {
|
|
|
423
524
|
var currentVersion;
|
|
424
525
|
var init_version = __esm(() => {
|
|
425
526
|
init_output();
|
|
426
|
-
currentVersion = "0.
|
|
527
|
+
currentVersion = "0.74.0".trim() ? "0.74.0".trim() : "0.1.0";
|
|
427
528
|
});
|
|
428
529
|
|
|
429
530
|
// src/update-notifier.ts
|
|
@@ -887,6 +988,24 @@ var init_recent = __esm(() => {
|
|
|
887
988
|
init_api();
|
|
888
989
|
});
|
|
889
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
|
+
|
|
890
1009
|
// src/commands/mixtape-youtube.ts
|
|
891
1010
|
var exports_mixtape_youtube = {};
|
|
892
1011
|
__export(exports_mixtape_youtube, {
|
|
@@ -1047,70 +1166,217 @@ var init_mixtape_youtube = __esm(() => {
|
|
|
1047
1166
|
init_output();
|
|
1048
1167
|
});
|
|
1049
1168
|
|
|
1050
|
-
// src/commands/mixtape-
|
|
1051
|
-
var
|
|
1052
|
-
__export(
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
|
|
1058
|
-
MIN_PART_SIZE: () => MIN_PART_SIZE,
|
|
1059
|
-
MAX_PARTS: () => MAX_PARTS,
|
|
1060
|
-
DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
|
|
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
|
|
1061
1176
|
});
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
|
|
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");
|
|
1069
1183
|
}
|
|
1070
|
-
|
|
1071
|
-
if (
|
|
1072
|
-
|
|
1184
|
+
const audio = Bun.file(audioPath);
|
|
1185
|
+
if (!await audio.exists()) {
|
|
1186
|
+
throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
|
|
1073
1187
|
}
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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");
|
|
1082
1196
|
}
|
|
1083
|
-
|
|
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 };
|
|
1084
1235
|
}
|
|
1085
|
-
function
|
|
1086
|
-
|
|
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.`);
|
|
1087
1243
|
}
|
|
1088
|
-
function
|
|
1089
|
-
const
|
|
1090
|
-
const body =
|
|
1091
|
-
|
|
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;
|
|
1092
1258
|
}
|
|
1093
|
-
function
|
|
1094
|
-
return
|
|
1095
|
-
"
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
"
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
+
|
|
1316
|
+
// src/commands/mixtape-set-video.ts
|
|
1317
|
+
var exports_mixtape_set_video = {};
|
|
1318
|
+
__export(exports_mixtape_set_video, {
|
|
1319
|
+
stageSetVideo: () => stageSetVideo,
|
|
1320
|
+
renditionFfmpegArgs: () => renditionFfmpegArgs,
|
|
1321
|
+
planMultipart: () => planMultipart,
|
|
1322
|
+
buildCompleteXml: () => buildCompleteXml,
|
|
1323
|
+
SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
|
|
1324
|
+
MIN_PART_SIZE: () => MIN_PART_SIZE,
|
|
1325
|
+
MAX_PARTS: () => MAX_PARTS,
|
|
1326
|
+
DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
|
|
1327
|
+
});
|
|
1328
|
+
import { randomUUID } from "node:crypto";
|
|
1329
|
+
import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
|
|
1330
|
+
import { tmpdir } from "node:os";
|
|
1331
|
+
import { join as join4 } from "node:path";
|
|
1332
|
+
function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
|
|
1333
|
+
if (!Number.isInteger(contentLength) || contentLength <= 0) {
|
|
1334
|
+
throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
|
|
1335
|
+
}
|
|
1336
|
+
let effective = Math.max(partSize, MIN_PART_SIZE);
|
|
1337
|
+
if (Math.ceil(contentLength / effective) > MAX_PARTS) {
|
|
1338
|
+
effective = Math.ceil(contentLength / MAX_PARTS);
|
|
1339
|
+
}
|
|
1340
|
+
const parts = [];
|
|
1341
|
+
let start = 0;
|
|
1342
|
+
let partNumber = 1;
|
|
1343
|
+
while (start < contentLength) {
|
|
1344
|
+
const end = Math.min(start + effective, contentLength);
|
|
1345
|
+
parts.push({ end, partNumber, size: end - start, start });
|
|
1346
|
+
start = end;
|
|
1347
|
+
partNumber += 1;
|
|
1348
|
+
}
|
|
1349
|
+
return { partCount: parts.length, partSize: effective, parts };
|
|
1350
|
+
}
|
|
1351
|
+
function escapeXml(value) {
|
|
1352
|
+
return value.replace(/[<>&'"]/g, (char) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[char] ?? char);
|
|
1353
|
+
}
|
|
1354
|
+
function buildCompleteXml(parts) {
|
|
1355
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
1356
|
+
const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
|
|
1357
|
+
return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
|
|
1358
|
+
}
|
|
1359
|
+
function renditionFfmpegArgs(inputPath, outputPath) {
|
|
1360
|
+
return [
|
|
1361
|
+
"-y",
|
|
1362
|
+
"-i",
|
|
1363
|
+
inputPath,
|
|
1364
|
+
"-vf",
|
|
1365
|
+
`scale=-2:${SET_VIDEO_RENDITION.height}`,
|
|
1366
|
+
"-c:v",
|
|
1367
|
+
"libx264",
|
|
1368
|
+
"-preset",
|
|
1369
|
+
"medium",
|
|
1370
|
+
"-crf",
|
|
1371
|
+
String(SET_VIDEO_RENDITION.crf),
|
|
1372
|
+
"-force_key_frames",
|
|
1373
|
+
`expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
|
|
1374
|
+
"-pix_fmt",
|
|
1375
|
+
"yuv420p",
|
|
1376
|
+
"-c:a",
|
|
1377
|
+
"aac",
|
|
1378
|
+
"-b:a",
|
|
1379
|
+
SET_VIDEO_RENDITION.audioBitrate,
|
|
1114
1380
|
"-movflags",
|
|
1115
1381
|
"+faststart",
|
|
1116
1382
|
outputPath
|
|
@@ -1159,517 +1425,104 @@ async function putPart(url, path2, part) {
|
|
|
1159
1425
|
method: "PUT"
|
|
1160
1426
|
});
|
|
1161
1427
|
if (!response.ok) {
|
|
1162
|
-
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1163
|
-
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
1164
|
-
}
|
|
1165
|
-
const etag = response.headers.get("etag");
|
|
1166
|
-
if (!etag) {
|
|
1167
|
-
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
1168
|
-
}
|
|
1169
|
-
return etag;
|
|
1170
|
-
}
|
|
1171
|
-
async function completeUpload(url, parts) {
|
|
1172
|
-
const response = await fetch(url, {
|
|
1173
|
-
body: buildCompleteXml(parts),
|
|
1174
|
-
headers: { "content-type": "application/xml" },
|
|
1175
|
-
method: "POST"
|
|
1176
|
-
});
|
|
1177
|
-
const text = await response.text().catch(() => "");
|
|
1178
|
-
if (!response.ok || text.includes("<Error>")) {
|
|
1179
|
-
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
async function abortUpload(url) {
|
|
1183
|
-
await fetch(url, { method: "DELETE" });
|
|
1184
|
-
}
|
|
1185
|
-
async function assertFfmpeg() {
|
|
1186
|
-
try {
|
|
1187
|
-
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
1188
|
-
await proc.exited;
|
|
1189
|
-
if (proc.exitCode !== 0) {
|
|
1190
|
-
throw new Error("ffmpeg -version exited non-zero");
|
|
1191
|
-
}
|
|
1192
|
-
} catch {
|
|
1193
|
-
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
async function deriveRendition(inputPath, outputPath) {
|
|
1197
|
-
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
1198
|
-
stderr: "pipe",
|
|
1199
|
-
stdout: "ignore"
|
|
1200
|
-
});
|
|
1201
|
-
await proc.exited;
|
|
1202
|
-
if (proc.exitCode !== 0) {
|
|
1203
|
-
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
1204
|
-
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
1208
|
-
var init_mixtape_set_video = __esm(() => {
|
|
1209
|
-
init_api();
|
|
1210
|
-
init_output();
|
|
1211
|
-
SET_VIDEO_RENDITION = {
|
|
1212
|
-
audioBitrate: "192k",
|
|
1213
|
-
crf: 20,
|
|
1214
|
-
gopSeconds: 2,
|
|
1215
|
-
height: 1080
|
|
1216
|
-
};
|
|
1217
|
-
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1218
|
-
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
// src/commands/mixtapes.ts
|
|
1222
|
-
var exports_mixtapes = {};
|
|
1223
|
-
__export(exports_mixtapes, {
|
|
1224
|
-
mixtapesCommand: () => mixtapesCommand,
|
|
1225
|
-
mixtapeUpdateCommand: () => mixtapeUpdateCommand,
|
|
1226
|
-
mixtapePublishCommand: () => mixtapePublishCommand,
|
|
1227
|
-
mixtapeMembersCommand: () => mixtapeMembersCommand,
|
|
1228
|
-
mixtapeListCommand: () => mixtapeListCommand,
|
|
1229
|
-
mixtapeGetCommand: () => mixtapeGetCommand,
|
|
1230
|
-
mixtapeDistributeCommand: () => mixtapeDistributeCommand,
|
|
1231
|
-
mixtapeDeleteCommand: () => mixtapeDeleteCommand,
|
|
1232
|
-
mixtapeCreateCommand: () => mixtapeCreateCommand
|
|
1233
|
-
});
|
|
1234
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1235
|
-
async function mixtapesCommand() {
|
|
1236
|
-
const response = await publicApiGet("/api/mixtapes");
|
|
1237
|
-
return response.mixtapes;
|
|
1238
|
-
}
|
|
1239
|
-
async function mixtapeCreateCommand(options) {
|
|
1240
|
-
return adminApiPost("/api/admin/mixtapes", buildBody(options));
|
|
1241
|
-
}
|
|
1242
|
-
async function mixtapeUpdateCommand(id, options) {
|
|
1243
|
-
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody(options));
|
|
1244
|
-
}
|
|
1245
|
-
async function mixtapeMembersCommand(id, refs, options) {
|
|
1246
|
-
const members = refs.map((ref) => ({ ref }));
|
|
1247
|
-
if (options.from) {
|
|
1248
|
-
members.push(...parseCueFile(options.from));
|
|
1249
|
-
}
|
|
1250
|
-
return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
|
|
1251
|
-
}
|
|
1252
|
-
async function mixtapePublishCommand(id) {
|
|
1253
|
-
return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
|
|
1254
|
-
}
|
|
1255
|
-
async function mixtapeDeleteCommand(id) {
|
|
1256
|
-
return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
|
|
1257
|
-
}
|
|
1258
|
-
async function mixtapeListCommand() {
|
|
1259
|
-
const response = await adminApiGet("/api/admin/mixtapes");
|
|
1260
|
-
return response.mixtapes;
|
|
1261
|
-
}
|
|
1262
|
-
async function mixtapeGetCommand(idOrLogId) {
|
|
1263
|
-
const mixtapes = await mixtapeListCommand();
|
|
1264
|
-
const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
|
|
1265
|
-
if (!match) {
|
|
1266
|
-
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1267
|
-
}
|
|
1268
|
-
return match;
|
|
1269
|
-
}
|
|
1270
|
-
async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
|
|
1271
|
-
const mixtape = await mixtapeGetCommand(idOrLogId);
|
|
1272
|
-
if (!mixtape.id) {
|
|
1273
|
-
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1274
|
-
}
|
|
1275
|
-
const both = !options.youtube && !options.mixcloud && !options.setVideo;
|
|
1276
|
-
const doYoutube = both || Boolean(options.youtube);
|
|
1277
|
-
const doMixcloud = both || Boolean(options.mixcloud);
|
|
1278
|
-
const doSetVideo = Boolean(options.setVideo);
|
|
1279
|
-
if (doYoutube && !options.video) {
|
|
1280
|
-
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1281
|
-
}
|
|
1282
|
-
if (doMixcloud && !options.audio) {
|
|
1283
|
-
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1284
|
-
}
|
|
1285
|
-
if (doSetVideo && !options.video) {
|
|
1286
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1287
|
-
}
|
|
1288
|
-
const mixtapeId = mixtape.id;
|
|
1289
|
-
let logId = mixtape.logId;
|
|
1290
|
-
if (!mixtape.durationMs) {
|
|
1291
|
-
const source = options.audio ?? options.video;
|
|
1292
|
-
const durationMs = source ? await probeDurationMs(source) : undefined;
|
|
1293
|
-
if (durationMs) {
|
|
1294
|
-
await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
|
|
1295
|
-
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
if (mixtape.status === "draft") {
|
|
1299
|
-
onProgress("Minting the coordinate…");
|
|
1300
|
-
const published = await mixtapePublishCommand(mixtapeId);
|
|
1301
|
-
logId = published.mixtape.logId;
|
|
1302
|
-
onProgress(`Minted ${logId}.`);
|
|
1303
|
-
} else if (mixtape.status === "published") {
|
|
1304
|
-
onProgress(`Already published (${logId}); re-distributing.`);
|
|
1305
|
-
} else {
|
|
1306
|
-
onProgress(`Resuming distribution for ${logId}.`);
|
|
1307
|
-
}
|
|
1308
|
-
if (!logId) {
|
|
1309
|
-
throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
|
|
1310
|
-
}
|
|
1311
|
-
const results = [];
|
|
1312
|
-
if (doYoutube) {
|
|
1313
|
-
if (!options.video) {
|
|
1314
|
-
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1315
|
-
}
|
|
1316
|
-
onProgress("YouTube: uploading video…");
|
|
1317
|
-
const { distributeYoutube: distributeYoutube2 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
|
|
1318
|
-
const result = await distributeYoutube2(mixtapeId, options.video, onProgress);
|
|
1319
|
-
results.push({ platform: "youtube", url: result.url });
|
|
1320
|
-
onProgress(`YouTube: ${result.url}`);
|
|
1321
|
-
}
|
|
1322
|
-
if (doMixcloud) {
|
|
1323
|
-
if (!options.audio) {
|
|
1324
|
-
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1325
|
-
}
|
|
1326
|
-
onProgress("Mixcloud: uploading audio…");
|
|
1327
|
-
const { distributeMixcloud } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
|
|
1328
|
-
const result = await distributeMixcloud(mixtapeId, options.audio, onProgress, options.unlisted);
|
|
1329
|
-
results.push({ platform: "mixcloud", url: result.url });
|
|
1330
|
-
onProgress(`Mixcloud: ${result.url}`);
|
|
1331
|
-
}
|
|
1332
|
-
if (doSetVideo) {
|
|
1333
|
-
if (!options.video) {
|
|
1334
|
-
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1335
|
-
}
|
|
1336
|
-
onProgress("Set video: staging the 1080p rendition…");
|
|
1337
|
-
const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
|
|
1338
|
-
const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
|
|
1339
|
-
results.push({ platform: "set-video", url: result.url });
|
|
1340
|
-
onProgress(`Set video: ${result.url}`);
|
|
1341
|
-
}
|
|
1342
|
-
return { logId, mixtapeId, results };
|
|
1343
|
-
}
|
|
1344
|
-
async function probeDurationMs(filePath) {
|
|
1345
|
-
try {
|
|
1346
|
-
const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
|
|
1347
|
-
const out = await new Response(proc.stdout).text();
|
|
1348
|
-
await proc.exited;
|
|
1349
|
-
const seconds = Number.parseFloat(out.trim());
|
|
1350
|
-
return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
|
|
1351
|
-
} catch {
|
|
1352
|
-
return;
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
function buildBody(options) {
|
|
1356
|
-
const body = {};
|
|
1357
|
-
if (options.note !== undefined) {
|
|
1358
|
-
body.note = options.note;
|
|
1359
|
-
}
|
|
1360
|
-
if (options.recordedAt !== undefined) {
|
|
1361
|
-
body.recordedAt = options.recordedAt;
|
|
1362
|
-
}
|
|
1363
|
-
if (options.soundcloudUrl !== undefined) {
|
|
1364
|
-
body.soundcloudUrl = options.soundcloudUrl;
|
|
1365
|
-
}
|
|
1366
|
-
if (options.durationMs !== undefined) {
|
|
1367
|
-
const parsed = parseDuration(options.durationMs);
|
|
1368
|
-
if (parsed === null) {
|
|
1369
|
-
throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
|
|
1370
|
-
}
|
|
1371
|
-
body.durationMs = parsed;
|
|
1372
|
-
}
|
|
1373
|
-
return body;
|
|
1374
|
-
}
|
|
1375
|
-
function parseCueFile(filePath) {
|
|
1376
|
-
if (!existsSync2(filePath)) {
|
|
1377
|
-
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1378
|
-
}
|
|
1379
|
-
const text = readFileSync2(filePath, "utf-8");
|
|
1380
|
-
const trimmed = text.trim();
|
|
1381
|
-
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
1382
|
-
try {
|
|
1383
|
-
const parsed = JSON.parse(trimmed);
|
|
1384
|
-
if (!Array.isArray(parsed)) {
|
|
1385
|
-
throw new Error("not an array");
|
|
1386
|
-
}
|
|
1387
|
-
return parsed.map((entry, index) => {
|
|
1388
|
-
if (typeof entry === "string") {
|
|
1389
|
-
return { ref: entry.trim() };
|
|
1390
|
-
}
|
|
1391
|
-
const obj = entry;
|
|
1392
|
-
if (typeof obj?.ref !== "string") {
|
|
1393
|
-
throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
|
|
1394
|
-
}
|
|
1395
|
-
const cue = { ref: obj.ref.trim() };
|
|
1396
|
-
if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
|
|
1397
|
-
cue.startMs = obj.startMs;
|
|
1398
|
-
}
|
|
1399
|
-
return cue;
|
|
1400
|
-
});
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
if (error instanceof CliError2) {
|
|
1403
|
-
throw error;
|
|
1404
|
-
}
|
|
1405
|
-
throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
return parseCueSheet(text);
|
|
1409
|
-
}
|
|
1410
|
-
function parseCueSheet(text) {
|
|
1411
|
-
const entries = [];
|
|
1412
|
-
for (const line of text.split(/\r?\n/)) {
|
|
1413
|
-
const trimmed = line.trim();
|
|
1414
|
-
if (!trimmed || trimmed.startsWith("#")) {
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
|
|
1418
|
-
if (match) {
|
|
1419
|
-
const [, time, ref] = match;
|
|
1420
|
-
if (time === undefined || ref === undefined) {
|
|
1421
|
-
continue;
|
|
1422
|
-
}
|
|
1423
|
-
const startMs = parseDuration(time);
|
|
1424
|
-
if (startMs === null) {
|
|
1425
|
-
continue;
|
|
1426
|
-
}
|
|
1427
|
-
entries.push({ ref: ref.trim(), startMs });
|
|
1428
|
-
} else {
|
|
1429
|
-
entries.push({ ref: trimmed });
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
return entries;
|
|
1433
|
-
}
|
|
1434
|
-
function parseDuration(input) {
|
|
1435
|
-
const trimmed = input.trim();
|
|
1436
|
-
if (!trimmed) {
|
|
1437
|
-
return null;
|
|
1438
|
-
}
|
|
1439
|
-
if (trimmed.includes(":")) {
|
|
1440
|
-
const parts = trimmed.split(":");
|
|
1441
|
-
if (parts.length !== 2 && parts.length !== 3) {
|
|
1442
|
-
return null;
|
|
1443
|
-
}
|
|
1444
|
-
const nums = parts.map((part) => Number(part));
|
|
1445
|
-
if (nums.some((n) => !Number.isFinite(n) || n < 0)) {
|
|
1446
|
-
return null;
|
|
1447
|
-
}
|
|
1448
|
-
if (parts.length === 3) {
|
|
1449
|
-
const [hours, minutes2, seconds2] = nums;
|
|
1450
|
-
if (hours === undefined || minutes2 === undefined || seconds2 === undefined) {
|
|
1451
|
-
return null;
|
|
1452
|
-
}
|
|
1453
|
-
if (minutes2 >= 60 || seconds2 >= 60) {
|
|
1454
|
-
return null;
|
|
1455
|
-
}
|
|
1456
|
-
return Math.round((hours * 3600 + minutes2 * 60 + seconds2) * 1000);
|
|
1457
|
-
}
|
|
1458
|
-
const [minutes, seconds] = nums;
|
|
1459
|
-
if (minutes === undefined || seconds === undefined) {
|
|
1460
|
-
return null;
|
|
1461
|
-
}
|
|
1462
|
-
if (seconds >= 60) {
|
|
1463
|
-
return null;
|
|
1464
|
-
}
|
|
1465
|
-
return Math.round((minutes * 60 + seconds) * 1000);
|
|
1466
|
-
}
|
|
1467
|
-
const value = Number(trimmed);
|
|
1468
|
-
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1469
|
-
}
|
|
1470
|
-
var init_mixtapes = __esm(() => {
|
|
1471
|
-
init_api();
|
|
1472
|
-
init_output();
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
// src/commands/mixtape-mixcloud.ts
|
|
1476
|
-
var exports_mixtape_mixcloud = {};
|
|
1477
|
-
__export(exports_mixtape_mixcloud, {
|
|
1478
|
-
mixtapeDescription: () => mixtapeDescription,
|
|
1479
|
-
mixcloudSections: () => mixcloudSections,
|
|
1480
|
-
distributeMixcloud: () => distributeMixcloud,
|
|
1481
|
-
authMixcloudCommand: () => authMixcloudCommand
|
|
1482
|
-
});
|
|
1483
|
-
async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
|
|
1484
|
-
const token = await fetchMixcloudToken();
|
|
1485
|
-
const mixtape = await mixtapeGetCommand(mixtapeId);
|
|
1486
|
-
const logId = mixtape.logId;
|
|
1487
|
-
if (!logId) {
|
|
1488
|
-
throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
|
|
1489
|
-
}
|
|
1490
|
-
const audio = Bun.file(audioPath);
|
|
1491
|
-
if (!await audio.exists()) {
|
|
1492
|
-
throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
|
|
1493
|
-
}
|
|
1494
|
-
const form = new FormData;
|
|
1495
|
-
form.append("mp3", audio);
|
|
1496
|
-
form.append("name", mixtape.title);
|
|
1497
|
-
form.append("description", mixtapeDescription(mixtape.note, logId));
|
|
1498
|
-
onProgress?.("Mixcloud: fetching the cover…");
|
|
1499
|
-
const picture = await fetchCover(logId);
|
|
1500
|
-
if (picture) {
|
|
1501
|
-
form.append("picture", picture, "cover.png");
|
|
1502
|
-
}
|
|
1503
|
-
for (const [index, tag] of mixtapeTags(mixtape).entries()) {
|
|
1504
|
-
form.append(`tags-${index}-tag`, tag);
|
|
1505
|
-
}
|
|
1506
|
-
const sections = mixcloudSections(mixtape.members);
|
|
1507
|
-
for (const [index, section] of sections.entries()) {
|
|
1508
|
-
form.append(`sections-${index}-artist`, section.artist);
|
|
1509
|
-
form.append(`sections-${index}-song`, section.song);
|
|
1510
|
-
form.append(`sections-${index}-start_time`, String(section.start_time));
|
|
1511
|
-
}
|
|
1512
|
-
if (unlisted) {
|
|
1513
|
-
form.append("unlisted", "1");
|
|
1514
|
-
onProgress?.("Mixcloud: uploading UNLISTED (private).");
|
|
1515
|
-
}
|
|
1516
|
-
const cuelessCount = mixtape.members.length - sections.length;
|
|
1517
|
-
if (cuelessCount > 0) {
|
|
1518
|
-
onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
|
|
1519
|
-
}
|
|
1520
|
-
onProgress?.("Mixcloud: uploading the master…");
|
|
1521
|
-
const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
|
|
1522
|
-
body: form,
|
|
1523
|
-
method: "POST"
|
|
1524
|
-
});
|
|
1525
|
-
const uploadText = await uploadResponse.text();
|
|
1526
|
-
if (!uploadResponse.ok) {
|
|
1527
|
-
throwMixcloudError(uploadResponse.status, uploadText);
|
|
1528
|
-
}
|
|
1529
|
-
const result = parseUploadResult(uploadText);
|
|
1530
|
-
if (!result.success || !result.key) {
|
|
1531
|
-
throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
|
|
1532
|
-
}
|
|
1533
|
-
const externalId = result.key;
|
|
1534
|
-
const url = `https://www.mixcloud.com${result.key}`;
|
|
1535
|
-
onProgress?.("Mixcloud: recording the link…");
|
|
1536
|
-
await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
|
|
1537
|
-
externalId,
|
|
1538
|
-
url
|
|
1539
|
-
});
|
|
1540
|
-
return { url };
|
|
1541
|
-
}
|
|
1542
|
-
async function authMixcloudCommand() {
|
|
1543
|
-
const response = await adminApiGet("/api/admin/mixcloud/auth/start");
|
|
1544
|
-
console.log(`Open this Mixcloud authorization URL:
|
|
1545
|
-
|
|
1546
|
-
${response.authUrl}
|
|
1547
|
-
|
|
1548
|
-
After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
|
|
1549
|
-
}
|
|
1550
|
-
function mixtapeDescription(note, logId) {
|
|
1551
|
-
const breadcrumb = `fluncle://${logId}`;
|
|
1552
|
-
const body = (note ?? "").trim();
|
|
1553
|
-
const full = body ? `${body}
|
|
1554
|
-
|
|
1555
|
-
${breadcrumb}` : breadcrumb;
|
|
1556
|
-
if (full.length <= DESCRIPTION_MAX) {
|
|
1557
|
-
return full;
|
|
1558
|
-
}
|
|
1559
|
-
const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
|
|
1560
|
-
const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
|
|
1561
|
-
return trimmedNote ? `${trimmedNote}
|
|
1562
|
-
|
|
1563
|
-
${breadcrumb}` : breadcrumb;
|
|
1564
|
-
}
|
|
1565
|
-
function mixcloudSections(members) {
|
|
1566
|
-
return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
|
|
1567
|
-
artist: member.artists.join(", "),
|
|
1568
|
-
song: member.title,
|
|
1569
|
-
start_time: Math.floor(member.startMs / 1000)
|
|
1570
|
-
}));
|
|
1571
|
-
}
|
|
1572
|
-
function mixtapeTags(_mixtape) {
|
|
1573
|
-
return ["Drum & Bass", "Fluncle"];
|
|
1574
|
-
}
|
|
1575
|
-
async function fetchMixcloudToken() {
|
|
1576
|
-
try {
|
|
1577
|
-
const response = await adminApiPost("/api/admin/mixcloud/token");
|
|
1578
|
-
return response.accessToken;
|
|
1579
|
-
} catch {
|
|
1580
|
-
throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
|
|
1428
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1429
|
+
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
1430
|
+
}
|
|
1431
|
+
const etag = response.headers.get("etag");
|
|
1432
|
+
if (!etag) {
|
|
1433
|
+
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
1581
1434
|
}
|
|
1435
|
+
return etag;
|
|
1582
1436
|
}
|
|
1583
|
-
async function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
}
|
|
1437
|
+
async function completeUpload(url, parts) {
|
|
1438
|
+
const response = await fetch(url, {
|
|
1439
|
+
body: buildCompleteXml(parts),
|
|
1440
|
+
headers: { "content-type": "application/xml" },
|
|
1441
|
+
method: "POST"
|
|
1442
|
+
});
|
|
1443
|
+
const text = await response.text().catch(() => "");
|
|
1444
|
+
if (!response.ok || text.includes("<Error>")) {
|
|
1445
|
+
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
1593
1446
|
}
|
|
1594
|
-
return;
|
|
1595
1447
|
}
|
|
1596
|
-
function
|
|
1448
|
+
async function abortUpload(url) {
|
|
1449
|
+
await fetch(url, { method: "DELETE" });
|
|
1450
|
+
}
|
|
1451
|
+
async function assertFfmpeg() {
|
|
1597
1452
|
try {
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
};
|
|
1453
|
+
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
1454
|
+
await proc.exited;
|
|
1455
|
+
if (proc.exitCode !== 0) {
|
|
1456
|
+
throw new Error("ffmpeg -version exited non-zero");
|
|
1457
|
+
}
|
|
1604
1458
|
} catch {
|
|
1605
|
-
|
|
1459
|
+
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
1606
1460
|
}
|
|
1607
1461
|
}
|
|
1608
|
-
function
|
|
1609
|
-
|
|
1610
|
-
|
|
1462
|
+
async function deriveRendition(inputPath, outputPath) {
|
|
1463
|
+
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
1464
|
+
stderr: "pipe",
|
|
1465
|
+
stdout: "ignore"
|
|
1466
|
+
});
|
|
1467
|
+
await proc.exited;
|
|
1468
|
+
if (proc.exitCode !== 0) {
|
|
1469
|
+
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
1470
|
+
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
1611
1471
|
}
|
|
1612
|
-
throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
|
|
1613
1472
|
}
|
|
1614
|
-
var
|
|
1615
|
-
var
|
|
1473
|
+
var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
1474
|
+
var init_mixtape_set_video = __esm(() => {
|
|
1616
1475
|
init_api();
|
|
1617
1476
|
init_output();
|
|
1618
|
-
|
|
1619
|
-
|
|
1477
|
+
SET_VIDEO_RENDITION = {
|
|
1478
|
+
audioBitrate: "192k",
|
|
1479
|
+
crf: 20,
|
|
1480
|
+
gopSeconds: 2,
|
|
1481
|
+
height: 1080
|
|
1482
|
+
};
|
|
1483
|
+
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1484
|
+
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1620
1485
|
});
|
|
1621
1486
|
|
|
1622
1487
|
// src/commands/mixtapes.ts
|
|
1623
|
-
var
|
|
1624
|
-
__export(
|
|
1625
|
-
mixtapesCommand: () =>
|
|
1626
|
-
mixtapeUpdateCommand: () =>
|
|
1627
|
-
mixtapePublishCommand: () =>
|
|
1628
|
-
mixtapeMembersCommand: () =>
|
|
1629
|
-
mixtapeListCommand: () =>
|
|
1630
|
-
mixtapeGetCommand: () =>
|
|
1631
|
-
mixtapeDistributeCommand: () =>
|
|
1632
|
-
mixtapeDeleteCommand: () =>
|
|
1633
|
-
mixtapeCreateCommand: () =>
|
|
1488
|
+
var exports_mixtapes = {};
|
|
1489
|
+
__export(exports_mixtapes, {
|
|
1490
|
+
mixtapesCommand: () => mixtapesCommand,
|
|
1491
|
+
mixtapeUpdateCommand: () => mixtapeUpdateCommand,
|
|
1492
|
+
mixtapePublishCommand: () => mixtapePublishCommand,
|
|
1493
|
+
mixtapeMembersCommand: () => mixtapeMembersCommand,
|
|
1494
|
+
mixtapeListCommand: () => mixtapeListCommand,
|
|
1495
|
+
mixtapeGetCommand: () => mixtapeGetCommand,
|
|
1496
|
+
mixtapeDistributeCommand: () => mixtapeDistributeCommand,
|
|
1497
|
+
mixtapeDeleteCommand: () => mixtapeDeleteCommand,
|
|
1498
|
+
mixtapeCreateCommand: () => mixtapeCreateCommand
|
|
1634
1499
|
});
|
|
1635
|
-
import { existsSync as
|
|
1636
|
-
async function
|
|
1500
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1501
|
+
async function mixtapesCommand() {
|
|
1637
1502
|
const response = await publicApiGet("/api/mixtapes");
|
|
1638
1503
|
return response.mixtapes;
|
|
1639
1504
|
}
|
|
1640
|
-
async function
|
|
1641
|
-
return adminApiPost("/api/admin/mixtapes",
|
|
1505
|
+
async function mixtapeCreateCommand(options) {
|
|
1506
|
+
return adminApiPost("/api/admin/mixtapes", buildBody(options));
|
|
1642
1507
|
}
|
|
1643
|
-
async function
|
|
1644
|
-
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`,
|
|
1508
|
+
async function mixtapeUpdateCommand(id, options) {
|
|
1509
|
+
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody(options));
|
|
1645
1510
|
}
|
|
1646
|
-
async function
|
|
1511
|
+
async function mixtapeMembersCommand(id, refs, options) {
|
|
1647
1512
|
const members = refs.map((ref) => ({ ref }));
|
|
1648
1513
|
if (options.from) {
|
|
1649
|
-
members.push(...
|
|
1514
|
+
members.push(...parseCueFile(options.from));
|
|
1650
1515
|
}
|
|
1651
1516
|
return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
|
|
1652
1517
|
}
|
|
1653
|
-
async function
|
|
1518
|
+
async function mixtapePublishCommand(id) {
|
|
1654
1519
|
return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
|
|
1655
1520
|
}
|
|
1656
|
-
async function
|
|
1521
|
+
async function mixtapeDeleteCommand(id) {
|
|
1657
1522
|
return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
|
|
1658
1523
|
}
|
|
1659
|
-
async function
|
|
1660
|
-
const
|
|
1661
|
-
return response.mixtapes;
|
|
1662
|
-
}
|
|
1663
|
-
async function mixtapeGetCommand2(idOrLogId) {
|
|
1664
|
-
const mixtapes = await mixtapeListCommand2();
|
|
1665
|
-
const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
|
|
1666
|
-
if (!match) {
|
|
1667
|
-
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1668
|
-
}
|
|
1669
|
-
return match;
|
|
1670
|
-
}
|
|
1671
|
-
async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
|
|
1672
|
-
const mixtape = await mixtapeGetCommand2(idOrLogId);
|
|
1524
|
+
async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
|
|
1525
|
+
const mixtape = await mixtapeGetCommand(idOrLogId);
|
|
1673
1526
|
if (!mixtape.id) {
|
|
1674
1527
|
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1675
1528
|
}
|
|
@@ -1690,15 +1543,15 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
|
|
|
1690
1543
|
let logId = mixtape.logId;
|
|
1691
1544
|
if (!mixtape.durationMs) {
|
|
1692
1545
|
const source = options.audio ?? options.video;
|
|
1693
|
-
const durationMs = source ? await
|
|
1546
|
+
const durationMs = source ? await probeDurationMs(source) : undefined;
|
|
1694
1547
|
if (durationMs) {
|
|
1695
|
-
await
|
|
1548
|
+
await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
|
|
1696
1549
|
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
1697
1550
|
}
|
|
1698
1551
|
}
|
|
1699
1552
|
if (mixtape.status === "draft") {
|
|
1700
1553
|
onProgress("Minting the coordinate…");
|
|
1701
|
-
const published = await
|
|
1554
|
+
const published = await mixtapePublishCommand(mixtapeId);
|
|
1702
1555
|
logId = published.mixtape.logId;
|
|
1703
1556
|
onProgress(`Minted ${logId}.`);
|
|
1704
1557
|
} else if (mixtape.status === "published") {
|
|
@@ -1742,7 +1595,7 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
|
|
|
1742
1595
|
}
|
|
1743
1596
|
return { logId, mixtapeId, results };
|
|
1744
1597
|
}
|
|
1745
|
-
async function
|
|
1598
|
+
async function probeDurationMs(filePath) {
|
|
1746
1599
|
try {
|
|
1747
1600
|
const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
|
|
1748
1601
|
const out = await new Response(proc.stdout).text();
|
|
@@ -1753,7 +1606,7 @@ async function probeDurationMs2(filePath) {
|
|
|
1753
1606
|
return;
|
|
1754
1607
|
}
|
|
1755
1608
|
}
|
|
1756
|
-
function
|
|
1609
|
+
function buildBody(options) {
|
|
1757
1610
|
const body = {};
|
|
1758
1611
|
if (options.note !== undefined) {
|
|
1759
1612
|
body.note = options.note;
|
|
@@ -1765,7 +1618,7 @@ function buildBody2(options) {
|
|
|
1765
1618
|
body.soundcloudUrl = options.soundcloudUrl;
|
|
1766
1619
|
}
|
|
1767
1620
|
if (options.durationMs !== undefined) {
|
|
1768
|
-
const parsed =
|
|
1621
|
+
const parsed = parseDuration(options.durationMs);
|
|
1769
1622
|
if (parsed === null) {
|
|
1770
1623
|
throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
|
|
1771
1624
|
}
|
|
@@ -1773,11 +1626,11 @@ function buildBody2(options) {
|
|
|
1773
1626
|
}
|
|
1774
1627
|
return body;
|
|
1775
1628
|
}
|
|
1776
|
-
function
|
|
1777
|
-
if (!
|
|
1629
|
+
function parseCueFile(filePath) {
|
|
1630
|
+
if (!existsSync2(filePath)) {
|
|
1778
1631
|
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1779
1632
|
}
|
|
1780
|
-
const text =
|
|
1633
|
+
const text = readFileSync2(filePath, "utf-8");
|
|
1781
1634
|
const trimmed = text.trim();
|
|
1782
1635
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
1783
1636
|
try {
|
|
@@ -1806,9 +1659,9 @@ function parseCueFile2(filePath) {
|
|
|
1806
1659
|
throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1807
1660
|
}
|
|
1808
1661
|
}
|
|
1809
|
-
return
|
|
1662
|
+
return parseCueSheet(text);
|
|
1810
1663
|
}
|
|
1811
|
-
function
|
|
1664
|
+
function parseCueSheet(text) {
|
|
1812
1665
|
const entries = [];
|
|
1813
1666
|
for (const line of text.split(/\r?\n/)) {
|
|
1814
1667
|
const trimmed = line.trim();
|
|
@@ -1821,7 +1674,7 @@ function parseCueSheet2(text) {
|
|
|
1821
1674
|
if (time === undefined || ref === undefined) {
|
|
1822
1675
|
continue;
|
|
1823
1676
|
}
|
|
1824
|
-
const startMs =
|
|
1677
|
+
const startMs = parseDuration(time);
|
|
1825
1678
|
if (startMs === null) {
|
|
1826
1679
|
continue;
|
|
1827
1680
|
}
|
|
@@ -1832,44 +1685,10 @@ function parseCueSheet2(text) {
|
|
|
1832
1685
|
}
|
|
1833
1686
|
return entries;
|
|
1834
1687
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
if (!trimmed) {
|
|
1838
|
-
return null;
|
|
1839
|
-
}
|
|
1840
|
-
if (trimmed.includes(":")) {
|
|
1841
|
-
const parts = trimmed.split(":");
|
|
1842
|
-
if (parts.length !== 2 && parts.length !== 3) {
|
|
1843
|
-
return null;
|
|
1844
|
-
}
|
|
1845
|
-
const nums = parts.map((part) => Number(part));
|
|
1846
|
-
if (nums.some((n) => !Number.isFinite(n) || n < 0)) {
|
|
1847
|
-
return null;
|
|
1848
|
-
}
|
|
1849
|
-
if (parts.length === 3) {
|
|
1850
|
-
const [hours, minutes2, seconds2] = nums;
|
|
1851
|
-
if (hours === undefined || minutes2 === undefined || seconds2 === undefined) {
|
|
1852
|
-
return null;
|
|
1853
|
-
}
|
|
1854
|
-
if (minutes2 >= 60 || seconds2 >= 60) {
|
|
1855
|
-
return null;
|
|
1856
|
-
}
|
|
1857
|
-
return Math.round((hours * 3600 + minutes2 * 60 + seconds2) * 1000);
|
|
1858
|
-
}
|
|
1859
|
-
const [minutes, seconds] = nums;
|
|
1860
|
-
if (minutes === undefined || seconds === undefined) {
|
|
1861
|
-
return null;
|
|
1862
|
-
}
|
|
1863
|
-
if (seconds >= 60) {
|
|
1864
|
-
return null;
|
|
1865
|
-
}
|
|
1866
|
-
return Math.round((minutes * 60 + seconds) * 1000);
|
|
1867
|
-
}
|
|
1868
|
-
const value = Number(trimmed);
|
|
1869
|
-
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1870
|
-
}
|
|
1871
|
-
var init_mixtapes2 = __esm(() => {
|
|
1688
|
+
var init_mixtapes = __esm(() => {
|
|
1689
|
+
init_util();
|
|
1872
1690
|
init_api();
|
|
1691
|
+
init_mixtape_api();
|
|
1873
1692
|
init_output();
|
|
1874
1693
|
});
|
|
1875
1694
|
|
|
@@ -1881,7 +1700,9 @@ function artistTitle(track) {
|
|
|
1881
1700
|
return `${track.artists.join(", ")} — ${track.title}`;
|
|
1882
1701
|
}
|
|
1883
1702
|
var COORD_FALLBACK = "—";
|
|
1884
|
-
var init_format = () => {
|
|
1703
|
+
var init_format = __esm(() => {
|
|
1704
|
+
init_util();
|
|
1705
|
+
});
|
|
1885
1706
|
|
|
1886
1707
|
// src/interactive.ts
|
|
1887
1708
|
import { createInterface } from "node:readline/promises";
|
|
@@ -2388,12 +2209,13 @@ async function meCommand() {
|
|
|
2388
2209
|
if (!me.user) {
|
|
2389
2210
|
throw new Error("Your sign-in expired. Run `fluncle login` to link this device again.");
|
|
2390
2211
|
}
|
|
2212
|
+
const user = me.user;
|
|
2391
2213
|
return {
|
|
2392
2214
|
collectedCount: progress.collectedLogIds.length,
|
|
2393
2215
|
deaths: progress.deaths,
|
|
2394
|
-
joinedAt:
|
|
2395
|
-
name:
|
|
2396
|
-
userId:
|
|
2216
|
+
joinedAt: user.createdAt,
|
|
2217
|
+
name: user.displayUsername ?? user.username ?? "cosmonaut",
|
|
2218
|
+
userId: user.id,
|
|
2397
2219
|
wins: progress.wins
|
|
2398
2220
|
};
|
|
2399
2221
|
}
|
|
@@ -2559,7 +2381,7 @@ function parseVersion2(version) {
|
|
|
2559
2381
|
var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
|
|
2560
2382
|
var init_version2 = __esm(() => {
|
|
2561
2383
|
init_output();
|
|
2562
|
-
currentVersion2 = "0.
|
|
2384
|
+
currentVersion2 = "0.74.0".trim() ? "0.74.0".trim() : "0.1.0";
|
|
2563
2385
|
});
|
|
2564
2386
|
|
|
2565
2387
|
// ../../packages/registry/src/index.ts
|
|
@@ -3553,99 +3375,32 @@ async function noteQueueCommand(limit) {
|
|
|
3553
3375
|
async function backfillLastfmCommand(limit, dryRun, cursor) {
|
|
3554
3376
|
const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
|
|
3555
3377
|
if (cursor) {
|
|
3556
|
-
params.set("cursor", cursor);
|
|
3557
|
-
}
|
|
3558
|
-
return adminApiPost(`/api/admin/backfill/lastfm?${params.toString()}`);
|
|
3559
|
-
}
|
|
3560
|
-
async function backfillDiscogsCommand(limit, dryRun, cursor) {
|
|
3561
|
-
const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
|
|
3562
|
-
if (cursor) {
|
|
3563
|
-
params.set("cursor", cursor);
|
|
3564
|
-
}
|
|
3565
|
-
return adminApiPost(`/api/admin/backfill/discogs?${params.toString()}`);
|
|
3566
|
-
}
|
|
3567
|
-
async function vehiclesCommand(limit) {
|
|
3568
|
-
const tracks = await fetchAdminTracks({ hasVideo: true, max: limit, order: "desc" });
|
|
3569
|
-
return tracks.map((track) => ({
|
|
3570
|
-
addedAt: track.addedAt,
|
|
3571
|
-
artists: track.artists,
|
|
3572
|
-
grain: track.videoGrain,
|
|
3573
|
-
logId: track.logId,
|
|
3574
|
-
title: track.title,
|
|
3575
|
-
vehicle: track.videoVehicle
|
|
3576
|
-
}));
|
|
3577
|
-
}
|
|
3578
|
-
var pageSize3 = 48;
|
|
3579
|
-
var init_admin_tracks = __esm(() => {
|
|
3580
|
-
init_api();
|
|
3581
|
-
init_recent2();
|
|
3582
|
-
});
|
|
3583
|
-
|
|
3584
|
-
// src/version-match.ts
|
|
3585
|
-
function isRemix(title) {
|
|
3586
|
-
return REMIX_MARKER.test(title);
|
|
3587
|
-
}
|
|
3588
|
-
function stripVersionSuffix(title) {
|
|
3589
|
-
const parts = title.split(/\s+-\s+/);
|
|
3590
|
-
if (parts.length > 1 && VERSION_MARKER.test(parts[parts.length - 1] ?? "")) {
|
|
3591
|
-
return parts.slice(0, -1).join(" - ").trim();
|
|
3592
|
-
}
|
|
3593
|
-
return title.trim();
|
|
3594
|
-
}
|
|
3595
|
-
function tokenize(value) {
|
|
3596
|
-
return value.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean);
|
|
3597
|
-
}
|
|
3598
|
-
function versionTokens(title) {
|
|
3599
|
-
const parts = title.split(/\s+-\s+/);
|
|
3600
|
-
const tail = parts.length > 1 ? parts[parts.length - 1] ?? "" : "";
|
|
3601
|
-
if (parts.length > 1 && VERSION_MARKER.test(tail)) {
|
|
3602
|
-
return new Set(tokenize(tail));
|
|
3603
|
-
}
|
|
3604
|
-
const bracketed = /[([]([^)\]]*?)[)\]]/.exec(title);
|
|
3605
|
-
if (bracketed?.[1] && VERSION_MARKER.test(bracketed[1])) {
|
|
3606
|
-
return new Set(tokenize(bracketed[1]));
|
|
3607
|
-
}
|
|
3608
|
-
return new Set;
|
|
3609
|
-
}
|
|
3610
|
-
function versionMatches(findingTitle, candidateTitle) {
|
|
3611
|
-
const findingIsRemix = isRemix(findingTitle);
|
|
3612
|
-
const candidateIsRemix = isRemix(candidateTitle);
|
|
3613
|
-
if (findingIsRemix) {
|
|
3614
|
-
if (!candidateIsRemix) {
|
|
3615
|
-
return false;
|
|
3616
|
-
}
|
|
3617
|
-
const want = [...versionTokens(findingTitle)].filter((t) => !VERSION_STOPWORDS.has(t));
|
|
3618
|
-
if (want.length === 0) {
|
|
3619
|
-
return true;
|
|
3620
|
-
}
|
|
3621
|
-
const have = versionTokens(candidateTitle);
|
|
3622
|
-
for (const token of want) {
|
|
3623
|
-
if (!have.has(token)) {
|
|
3624
|
-
return false;
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
return true;
|
|
3378
|
+
params.set("cursor", cursor);
|
|
3628
3379
|
}
|
|
3629
|
-
return
|
|
3380
|
+
return adminApiPost(`/api/admin/backfill/lastfm?${params.toString()}`);
|
|
3630
3381
|
}
|
|
3631
|
-
function
|
|
3632
|
-
const
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
return false;
|
|
3636
|
-
}
|
|
3637
|
-
for (const token of want) {
|
|
3638
|
-
if (!have.has(token)) {
|
|
3639
|
-
return false;
|
|
3640
|
-
}
|
|
3382
|
+
async function backfillDiscogsCommand(limit, dryRun, cursor) {
|
|
3383
|
+
const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
|
|
3384
|
+
if (cursor) {
|
|
3385
|
+
params.set("cursor", cursor);
|
|
3641
3386
|
}
|
|
3642
|
-
return
|
|
3387
|
+
return adminApiPost(`/api/admin/backfill/discogs?${params.toString()}`);
|
|
3643
3388
|
}
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3389
|
+
async function vehiclesCommand(limit) {
|
|
3390
|
+
const tracks = await fetchAdminTracks({ hasVideo: true, max: limit, order: "desc" });
|
|
3391
|
+
return tracks.map((track) => ({
|
|
3392
|
+
addedAt: track.addedAt,
|
|
3393
|
+
artists: track.artists,
|
|
3394
|
+
grain: track.videoGrain,
|
|
3395
|
+
logId: track.logId,
|
|
3396
|
+
title: track.title,
|
|
3397
|
+
vehicle: track.videoVehicle
|
|
3398
|
+
}));
|
|
3399
|
+
}
|
|
3400
|
+
var pageSize3 = 48;
|
|
3401
|
+
var init_admin_tracks = __esm(() => {
|
|
3402
|
+
init_api();
|
|
3403
|
+
init_recent2();
|
|
3649
3404
|
});
|
|
3650
3405
|
|
|
3651
3406
|
// src/commands/preview-archive.ts
|
|
@@ -3844,8 +3599,8 @@ function normalize(value) {
|
|
|
3844
3599
|
}
|
|
3845
3600
|
var DURATION_TOLERANCE_S = 5;
|
|
3846
3601
|
var init_preview_archive = __esm(() => {
|
|
3602
|
+
init_util();
|
|
3847
3603
|
init_api();
|
|
3848
|
-
init_version_match();
|
|
3849
3604
|
});
|
|
3850
3605
|
|
|
3851
3606
|
// src/commands/mixtape-youtube.ts
|
|
@@ -4008,6 +3763,214 @@ var init_mixtape_youtube2 = __esm(() => {
|
|
|
4008
3763
|
init_output();
|
|
4009
3764
|
});
|
|
4010
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
|
+
|
|
4011
3974
|
// src/commands/clips.ts
|
|
4012
3975
|
var exports_clips = {};
|
|
4013
3976
|
__export(exports_clips, {
|
|
@@ -4102,8 +4065,8 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
|
|
|
4102
4065
|
if (!clip) {
|
|
4103
4066
|
throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
|
|
4104
4067
|
}
|
|
4105
|
-
const { mixtapeGetCommand:
|
|
4106
|
-
const mixtape = await
|
|
4068
|
+
const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
4069
|
+
const mixtape = await mixtapeGetCommand2(clip.mixtapeId);
|
|
4107
4070
|
if (!mixtape.logId) {
|
|
4108
4071
|
throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
|
|
4109
4072
|
}
|
|
@@ -4544,7 +4507,7 @@ var MIXCLOUD_API2 = "https://api.mixcloud.com", DESCRIPTION_MAX2 = 1000, PICTURE
|
|
|
4544
4507
|
var init_mixtape_mixcloud2 = __esm(() => {
|
|
4545
4508
|
init_api();
|
|
4546
4509
|
init_output();
|
|
4547
|
-
|
|
4510
|
+
init_mixtape_api();
|
|
4548
4511
|
PICTURE_MAX_BYTES2 = 10 * 1024 * 1024;
|
|
4549
4512
|
});
|
|
4550
4513
|
|
|
@@ -4854,7 +4817,9 @@ function trackDetailLines(track) {
|
|
|
4854
4817
|
return lines;
|
|
4855
4818
|
}
|
|
4856
4819
|
var COORD_FALLBACK2 = "—";
|
|
4857
|
-
var init_format2 = () => {
|
|
4820
|
+
var init_format2 = __esm(() => {
|
|
4821
|
+
init_util();
|
|
4822
|
+
});
|
|
4858
4823
|
|
|
4859
4824
|
// src/cli.ts
|
|
4860
4825
|
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
@@ -6966,6 +6931,10 @@ function toJsonFailure(error) {
|
|
|
6966
6931
|
ok: false
|
|
6967
6932
|
};
|
|
6968
6933
|
}
|
|
6934
|
+
|
|
6935
|
+
// src/retry.ts
|
|
6936
|
+
init_util();
|
|
6937
|
+
|
|
6969
6938
|
// src/cli.ts
|
|
6970
6939
|
function createProgram() {
|
|
6971
6940
|
const program2 = configureCommand(new Command);
|
|
@@ -7019,7 +6988,7 @@ function addListenCommands(program2) {
|
|
|
7019
6988
|
await runRecent(options, recentCommand3);
|
|
7020
6989
|
});
|
|
7021
6990
|
program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
|
|
7022
|
-
const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (
|
|
6991
|
+
const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7023
6992
|
await runMixtapes(options, mixtapesCommand3);
|
|
7024
6993
|
});
|
|
7025
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) => {
|
|
@@ -7073,7 +7042,7 @@ function addMetaCommands(program2) {
|
|
|
7073
7042
|
});
|
|
7074
7043
|
}
|
|
7075
7044
|
function addTrackCommands(program2) {
|
|
7076
|
-
const tracks = configureCommand(program2.command("tracks", { hidden: true }).
|
|
7045
|
+
const tracks = configureCommand(program2.command("tracks", { hidden: true }).description("Public track lookups"));
|
|
7077
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) => {
|
|
7078
7047
|
const { trackGetCommand: trackGetCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
|
|
7079
7048
|
await runTrackGet(idOrLogId, options, trackGetCommand2);
|
|
@@ -7087,7 +7056,7 @@ function addAdminCommands(program2) {
|
|
|
7087
7056
|
admin.command("help", { hidden: true }).description("display help for command").action(() => {
|
|
7088
7057
|
admin.outputHelp();
|
|
7089
7058
|
});
|
|
7090
|
-
const adminTracks = configureCommand(admin.command("tracks").
|
|
7059
|
+
const adminTracks = configureCommand(admin.command("tracks").description("Track admin commands"));
|
|
7091
7060
|
adminTracks.action(() => {
|
|
7092
7061
|
adminTracks.outputHelp();
|
|
7093
7062
|
});
|
|
@@ -7095,15 +7064,7 @@ function addAdminCommands(program2) {
|
|
|
7095
7064
|
const { addCommand: addCommand3 } = await Promise.resolve().then(() => (init_add(), exports_add));
|
|
7096
7065
|
await runAdd(spotifyUrl, options, addCommand3);
|
|
7097
7066
|
});
|
|
7098
|
-
|
|
7099
|
-
const { addCommand: addCommand3 } = await Promise.resolve().then(() => (init_add(), exports_add));
|
|
7100
|
-
await runAdd(spotifyUrl, options, addCommand3);
|
|
7101
|
-
});
|
|
7102
|
-
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) => {
|
|
7103
|
-
const { queueCommand: queueCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
|
|
7104
|
-
await runAdminQueue(options, queueCommand2);
|
|
7105
|
-
});
|
|
7106
|
-
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) => {
|
|
7107
7068
|
const { queueCommand: queueCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
|
|
7108
7069
|
await runAdminQueue(options, queueCommand2);
|
|
7109
7070
|
});
|
|
@@ -7120,10 +7081,6 @@ function addAdminCommands(program2) {
|
|
|
7120
7081
|
const { vehiclesCommand: vehiclesCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
|
|
7121
7082
|
await runAdminVehicles(options, vehiclesCommand2);
|
|
7122
7083
|
});
|
|
7123
|
-
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) => {
|
|
7124
|
-
const { vehiclesCommand: vehiclesCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
|
|
7125
|
-
await runAdminVehicles(options, vehiclesCommand2);
|
|
7126
|
-
});
|
|
7127
7084
|
const adminTrack = adminTracks;
|
|
7128
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) => {
|
|
7129
7086
|
const { trackUpdateCommand: trackUpdateCommand2 } = await Promise.resolve().then(() => (init_track(), exports_track));
|
|
@@ -7190,35 +7147,35 @@ function addAdminCommands(program2) {
|
|
|
7190
7147
|
adminMixtapes.outputHelp();
|
|
7191
7148
|
});
|
|
7192
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) => {
|
|
7193
|
-
const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (
|
|
7150
|
+
const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7194
7151
|
await runMixtapeCreate(options, mixtapeCreateCommand3);
|
|
7195
7152
|
});
|
|
7196
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) => {
|
|
7197
|
-
const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (
|
|
7154
|
+
const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7198
7155
|
await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
|
|
7199
7156
|
});
|
|
7200
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) => {
|
|
7201
|
-
const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (
|
|
7158
|
+
const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7202
7159
|
await runMixtapeMembers(id, refs, options, mixtapeMembersCommand3);
|
|
7203
7160
|
});
|
|
7204
7161
|
adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
7205
|
-
const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (
|
|
7162
|
+
const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7206
7163
|
await runMixtapePublish(id, options, mixtapePublishCommand3);
|
|
7207
7164
|
});
|
|
7208
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) => {
|
|
7209
|
-
const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (
|
|
7166
|
+
const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7210
7167
|
await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
|
|
7211
7168
|
});
|
|
7212
7169
|
adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
|
|
7213
|
-
const { mixtapeListCommand:
|
|
7214
|
-
await runMixtapeList(options,
|
|
7170
|
+
const { mixtapeListCommand: mixtapeListCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7171
|
+
await runMixtapeList(options, mixtapeListCommand2);
|
|
7215
7172
|
});
|
|
7216
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) => {
|
|
7217
|
-
const { mixtapeGetCommand:
|
|
7218
|
-
await runMixtapeGet(idOrLogId, options,
|
|
7174
|
+
const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7175
|
+
await runMixtapeGet(idOrLogId, options, mixtapeGetCommand2);
|
|
7219
7176
|
});
|
|
7220
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) => {
|
|
7221
|
-
const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (
|
|
7178
|
+
const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
7222
7179
|
await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
|
|
7223
7180
|
});
|
|
7224
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) => {
|
|
@@ -7304,7 +7261,7 @@ function addAdminCommands(program2) {
|
|
|
7304
7261
|
const { authLastfmCommand: authLastfmCommand2 } = await Promise.resolve().then(() => (init_auth_lastfm(), exports_auth_lastfm));
|
|
7305
7262
|
await authLastfmCommand2(options);
|
|
7306
7263
|
});
|
|
7307
|
-
const backfill = configureCommand(admin.command("backfills").
|
|
7264
|
+
const backfill = configureCommand(admin.command("backfills").description("Backfill operator-only archives"));
|
|
7308
7265
|
backfill.action(() => {
|
|
7309
7266
|
backfill.outputHelp();
|
|
7310
7267
|
});
|
|
@@ -7798,8 +7755,8 @@ async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
|
|
|
7798
7755
|
}
|
|
7799
7756
|
console.log(`Discarded draft ${id}.`);
|
|
7800
7757
|
}
|
|
7801
|
-
async function runMixtapeList(options,
|
|
7802
|
-
const mixtapes = await
|
|
7758
|
+
async function runMixtapeList(options, mixtapeListCommand2) {
|
|
7759
|
+
const mixtapes = await mixtapeListCommand2();
|
|
7803
7760
|
if (options.json) {
|
|
7804
7761
|
printJson({ mixtapes, ok: true });
|
|
7805
7762
|
return;
|
|
@@ -7840,11 +7797,11 @@ async function runClipsCut(clipId, options, clipCutCommand2) {
|
|
|
7840
7797
|
}
|
|
7841
7798
|
console.log(`Cut ${result.clipId} \u2192 ${result.url} (${(result.sizeBytes / 1e6).toFixed(1)} MB).`);
|
|
7842
7799
|
}
|
|
7843
|
-
async function runMixtapeGet(idOrLogId, options,
|
|
7800
|
+
async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand2) {
|
|
7844
7801
|
if (!idOrLogId) {
|
|
7845
7802
|
throw new Error("Missing mixtape id or log id. Usage: fluncle admin mixtapes get <id|logId>");
|
|
7846
7803
|
}
|
|
7847
|
-
const mixtape = await
|
|
7804
|
+
const mixtape = await mixtapeGetCommand2(idOrLogId);
|
|
7848
7805
|
if (options.json) {
|
|
7849
7806
|
printJson(mixtape);
|
|
7850
7807
|
return;
|
|
@@ -8011,7 +7968,6 @@ function parseListLimit(value) {
|
|
|
8011
7968
|
async function runAdminQueue(options, queueCommand2) {
|
|
8012
7969
|
const limit = parseListLimit(options.limit);
|
|
8013
7970
|
const tracks = await queueCommand2(limit, {
|
|
8014
|
-
hasContext: options.hasContext ? true : undefined,
|
|
8015
7971
|
hasObservation: options.hasObservation ? true : undefined
|
|
8016
7972
|
});
|
|
8017
7973
|
if (options.json) {
|