fluncle 0.69.0 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/fluncle.mjs +742 -288
- package/package.json +1 -1
package/bin/fluncle.mjs
CHANGED
|
@@ -423,7 +423,7 @@ function parseVersion(version) {
|
|
|
423
423
|
var currentVersion;
|
|
424
424
|
var init_version = __esm(() => {
|
|
425
425
|
init_output();
|
|
426
|
-
currentVersion = "0.
|
|
426
|
+
currentVersion = "0.70.0".trim() ? "0.70.0".trim() : "0.1.0";
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
// src/update-notifier.ts
|
|
@@ -1047,171 +1047,6 @@ var init_mixtape_youtube = __esm(() => {
|
|
|
1047
1047
|
init_output();
|
|
1048
1048
|
});
|
|
1049
1049
|
|
|
1050
|
-
// src/commands/mixtapes.ts
|
|
1051
|
-
async function mixtapeListCommand() {
|
|
1052
|
-
const response = await adminApiGet("/api/admin/mixtapes");
|
|
1053
|
-
return response.mixtapes;
|
|
1054
|
-
}
|
|
1055
|
-
async function mixtapeGetCommand(idOrLogId) {
|
|
1056
|
-
const mixtapes = await mixtapeListCommand();
|
|
1057
|
-
const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
|
|
1058
|
-
if (!match) {
|
|
1059
|
-
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1060
|
-
}
|
|
1061
|
-
return match;
|
|
1062
|
-
}
|
|
1063
|
-
var init_mixtapes = __esm(() => {
|
|
1064
|
-
init_api();
|
|
1065
|
-
init_output();
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
// src/commands/mixtape-mixcloud.ts
|
|
1069
|
-
var exports_mixtape_mixcloud = {};
|
|
1070
|
-
__export(exports_mixtape_mixcloud, {
|
|
1071
|
-
mixtapeDescription: () => mixtapeDescription,
|
|
1072
|
-
mixcloudSections: () => mixcloudSections,
|
|
1073
|
-
distributeMixcloud: () => distributeMixcloud,
|
|
1074
|
-
authMixcloudCommand: () => authMixcloudCommand
|
|
1075
|
-
});
|
|
1076
|
-
async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
|
|
1077
|
-
const token = await fetchMixcloudToken();
|
|
1078
|
-
const mixtape = await mixtapeGetCommand(mixtapeId);
|
|
1079
|
-
const logId = mixtape.logId;
|
|
1080
|
-
if (!logId) {
|
|
1081
|
-
throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
|
|
1082
|
-
}
|
|
1083
|
-
const audio = Bun.file(audioPath);
|
|
1084
|
-
if (!await audio.exists()) {
|
|
1085
|
-
throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
|
|
1086
|
-
}
|
|
1087
|
-
const form = new FormData;
|
|
1088
|
-
form.append("mp3", audio);
|
|
1089
|
-
form.append("name", mixtape.title);
|
|
1090
|
-
form.append("description", mixtapeDescription(mixtape.note, logId));
|
|
1091
|
-
onProgress?.("Mixcloud: fetching the cover…");
|
|
1092
|
-
const picture = await fetchCover(logId);
|
|
1093
|
-
if (picture) {
|
|
1094
|
-
form.append("picture", picture, "cover.png");
|
|
1095
|
-
}
|
|
1096
|
-
for (const [index, tag] of mixtapeTags(mixtape).entries()) {
|
|
1097
|
-
form.append(`tags-${index}-tag`, tag);
|
|
1098
|
-
}
|
|
1099
|
-
const sections = mixcloudSections(mixtape.members);
|
|
1100
|
-
for (const [index, section] of sections.entries()) {
|
|
1101
|
-
form.append(`sections-${index}-artist`, section.artist);
|
|
1102
|
-
form.append(`sections-${index}-song`, section.song);
|
|
1103
|
-
form.append(`sections-${index}-start_time`, String(section.start_time));
|
|
1104
|
-
}
|
|
1105
|
-
if (unlisted) {
|
|
1106
|
-
form.append("unlisted", "1");
|
|
1107
|
-
onProgress?.("Mixcloud: uploading UNLISTED (private).");
|
|
1108
|
-
}
|
|
1109
|
-
const cuelessCount = mixtape.members.length - sections.length;
|
|
1110
|
-
if (cuelessCount > 0) {
|
|
1111
|
-
onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
|
|
1112
|
-
}
|
|
1113
|
-
onProgress?.("Mixcloud: uploading the master…");
|
|
1114
|
-
const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
|
|
1115
|
-
body: form,
|
|
1116
|
-
method: "POST"
|
|
1117
|
-
});
|
|
1118
|
-
const uploadText = await uploadResponse.text();
|
|
1119
|
-
if (!uploadResponse.ok) {
|
|
1120
|
-
throwMixcloudError(uploadResponse.status, uploadText);
|
|
1121
|
-
}
|
|
1122
|
-
const result = parseUploadResult(uploadText);
|
|
1123
|
-
if (!result.success || !result.key) {
|
|
1124
|
-
throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
|
|
1125
|
-
}
|
|
1126
|
-
const externalId = result.key;
|
|
1127
|
-
const url = `https://www.mixcloud.com${result.key}`;
|
|
1128
|
-
onProgress?.("Mixcloud: recording the link…");
|
|
1129
|
-
await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
|
|
1130
|
-
externalId,
|
|
1131
|
-
url
|
|
1132
|
-
});
|
|
1133
|
-
return { url };
|
|
1134
|
-
}
|
|
1135
|
-
async function authMixcloudCommand() {
|
|
1136
|
-
const response = await adminApiGet("/api/admin/mixcloud/auth/start");
|
|
1137
|
-
console.log(`Open this Mixcloud authorization URL:
|
|
1138
|
-
|
|
1139
|
-
${response.authUrl}
|
|
1140
|
-
|
|
1141
|
-
After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
|
|
1142
|
-
}
|
|
1143
|
-
function mixtapeDescription(note, logId) {
|
|
1144
|
-
const breadcrumb = `fluncle://${logId}`;
|
|
1145
|
-
const body = (note ?? "").trim();
|
|
1146
|
-
const full = body ? `${body}
|
|
1147
|
-
|
|
1148
|
-
${breadcrumb}` : breadcrumb;
|
|
1149
|
-
if (full.length <= DESCRIPTION_MAX) {
|
|
1150
|
-
return full;
|
|
1151
|
-
}
|
|
1152
|
-
const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
|
|
1153
|
-
const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
|
|
1154
|
-
return trimmedNote ? `${trimmedNote}
|
|
1155
|
-
|
|
1156
|
-
${breadcrumb}` : breadcrumb;
|
|
1157
|
-
}
|
|
1158
|
-
function mixcloudSections(members) {
|
|
1159
|
-
return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
|
|
1160
|
-
artist: member.artists.join(", "),
|
|
1161
|
-
song: member.title,
|
|
1162
|
-
start_time: Math.floor(member.startMs / 1000)
|
|
1163
|
-
}));
|
|
1164
|
-
}
|
|
1165
|
-
function mixtapeTags(_mixtape) {
|
|
1166
|
-
return ["Drum & Bass", "Fluncle"];
|
|
1167
|
-
}
|
|
1168
|
-
async function fetchMixcloudToken() {
|
|
1169
|
-
try {
|
|
1170
|
-
const response = await adminApiPost("/api/admin/mixcloud/token");
|
|
1171
|
-
return response.accessToken;
|
|
1172
|
-
} catch {
|
|
1173
|
-
throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
async function fetchCover(logId) {
|
|
1177
|
-
for (const size of ["square", "og"]) {
|
|
1178
|
-
const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
|
|
1179
|
-
if (!response.ok) {
|
|
1180
|
-
continue;
|
|
1181
|
-
}
|
|
1182
|
-
const blob = await response.blob();
|
|
1183
|
-
if (blob.size <= PICTURE_MAX_BYTES) {
|
|
1184
|
-
return blob;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
return;
|
|
1188
|
-
}
|
|
1189
|
-
function parseUploadResult(body) {
|
|
1190
|
-
try {
|
|
1191
|
-
const data = JSON.parse(body);
|
|
1192
|
-
return {
|
|
1193
|
-
key: data.result?.key,
|
|
1194
|
-
message: data.result?.message,
|
|
1195
|
-
success: data.result?.success === true
|
|
1196
|
-
};
|
|
1197
|
-
} catch {
|
|
1198
|
-
return { message: body.slice(0, 300), success: false };
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
function throwMixcloudError(status, body) {
|
|
1202
|
-
if (body.includes("An invalid access token was provided")) {
|
|
1203
|
-
throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
|
|
1204
|
-
}
|
|
1205
|
-
throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
|
|
1206
|
-
}
|
|
1207
|
-
var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
|
|
1208
|
-
var init_mixtape_mixcloud = __esm(() => {
|
|
1209
|
-
init_api();
|
|
1210
|
-
init_output();
|
|
1211
|
-
init_mixtapes();
|
|
1212
|
-
PICTURE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
1050
|
// src/commands/mixtape-set-video.ts
|
|
1216
1051
|
var exports_mixtape_set_video = {};
|
|
1217
1052
|
__export(exports_mixtape_set_video, {
|
|
@@ -1317,107 +1152,508 @@ async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
|
|
|
1317
1152
|
} finally {
|
|
1318
1153
|
rmSync2(renditionPath, { force: true });
|
|
1319
1154
|
}
|
|
1320
|
-
}
|
|
1321
|
-
async function putPart(url, path2, part) {
|
|
1322
|
-
const response = await fetch(url, {
|
|
1323
|
-
body: Bun.file(path2).slice(part.start, part.end),
|
|
1324
|
-
method: "PUT"
|
|
1325
|
-
});
|
|
1326
|
-
if (!response.ok) {
|
|
1327
|
-
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1328
|
-
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
1155
|
+
}
|
|
1156
|
+
async function putPart(url, path2, part) {
|
|
1157
|
+
const response = await fetch(url, {
|
|
1158
|
+
body: Bun.file(path2).slice(part.start, part.end),
|
|
1159
|
+
method: "PUT"
|
|
1160
|
+
});
|
|
1161
|
+
if (!response.ok) {
|
|
1162
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
1163
|
+
throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
1164
|
+
}
|
|
1165
|
+
const etag = response.headers.get("etag");
|
|
1166
|
+
if (!etag) {
|
|
1167
|
+
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
1168
|
+
}
|
|
1169
|
+
return etag;
|
|
1170
|
+
}
|
|
1171
|
+
async function completeUpload(url, parts) {
|
|
1172
|
+
const response = await fetch(url, {
|
|
1173
|
+
body: buildCompleteXml(parts),
|
|
1174
|
+
headers: { "content-type": "application/xml" },
|
|
1175
|
+
method: "POST"
|
|
1176
|
+
});
|
|
1177
|
+
const text = await response.text().catch(() => "");
|
|
1178
|
+
if (!response.ok || text.includes("<Error>")) {
|
|
1179
|
+
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
async function abortUpload(url) {
|
|
1183
|
+
await fetch(url, { method: "DELETE" });
|
|
1184
|
+
}
|
|
1185
|
+
async function assertFfmpeg() {
|
|
1186
|
+
try {
|
|
1187
|
+
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
1188
|
+
await proc.exited;
|
|
1189
|
+
if (proc.exitCode !== 0) {
|
|
1190
|
+
throw new Error("ffmpeg -version exited non-zero");
|
|
1191
|
+
}
|
|
1192
|
+
} catch {
|
|
1193
|
+
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function deriveRendition(inputPath, outputPath) {
|
|
1197
|
+
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
1198
|
+
stderr: "pipe",
|
|
1199
|
+
stdout: "ignore"
|
|
1200
|
+
});
|
|
1201
|
+
await proc.exited;
|
|
1202
|
+
if (proc.exitCode !== 0) {
|
|
1203
|
+
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
1204
|
+
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
1208
|
+
var init_mixtape_set_video = __esm(() => {
|
|
1209
|
+
init_api();
|
|
1210
|
+
init_output();
|
|
1211
|
+
SET_VIDEO_RENDITION = {
|
|
1212
|
+
audioBitrate: "192k",
|
|
1213
|
+
crf: 20,
|
|
1214
|
+
gopSeconds: 2,
|
|
1215
|
+
height: 1080
|
|
1216
|
+
};
|
|
1217
|
+
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1218
|
+
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
// src/commands/mixtapes.ts
|
|
1222
|
+
var exports_mixtapes = {};
|
|
1223
|
+
__export(exports_mixtapes, {
|
|
1224
|
+
mixtapesCommand: () => mixtapesCommand,
|
|
1225
|
+
mixtapeUpdateCommand: () => mixtapeUpdateCommand,
|
|
1226
|
+
mixtapePublishCommand: () => mixtapePublishCommand,
|
|
1227
|
+
mixtapeMembersCommand: () => mixtapeMembersCommand,
|
|
1228
|
+
mixtapeListCommand: () => mixtapeListCommand,
|
|
1229
|
+
mixtapeGetCommand: () => mixtapeGetCommand,
|
|
1230
|
+
mixtapeDistributeCommand: () => mixtapeDistributeCommand,
|
|
1231
|
+
mixtapeDeleteCommand: () => mixtapeDeleteCommand,
|
|
1232
|
+
mixtapeCreateCommand: () => mixtapeCreateCommand
|
|
1233
|
+
});
|
|
1234
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1235
|
+
async function mixtapesCommand() {
|
|
1236
|
+
const response = await publicApiGet("/api/mixtapes");
|
|
1237
|
+
return response.mixtapes;
|
|
1238
|
+
}
|
|
1239
|
+
async function mixtapeCreateCommand(options) {
|
|
1240
|
+
return adminApiPost("/api/admin/mixtapes", buildBody(options));
|
|
1241
|
+
}
|
|
1242
|
+
async function mixtapeUpdateCommand(id, options) {
|
|
1243
|
+
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody(options));
|
|
1244
|
+
}
|
|
1245
|
+
async function mixtapeMembersCommand(id, refs, options) {
|
|
1246
|
+
const members = refs.map((ref) => ({ ref }));
|
|
1247
|
+
if (options.from) {
|
|
1248
|
+
members.push(...parseCueFile(options.from));
|
|
1249
|
+
}
|
|
1250
|
+
return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
|
|
1251
|
+
}
|
|
1252
|
+
async function mixtapePublishCommand(id) {
|
|
1253
|
+
return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
|
|
1254
|
+
}
|
|
1255
|
+
async function mixtapeDeleteCommand(id) {
|
|
1256
|
+
return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
|
|
1257
|
+
}
|
|
1258
|
+
async function mixtapeListCommand() {
|
|
1259
|
+
const response = await adminApiGet("/api/admin/mixtapes");
|
|
1260
|
+
return response.mixtapes;
|
|
1261
|
+
}
|
|
1262
|
+
async function mixtapeGetCommand(idOrLogId) {
|
|
1263
|
+
const mixtapes = await mixtapeListCommand();
|
|
1264
|
+
const match = mixtapes.find((mixtape) => mixtape.id === idOrLogId || mixtape.logId === idOrLogId);
|
|
1265
|
+
if (!match) {
|
|
1266
|
+
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1267
|
+
}
|
|
1268
|
+
return match;
|
|
1269
|
+
}
|
|
1270
|
+
async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {}) {
|
|
1271
|
+
const mixtape = await mixtapeGetCommand(idOrLogId);
|
|
1272
|
+
if (!mixtape.id) {
|
|
1273
|
+
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1274
|
+
}
|
|
1275
|
+
const both = !options.youtube && !options.mixcloud && !options.setVideo;
|
|
1276
|
+
const doYoutube = both || Boolean(options.youtube);
|
|
1277
|
+
const doMixcloud = both || Boolean(options.mixcloud);
|
|
1278
|
+
const doSetVideo = Boolean(options.setVideo);
|
|
1279
|
+
if (doYoutube && !options.video) {
|
|
1280
|
+
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1281
|
+
}
|
|
1282
|
+
if (doMixcloud && !options.audio) {
|
|
1283
|
+
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1284
|
+
}
|
|
1285
|
+
if (doSetVideo && !options.video) {
|
|
1286
|
+
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1287
|
+
}
|
|
1288
|
+
const mixtapeId = mixtape.id;
|
|
1289
|
+
let logId = mixtape.logId;
|
|
1290
|
+
if (!mixtape.durationMs) {
|
|
1291
|
+
const source = options.audio ?? options.video;
|
|
1292
|
+
const durationMs = source ? await probeDurationMs(source) : undefined;
|
|
1293
|
+
if (durationMs) {
|
|
1294
|
+
await mixtapeUpdateCommand(mixtapeId, { durationMs: String(durationMs), json: false });
|
|
1295
|
+
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (mixtape.status === "draft") {
|
|
1299
|
+
onProgress("Minting the coordinate…");
|
|
1300
|
+
const published = await mixtapePublishCommand(mixtapeId);
|
|
1301
|
+
logId = published.mixtape.logId;
|
|
1302
|
+
onProgress(`Minted ${logId}.`);
|
|
1303
|
+
} else if (mixtape.status === "published") {
|
|
1304
|
+
onProgress(`Already published (${logId}); re-distributing.`);
|
|
1305
|
+
} else {
|
|
1306
|
+
onProgress(`Resuming distribution for ${logId}.`);
|
|
1307
|
+
}
|
|
1308
|
+
if (!logId) {
|
|
1309
|
+
throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
|
|
1310
|
+
}
|
|
1311
|
+
const results = [];
|
|
1312
|
+
if (doYoutube) {
|
|
1313
|
+
if (!options.video) {
|
|
1314
|
+
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1315
|
+
}
|
|
1316
|
+
onProgress("YouTube: uploading video…");
|
|
1317
|
+
const { distributeYoutube: distributeYoutube2 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
|
|
1318
|
+
const result = await distributeYoutube2(mixtapeId, options.video, onProgress);
|
|
1319
|
+
results.push({ platform: "youtube", url: result.url });
|
|
1320
|
+
onProgress(`YouTube: ${result.url}`);
|
|
1321
|
+
}
|
|
1322
|
+
if (doMixcloud) {
|
|
1323
|
+
if (!options.audio) {
|
|
1324
|
+
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1325
|
+
}
|
|
1326
|
+
onProgress("Mixcloud: uploading audio…");
|
|
1327
|
+
const { distributeMixcloud } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
|
|
1328
|
+
const result = await distributeMixcloud(mixtapeId, options.audio, onProgress, options.unlisted);
|
|
1329
|
+
results.push({ platform: "mixcloud", url: result.url });
|
|
1330
|
+
onProgress(`Mixcloud: ${result.url}`);
|
|
1331
|
+
}
|
|
1332
|
+
if (doSetVideo) {
|
|
1333
|
+
if (!options.video) {
|
|
1334
|
+
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1335
|
+
}
|
|
1336
|
+
onProgress("Set video: staging the 1080p rendition…");
|
|
1337
|
+
const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
|
|
1338
|
+
const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
|
|
1339
|
+
results.push({ platform: "set-video", url: result.url });
|
|
1340
|
+
onProgress(`Set video: ${result.url}`);
|
|
1341
|
+
}
|
|
1342
|
+
return { logId, mixtapeId, results };
|
|
1343
|
+
}
|
|
1344
|
+
async function probeDurationMs(filePath) {
|
|
1345
|
+
try {
|
|
1346
|
+
const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
|
|
1347
|
+
const out = await new Response(proc.stdout).text();
|
|
1348
|
+
await proc.exited;
|
|
1349
|
+
const seconds = Number.parseFloat(out.trim());
|
|
1350
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds * 1000) : undefined;
|
|
1351
|
+
} catch {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
function buildBody(options) {
|
|
1356
|
+
const body = {};
|
|
1357
|
+
if (options.note !== undefined) {
|
|
1358
|
+
body.note = options.note;
|
|
1359
|
+
}
|
|
1360
|
+
if (options.recordedAt !== undefined) {
|
|
1361
|
+
body.recordedAt = options.recordedAt;
|
|
1362
|
+
}
|
|
1363
|
+
if (options.soundcloudUrl !== undefined) {
|
|
1364
|
+
body.soundcloudUrl = options.soundcloudUrl;
|
|
1365
|
+
}
|
|
1366
|
+
if (options.durationMs !== undefined) {
|
|
1367
|
+
const parsed = parseDuration(options.durationMs);
|
|
1368
|
+
if (parsed === null) {
|
|
1369
|
+
throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
|
|
1370
|
+
}
|
|
1371
|
+
body.durationMs = parsed;
|
|
1372
|
+
}
|
|
1373
|
+
return body;
|
|
1374
|
+
}
|
|
1375
|
+
function parseCueFile(filePath) {
|
|
1376
|
+
if (!existsSync2(filePath)) {
|
|
1377
|
+
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1378
|
+
}
|
|
1379
|
+
const text = readFileSync2(filePath, "utf-8");
|
|
1380
|
+
const trimmed = text.trim();
|
|
1381
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
1382
|
+
try {
|
|
1383
|
+
const parsed = JSON.parse(trimmed);
|
|
1384
|
+
if (!Array.isArray(parsed)) {
|
|
1385
|
+
throw new Error("not an array");
|
|
1386
|
+
}
|
|
1387
|
+
return parsed.map((entry, index) => {
|
|
1388
|
+
if (typeof entry === "string") {
|
|
1389
|
+
return { ref: entry.trim() };
|
|
1390
|
+
}
|
|
1391
|
+
const obj = entry;
|
|
1392
|
+
if (typeof obj?.ref !== "string") {
|
|
1393
|
+
throw new CliError2("invalid_cue_json", `Entry ${index + 1} missing "ref" string`);
|
|
1394
|
+
}
|
|
1395
|
+
const cue = { ref: obj.ref.trim() };
|
|
1396
|
+
if (typeof obj.startMs === "number" && Number.isInteger(obj.startMs) && obj.startMs >= 0) {
|
|
1397
|
+
cue.startMs = obj.startMs;
|
|
1398
|
+
}
|
|
1399
|
+
return cue;
|
|
1400
|
+
});
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
if (error instanceof CliError2) {
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return parseCueSheet(text);
|
|
1409
|
+
}
|
|
1410
|
+
function parseCueSheet(text) {
|
|
1411
|
+
const entries = [];
|
|
1412
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1413
|
+
const trimmed = line.trim();
|
|
1414
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
const match = trimmed.match(/^(\d{1,2}:\d{2}(?::\d{2})?)\s+(.+)$/);
|
|
1418
|
+
if (match) {
|
|
1419
|
+
const [, time, ref] = match;
|
|
1420
|
+
if (time === undefined || ref === undefined) {
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const startMs = parseDuration(time);
|
|
1424
|
+
if (startMs === null) {
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
entries.push({ ref: ref.trim(), startMs });
|
|
1428
|
+
} else {
|
|
1429
|
+
entries.push({ ref: trimmed });
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return entries;
|
|
1433
|
+
}
|
|
1434
|
+
function parseDuration(input) {
|
|
1435
|
+
const trimmed = input.trim();
|
|
1436
|
+
if (!trimmed) {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
if (trimmed.includes(":")) {
|
|
1440
|
+
const parts = trimmed.split(":");
|
|
1441
|
+
if (parts.length !== 2 && parts.length !== 3) {
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
const nums = parts.map((part) => Number(part));
|
|
1445
|
+
if (nums.some((n) => !Number.isFinite(n) || n < 0)) {
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
if (parts.length === 3) {
|
|
1449
|
+
const [hours, minutes2, seconds2] = nums;
|
|
1450
|
+
if (hours === undefined || minutes2 === undefined || seconds2 === undefined) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
if (minutes2 >= 60 || seconds2 >= 60) {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
return Math.round((hours * 3600 + minutes2 * 60 + seconds2) * 1000);
|
|
1457
|
+
}
|
|
1458
|
+
const [minutes, seconds] = nums;
|
|
1459
|
+
if (minutes === undefined || seconds === undefined) {
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
if (seconds >= 60) {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
return Math.round((minutes * 60 + seconds) * 1000);
|
|
1466
|
+
}
|
|
1467
|
+
const value = Number(trimmed);
|
|
1468
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1469
|
+
}
|
|
1470
|
+
var init_mixtapes = __esm(() => {
|
|
1471
|
+
init_api();
|
|
1472
|
+
init_output();
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// src/commands/mixtape-mixcloud.ts
|
|
1476
|
+
var exports_mixtape_mixcloud = {};
|
|
1477
|
+
__export(exports_mixtape_mixcloud, {
|
|
1478
|
+
mixtapeDescription: () => mixtapeDescription,
|
|
1479
|
+
mixcloudSections: () => mixcloudSections,
|
|
1480
|
+
distributeMixcloud: () => distributeMixcloud,
|
|
1481
|
+
authMixcloudCommand: () => authMixcloudCommand
|
|
1482
|
+
});
|
|
1483
|
+
async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = false) {
|
|
1484
|
+
const token = await fetchMixcloudToken();
|
|
1485
|
+
const mixtape = await mixtapeGetCommand(mixtapeId);
|
|
1486
|
+
const logId = mixtape.logId;
|
|
1487
|
+
if (!logId) {
|
|
1488
|
+
throw new CliError2("mixtape_no_log_id", "The mixtape has no Log ID; mint it before distributing");
|
|
1489
|
+
}
|
|
1490
|
+
const audio = Bun.file(audioPath);
|
|
1491
|
+
if (!await audio.exists()) {
|
|
1492
|
+
throw new CliError2("audio_not_found", `Audio master not found: ${audioPath}`);
|
|
1493
|
+
}
|
|
1494
|
+
const form = new FormData;
|
|
1495
|
+
form.append("mp3", audio);
|
|
1496
|
+
form.append("name", mixtape.title);
|
|
1497
|
+
form.append("description", mixtapeDescription(mixtape.note, logId));
|
|
1498
|
+
onProgress?.("Mixcloud: fetching the cover…");
|
|
1499
|
+
const picture = await fetchCover(logId);
|
|
1500
|
+
if (picture) {
|
|
1501
|
+
form.append("picture", picture, "cover.png");
|
|
1502
|
+
}
|
|
1503
|
+
for (const [index, tag] of mixtapeTags(mixtape).entries()) {
|
|
1504
|
+
form.append(`tags-${index}-tag`, tag);
|
|
1505
|
+
}
|
|
1506
|
+
const sections = mixcloudSections(mixtape.members);
|
|
1507
|
+
for (const [index, section] of sections.entries()) {
|
|
1508
|
+
form.append(`sections-${index}-artist`, section.artist);
|
|
1509
|
+
form.append(`sections-${index}-song`, section.song);
|
|
1510
|
+
form.append(`sections-${index}-start_time`, String(section.start_time));
|
|
1511
|
+
}
|
|
1512
|
+
if (unlisted) {
|
|
1513
|
+
form.append("unlisted", "1");
|
|
1514
|
+
onProgress?.("Mixcloud: uploading UNLISTED (private).");
|
|
1329
1515
|
}
|
|
1330
|
-
const
|
|
1331
|
-
if (
|
|
1332
|
-
|
|
1516
|
+
const cuelessCount = mixtape.members.length - sections.length;
|
|
1517
|
+
if (cuelessCount > 0) {
|
|
1518
|
+
onProgress?.(`Mixcloud: ${cuelessCount} of ${mixtape.members.length} members have no cue (omitted from sections).`);
|
|
1333
1519
|
}
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
const response = await fetch(url, {
|
|
1338
|
-
body: buildCompleteXml(parts),
|
|
1339
|
-
headers: { "content-type": "application/xml" },
|
|
1520
|
+
onProgress?.("Mixcloud: uploading the master…");
|
|
1521
|
+
const uploadResponse = await fetch(`${MIXCLOUD_API}/upload/?access_token=${encodeURIComponent(token)}`, {
|
|
1522
|
+
body: form,
|
|
1340
1523
|
method: "POST"
|
|
1341
1524
|
});
|
|
1342
|
-
const
|
|
1343
|
-
if (!
|
|
1344
|
-
|
|
1525
|
+
const uploadText = await uploadResponse.text();
|
|
1526
|
+
if (!uploadResponse.ok) {
|
|
1527
|
+
throwMixcloudError(uploadResponse.status, uploadText);
|
|
1528
|
+
}
|
|
1529
|
+
const result = parseUploadResult(uploadText);
|
|
1530
|
+
if (!result.success || !result.key) {
|
|
1531
|
+
throw new CliError2("mixcloud_upload_rejected", `Mixcloud rejected the upload: ${result.message ?? uploadText.slice(0, 300)}`);
|
|
1345
1532
|
}
|
|
1533
|
+
const externalId = result.key;
|
|
1534
|
+
const url = `https://www.mixcloud.com${result.key}`;
|
|
1535
|
+
onProgress?.("Mixcloud: recording the link…");
|
|
1536
|
+
await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/mixcloud/finalize`, {
|
|
1537
|
+
externalId,
|
|
1538
|
+
url
|
|
1539
|
+
});
|
|
1540
|
+
return { url };
|
|
1346
1541
|
}
|
|
1347
|
-
async function
|
|
1348
|
-
|
|
1542
|
+
async function authMixcloudCommand() {
|
|
1543
|
+
const response = await adminApiGet("/api/admin/mixcloud/auth/start");
|
|
1544
|
+
console.log(`Open this Mixcloud authorization URL:
|
|
1545
|
+
|
|
1546
|
+
${response.authUrl}
|
|
1547
|
+
|
|
1548
|
+
After approving access, Mixcloud returns to the Fluncle admin callback and stores the access token server-side.`);
|
|
1349
1549
|
}
|
|
1350
|
-
|
|
1550
|
+
function mixtapeDescription(note, logId) {
|
|
1551
|
+
const breadcrumb = `fluncle://${logId}`;
|
|
1552
|
+
const body = (note ?? "").trim();
|
|
1553
|
+
const full = body ? `${body}
|
|
1554
|
+
|
|
1555
|
+
${breadcrumb}` : breadcrumb;
|
|
1556
|
+
if (full.length <= DESCRIPTION_MAX) {
|
|
1557
|
+
return full;
|
|
1558
|
+
}
|
|
1559
|
+
const room = DESCRIPTION_MAX - (breadcrumb.length + 2);
|
|
1560
|
+
const trimmedNote = body.slice(0, Math.max(room, 0)).trimEnd();
|
|
1561
|
+
return trimmedNote ? `${trimmedNote}
|
|
1562
|
+
|
|
1563
|
+
${breadcrumb}` : breadcrumb;
|
|
1564
|
+
}
|
|
1565
|
+
function mixcloudSections(members) {
|
|
1566
|
+
return members.filter((member) => typeof member.startMs === "number").sort((a, b) => a.startMs - b.startMs).map((member) => ({
|
|
1567
|
+
artist: member.artists.join(", "),
|
|
1568
|
+
song: member.title,
|
|
1569
|
+
start_time: Math.floor(member.startMs / 1000)
|
|
1570
|
+
}));
|
|
1571
|
+
}
|
|
1572
|
+
function mixtapeTags(_mixtape) {
|
|
1573
|
+
return ["Drum & Bass", "Fluncle"];
|
|
1574
|
+
}
|
|
1575
|
+
async function fetchMixcloudToken() {
|
|
1351
1576
|
try {
|
|
1352
|
-
const
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1577
|
+
const response = await adminApiPost("/api/admin/mixcloud/token");
|
|
1578
|
+
return response.accessToken;
|
|
1579
|
+
} catch {
|
|
1580
|
+
throw new CliError2("mixcloud_not_connected", "Mixcloud is not connected. Run `fluncle admin auth mixcloud` to authorize it.");
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
async function fetchCover(logId) {
|
|
1584
|
+
for (const size of ["square", "og"]) {
|
|
1585
|
+
const response = await fetch(`${COVER_BASE}/${encodeURIComponent(logId)}?size=${size}`);
|
|
1586
|
+
if (!response.ok) {
|
|
1587
|
+
continue;
|
|
1356
1588
|
}
|
|
1589
|
+
const blob = await response.blob();
|
|
1590
|
+
if (blob.size <= PICTURE_MAX_BYTES) {
|
|
1591
|
+
return blob;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
function parseUploadResult(body) {
|
|
1597
|
+
try {
|
|
1598
|
+
const data = JSON.parse(body);
|
|
1599
|
+
return {
|
|
1600
|
+
key: data.result?.key,
|
|
1601
|
+
message: data.result?.message,
|
|
1602
|
+
success: data.result?.success === true
|
|
1603
|
+
};
|
|
1357
1604
|
} catch {
|
|
1358
|
-
|
|
1605
|
+
return { message: body.slice(0, 300), success: false };
|
|
1359
1606
|
}
|
|
1360
1607
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
stdout: "ignore"
|
|
1365
|
-
});
|
|
1366
|
-
await proc.exited;
|
|
1367
|
-
if (proc.exitCode !== 0) {
|
|
1368
|
-
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
1369
|
-
throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
|
|
1608
|
+
function throwMixcloudError(status, body) {
|
|
1609
|
+
if (body.includes("An invalid access token was provided")) {
|
|
1610
|
+
throw new CliError2("mixcloud_invalid_token", "Mixcloud rejected the access token. Re-auth with `fluncle admin auth mixcloud`.");
|
|
1370
1611
|
}
|
|
1612
|
+
throw new CliError2("mixcloud_request_failed", `Mixcloud responded ${status}${body ? `: ${body.slice(0, 300)}` : ""}`);
|
|
1371
1613
|
}
|
|
1372
|
-
var
|
|
1373
|
-
var
|
|
1614
|
+
var MIXCLOUD_API = "https://api.mixcloud.com", DESCRIPTION_MAX = 1000, PICTURE_MAX_BYTES, COVER_BASE = "https://www.fluncle.com/api/mixtape-cover";
|
|
1615
|
+
var init_mixtape_mixcloud = __esm(() => {
|
|
1374
1616
|
init_api();
|
|
1375
1617
|
init_output();
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
crf: 20,
|
|
1379
|
-
gopSeconds: 2,
|
|
1380
|
-
height: 1080
|
|
1381
|
-
};
|
|
1382
|
-
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1383
|
-
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1618
|
+
init_mixtapes();
|
|
1619
|
+
PICTURE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1384
1620
|
});
|
|
1385
1621
|
|
|
1386
1622
|
// src/commands/mixtapes.ts
|
|
1387
|
-
var
|
|
1388
|
-
__export(
|
|
1389
|
-
mixtapesCommand: () =>
|
|
1390
|
-
mixtapeUpdateCommand: () =>
|
|
1391
|
-
mixtapePublishCommand: () =>
|
|
1392
|
-
mixtapeMembersCommand: () =>
|
|
1623
|
+
var exports_mixtapes2 = {};
|
|
1624
|
+
__export(exports_mixtapes2, {
|
|
1625
|
+
mixtapesCommand: () => mixtapesCommand2,
|
|
1626
|
+
mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
|
|
1627
|
+
mixtapePublishCommand: () => mixtapePublishCommand2,
|
|
1628
|
+
mixtapeMembersCommand: () => mixtapeMembersCommand2,
|
|
1393
1629
|
mixtapeListCommand: () => mixtapeListCommand2,
|
|
1394
1630
|
mixtapeGetCommand: () => mixtapeGetCommand2,
|
|
1395
|
-
mixtapeDistributeCommand: () =>
|
|
1396
|
-
mixtapeDeleteCommand: () =>
|
|
1397
|
-
mixtapeCreateCommand: () =>
|
|
1631
|
+
mixtapeDistributeCommand: () => mixtapeDistributeCommand2,
|
|
1632
|
+
mixtapeDeleteCommand: () => mixtapeDeleteCommand2,
|
|
1633
|
+
mixtapeCreateCommand: () => mixtapeCreateCommand2
|
|
1398
1634
|
});
|
|
1399
|
-
import { existsSync as
|
|
1400
|
-
async function
|
|
1635
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
1636
|
+
async function mixtapesCommand2() {
|
|
1401
1637
|
const response = await publicApiGet("/api/mixtapes");
|
|
1402
1638
|
return response.mixtapes;
|
|
1403
1639
|
}
|
|
1404
|
-
async function
|
|
1405
|
-
return adminApiPost("/api/admin/mixtapes",
|
|
1640
|
+
async function mixtapeCreateCommand2(options) {
|
|
1641
|
+
return adminApiPost("/api/admin/mixtapes", buildBody2(options));
|
|
1406
1642
|
}
|
|
1407
|
-
async function
|
|
1408
|
-
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`,
|
|
1643
|
+
async function mixtapeUpdateCommand2(id, options) {
|
|
1644
|
+
return adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(id)}`, buildBody2(options));
|
|
1409
1645
|
}
|
|
1410
|
-
async function
|
|
1646
|
+
async function mixtapeMembersCommand2(id, refs, options) {
|
|
1411
1647
|
const members = refs.map((ref) => ({ ref }));
|
|
1412
1648
|
if (options.from) {
|
|
1413
|
-
members.push(...
|
|
1649
|
+
members.push(...parseCueFile2(options.from));
|
|
1414
1650
|
}
|
|
1415
1651
|
return adminApiPut(`/api/admin/mixtapes/${encodeURIComponent(id)}/members`, { members });
|
|
1416
1652
|
}
|
|
1417
|
-
async function
|
|
1653
|
+
async function mixtapePublishCommand2(id) {
|
|
1418
1654
|
return adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(id)}/publish`);
|
|
1419
1655
|
}
|
|
1420
|
-
async function
|
|
1656
|
+
async function mixtapeDeleteCommand2(id) {
|
|
1421
1657
|
return adminApiDelete(`/api/admin/mixtapes/${encodeURIComponent(id)}`);
|
|
1422
1658
|
}
|
|
1423
1659
|
async function mixtapeListCommand2() {
|
|
@@ -1432,7 +1668,7 @@ async function mixtapeGetCommand2(idOrLogId) {
|
|
|
1432
1668
|
}
|
|
1433
1669
|
return match;
|
|
1434
1670
|
}
|
|
1435
|
-
async function
|
|
1671
|
+
async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () => {}) {
|
|
1436
1672
|
const mixtape = await mixtapeGetCommand2(idOrLogId);
|
|
1437
1673
|
if (!mixtape.id) {
|
|
1438
1674
|
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
@@ -1454,15 +1690,15 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1454
1690
|
let logId = mixtape.logId;
|
|
1455
1691
|
if (!mixtape.durationMs) {
|
|
1456
1692
|
const source = options.audio ?? options.video;
|
|
1457
|
-
const durationMs = source ? await
|
|
1693
|
+
const durationMs = source ? await probeDurationMs2(source) : undefined;
|
|
1458
1694
|
if (durationMs) {
|
|
1459
|
-
await
|
|
1695
|
+
await mixtapeUpdateCommand2(mixtapeId, { durationMs: String(durationMs), json: false });
|
|
1460
1696
|
onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
|
|
1461
1697
|
}
|
|
1462
1698
|
}
|
|
1463
1699
|
if (mixtape.status === "draft") {
|
|
1464
1700
|
onProgress("Minting the coordinate…");
|
|
1465
|
-
const published = await
|
|
1701
|
+
const published = await mixtapePublishCommand2(mixtapeId);
|
|
1466
1702
|
logId = published.mixtape.logId;
|
|
1467
1703
|
onProgress(`Minted ${logId}.`);
|
|
1468
1704
|
} else if (mixtape.status === "published") {
|
|
@@ -1506,7 +1742,7 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1506
1742
|
}
|
|
1507
1743
|
return { logId, mixtapeId, results };
|
|
1508
1744
|
}
|
|
1509
|
-
async function
|
|
1745
|
+
async function probeDurationMs2(filePath) {
|
|
1510
1746
|
try {
|
|
1511
1747
|
const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
|
|
1512
1748
|
const out = await new Response(proc.stdout).text();
|
|
@@ -1517,7 +1753,7 @@ async function probeDurationMs(filePath) {
|
|
|
1517
1753
|
return;
|
|
1518
1754
|
}
|
|
1519
1755
|
}
|
|
1520
|
-
function
|
|
1756
|
+
function buildBody2(options) {
|
|
1521
1757
|
const body = {};
|
|
1522
1758
|
if (options.note !== undefined) {
|
|
1523
1759
|
body.note = options.note;
|
|
@@ -1529,7 +1765,7 @@ function buildBody(options) {
|
|
|
1529
1765
|
body.soundcloudUrl = options.soundcloudUrl;
|
|
1530
1766
|
}
|
|
1531
1767
|
if (options.durationMs !== undefined) {
|
|
1532
|
-
const parsed =
|
|
1768
|
+
const parsed = parseDuration2(options.durationMs);
|
|
1533
1769
|
if (parsed === null) {
|
|
1534
1770
|
throw new CliError2("invalid_duration", "Duration must be mm:ss or h:mm:ss, or a millisecond count");
|
|
1535
1771
|
}
|
|
@@ -1537,11 +1773,11 @@ function buildBody(options) {
|
|
|
1537
1773
|
}
|
|
1538
1774
|
return body;
|
|
1539
1775
|
}
|
|
1540
|
-
function
|
|
1541
|
-
if (!
|
|
1776
|
+
function parseCueFile2(filePath) {
|
|
1777
|
+
if (!existsSync3(filePath)) {
|
|
1542
1778
|
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1543
1779
|
}
|
|
1544
|
-
const text =
|
|
1780
|
+
const text = readFileSync3(filePath, "utf-8");
|
|
1545
1781
|
const trimmed = text.trim();
|
|
1546
1782
|
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
1547
1783
|
try {
|
|
@@ -1570,9 +1806,9 @@ function parseCueFile(filePath) {
|
|
|
1570
1806
|
throw new CliError2("invalid_cue_json", `Cue JSON parse failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1571
1807
|
}
|
|
1572
1808
|
}
|
|
1573
|
-
return
|
|
1809
|
+
return parseCueSheet2(text);
|
|
1574
1810
|
}
|
|
1575
|
-
function
|
|
1811
|
+
function parseCueSheet2(text) {
|
|
1576
1812
|
const entries = [];
|
|
1577
1813
|
for (const line of text.split(/\r?\n/)) {
|
|
1578
1814
|
const trimmed = line.trim();
|
|
@@ -1585,7 +1821,7 @@ function parseCueSheet(text) {
|
|
|
1585
1821
|
if (time === undefined || ref === undefined) {
|
|
1586
1822
|
continue;
|
|
1587
1823
|
}
|
|
1588
|
-
const startMs =
|
|
1824
|
+
const startMs = parseDuration2(time);
|
|
1589
1825
|
if (startMs === null) {
|
|
1590
1826
|
continue;
|
|
1591
1827
|
}
|
|
@@ -1596,7 +1832,7 @@ function parseCueSheet(text) {
|
|
|
1596
1832
|
}
|
|
1597
1833
|
return entries;
|
|
1598
1834
|
}
|
|
1599
|
-
function
|
|
1835
|
+
function parseDuration2(input) {
|
|
1600
1836
|
const trimmed = input.trim();
|
|
1601
1837
|
if (!trimmed) {
|
|
1602
1838
|
return null;
|
|
@@ -2323,7 +2559,7 @@ function parseVersion2(version) {
|
|
|
2323
2559
|
var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
|
|
2324
2560
|
var init_version2 = __esm(() => {
|
|
2325
2561
|
init_output();
|
|
2326
|
-
currentVersion2 = "0.
|
|
2562
|
+
currentVersion2 = "0.70.0".trim() ? "0.70.0".trim() : "0.1.0";
|
|
2327
2563
|
});
|
|
2328
2564
|
|
|
2329
2565
|
// ../../packages/registry/src/index.ts
|
|
@@ -3772,6 +4008,186 @@ var init_mixtape_youtube2 = __esm(() => {
|
|
|
3772
4008
|
init_output();
|
|
3773
4009
|
});
|
|
3774
4010
|
|
|
4011
|
+
// src/commands/clips.ts
|
|
4012
|
+
var exports_clips = {};
|
|
4013
|
+
__export(exports_clips, {
|
|
4014
|
+
setVideoUrl: () => setVideoUrl,
|
|
4015
|
+
escapeDrawtextValue: () => escapeDrawtextValue,
|
|
4016
|
+
clipsListCommand: () => clipsListCommand,
|
|
4017
|
+
clipFootageKey: () => clipFootageKey,
|
|
4018
|
+
clipCutVideoFilter: () => clipCutVideoFilter,
|
|
4019
|
+
clipCutFfmpegArgs: () => clipCutFfmpegArgs,
|
|
4020
|
+
clipCutCommand: () => clipCutCommand,
|
|
4021
|
+
MAX_CLIP_BYTES: () => MAX_CLIP_BYTES,
|
|
4022
|
+
CLIP_WIDTH: () => CLIP_WIDTH,
|
|
4023
|
+
CLIP_MAXRATE: () => CLIP_MAXRATE,
|
|
4024
|
+
CLIP_HEIGHT: () => CLIP_HEIGHT,
|
|
4025
|
+
CLIP_CRF: () => CLIP_CRF,
|
|
4026
|
+
CLIP_BUFSIZE: () => CLIP_BUFSIZE,
|
|
4027
|
+
CLIP_AUDIO_BITRATE: () => CLIP_AUDIO_BITRATE
|
|
4028
|
+
});
|
|
4029
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
4030
|
+
import { rmSync as rmSync3, statSync as statSync4 } from "node:fs";
|
|
4031
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
4032
|
+
import { join as join5 } from "node:path";
|
|
4033
|
+
function clipFootageKey(clipId) {
|
|
4034
|
+
return `${clipId}/footage.mp4`;
|
|
4035
|
+
}
|
|
4036
|
+
function setVideoUrl(logId) {
|
|
4037
|
+
return `${FOUND_BASE3}/${encodeURIComponent(logId)}/set.mp4`;
|
|
4038
|
+
}
|
|
4039
|
+
function escapeDrawtextValue(text) {
|
|
4040
|
+
return text.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/'/g, "'\\''");
|
|
4041
|
+
}
|
|
4042
|
+
function clipCutVideoFilter(options) {
|
|
4043
|
+
const xOffset = Math.max(0, Math.round(options.xOffset));
|
|
4044
|
+
const crop = `crop=ih*9/16:ih:${xOffset}:0,scale=${CLIP_WIDTH}:${CLIP_HEIGHT}`;
|
|
4045
|
+
const font = options.fontFile ? `:fontfile='${options.fontFile}'` : "";
|
|
4046
|
+
const title = escapeDrawtextValue(options.title);
|
|
4047
|
+
const coordinate2 = escapeDrawtextValue(`fluncle://${options.logId}`);
|
|
4048
|
+
const titleDraw = `drawtext=text='${title}'${font}:fontcolor=white:fontsize=46:` + `x=56:y=h-208:box=1:boxcolor=black@0.55:boxborderw=18`;
|
|
4049
|
+
const coordinateDraw = `drawtext=text='${coordinate2}'${font}:fontcolor=0xF4EAD7:fontsize=30:` + `x=56:y=h-132:box=1:boxcolor=black@0.55:boxborderw=12`;
|
|
4050
|
+
return `${crop},${titleDraw},${coordinateDraw}`;
|
|
4051
|
+
}
|
|
4052
|
+
function clipCutFfmpegArgs(options) {
|
|
4053
|
+
const inSeconds = (options.inMs / 1000).toFixed(3);
|
|
4054
|
+
const durationSeconds = ((options.outMs - options.inMs) / 1000).toFixed(3);
|
|
4055
|
+
const filter = clipCutVideoFilter(options);
|
|
4056
|
+
return [
|
|
4057
|
+
"-y",
|
|
4058
|
+
"-ss",
|
|
4059
|
+
inSeconds,
|
|
4060
|
+
"-i",
|
|
4061
|
+
options.setUrl,
|
|
4062
|
+
"-t",
|
|
4063
|
+
durationSeconds,
|
|
4064
|
+
"-vf",
|
|
4065
|
+
filter,
|
|
4066
|
+
"-c:v",
|
|
4067
|
+
"libx264",
|
|
4068
|
+
"-preset",
|
|
4069
|
+
"veryfast",
|
|
4070
|
+
"-crf",
|
|
4071
|
+
String(CLIP_CRF),
|
|
4072
|
+
"-maxrate",
|
|
4073
|
+
CLIP_MAXRATE,
|
|
4074
|
+
"-bufsize",
|
|
4075
|
+
CLIP_BUFSIZE,
|
|
4076
|
+
"-pix_fmt",
|
|
4077
|
+
"yuv420p",
|
|
4078
|
+
"-c:a",
|
|
4079
|
+
"aac",
|
|
4080
|
+
"-b:a",
|
|
4081
|
+
CLIP_AUDIO_BITRATE,
|
|
4082
|
+
"-movflags",
|
|
4083
|
+
"+faststart",
|
|
4084
|
+
options.outputPath
|
|
4085
|
+
];
|
|
4086
|
+
}
|
|
4087
|
+
async function clipsListCommand(filter = {}) {
|
|
4088
|
+
const params = new URLSearchParams;
|
|
4089
|
+
if (filter.mixtapeId) {
|
|
4090
|
+
params.set("mixtapeId", filter.mixtapeId);
|
|
4091
|
+
}
|
|
4092
|
+
if (filter.status) {
|
|
4093
|
+
params.set("status", filter.status);
|
|
4094
|
+
}
|
|
4095
|
+
const query = params.toString();
|
|
4096
|
+
const response = await adminApiGet(`/api/admin/clips${query ? `?${query}` : ""}`);
|
|
4097
|
+
return response.clips;
|
|
4098
|
+
}
|
|
4099
|
+
async function clipCutCommand(clipId, onProgress = () => {}) {
|
|
4100
|
+
const clips = await clipsListCommand();
|
|
4101
|
+
const clip = clips.find((candidate) => candidate.id === clipId);
|
|
4102
|
+
if (!clip) {
|
|
4103
|
+
throw new CliError2("clip_not_found", `No clip with id ${clipId}`);
|
|
4104
|
+
}
|
|
4105
|
+
const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
|
|
4106
|
+
const mixtape = await mixtapeGetCommand3(clip.mixtapeId);
|
|
4107
|
+
if (!mixtape.logId) {
|
|
4108
|
+
throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
|
|
4109
|
+
}
|
|
4110
|
+
if (!mixtape.setVideoAt) {
|
|
4111
|
+
throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
|
|
4112
|
+
}
|
|
4113
|
+
await assertFfmpeg2();
|
|
4114
|
+
const setUrl = setVideoUrl(mixtape.logId);
|
|
4115
|
+
const outputPath = join5(tmpdir2(), `fluncle-clip-${randomUUID2()}.mp4`);
|
|
4116
|
+
try {
|
|
4117
|
+
onProgress(`Clip ${clipId}: cutting [${clip.inMs}–${clip.outMs}ms] from ${mixtape.logId}…`);
|
|
4118
|
+
await runClipCut({
|
|
4119
|
+
fontFile: process.env.CLIP_FONT_FILE,
|
|
4120
|
+
inMs: clip.inMs,
|
|
4121
|
+
logId: mixtape.logId,
|
|
4122
|
+
outMs: clip.outMs,
|
|
4123
|
+
outputPath,
|
|
4124
|
+
setUrl,
|
|
4125
|
+
title: mixtape.title,
|
|
4126
|
+
xOffset: clip.xOffset
|
|
4127
|
+
});
|
|
4128
|
+
const sizeBytes = statSync4(outputPath).size;
|
|
4129
|
+
if (sizeBytes > MAX_CLIP_BYTES) {
|
|
4130
|
+
throw new CliError2("clip_too_large", `The cut is ${(sizeBytes / 1e6).toFixed(1)} MB (> 100 MB Cloudflare MT ceiling) — shorten the window or lower the bitrate cap`);
|
|
4131
|
+
}
|
|
4132
|
+
onProgress(`Clip ${clipId}: uploading ${(sizeBytes / 1e6).toFixed(1)} MB…`);
|
|
4133
|
+
const presign = await adminApiPost(`/api/admin/clips/${encodeURIComponent(clipId)}/cut/presign`, { contentType: "video/mp4" });
|
|
4134
|
+
await putClip(presign.url, presign.contentType, outputPath);
|
|
4135
|
+
await adminApiPost(`/api/admin/clips/${encodeURIComponent(clipId)}/cut/finalize`);
|
|
4136
|
+
onProgress(`Clip ${clipId}: done → ${FOUND_BASE3}/${presign.key}`);
|
|
4137
|
+
return {
|
|
4138
|
+
clipId,
|
|
4139
|
+
key: presign.key,
|
|
4140
|
+
logId: mixtape.logId,
|
|
4141
|
+
sizeBytes,
|
|
4142
|
+
url: `${FOUND_BASE3}/${presign.key}`
|
|
4143
|
+
};
|
|
4144
|
+
} finally {
|
|
4145
|
+
rmSync3(outputPath, { force: true });
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
async function putClip(url, contentType, path2) {
|
|
4149
|
+
const response = await fetch(url, {
|
|
4150
|
+
body: Bun.file(path2),
|
|
4151
|
+
headers: { "content-type": contentType },
|
|
4152
|
+
method: "PUT"
|
|
4153
|
+
});
|
|
4154
|
+
if (!response.ok) {
|
|
4155
|
+
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
4156
|
+
throw new CliError2("r2_put_failed", `R2 rejected the clip upload (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
async function assertFfmpeg2() {
|
|
4160
|
+
try {
|
|
4161
|
+
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
4162
|
+
await proc.exited;
|
|
4163
|
+
if (proc.exitCode !== 0) {
|
|
4164
|
+
throw new Error("ffmpeg -version exited non-zero");
|
|
4165
|
+
}
|
|
4166
|
+
} catch {
|
|
4167
|
+
throw new CliError2("ffmpeg_missing", "The clip cut needs ffmpeg. Install it on the box (apt-get install -y ffmpeg).");
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
async function runClipCut(options) {
|
|
4171
|
+
if (!options.setUrl) {
|
|
4172
|
+
throw new CliError2("missing_set_url", "The clip cut needs the set rendition URL");
|
|
4173
|
+
}
|
|
4174
|
+
const proc = Bun.spawn(["ffmpeg", ...clipCutFfmpegArgs(options)], {
|
|
4175
|
+
stderr: "pipe",
|
|
4176
|
+
stdout: "ignore"
|
|
4177
|
+
});
|
|
4178
|
+
await proc.exited;
|
|
4179
|
+
if (proc.exitCode !== 0) {
|
|
4180
|
+
const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
|
|
4181
|
+
throw new CliError2("ffmpeg_failed", `ffmpeg failed to cut the clip${detail ? `: ${detail}` : ""}`);
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
var FOUND_BASE3 = "https://found.fluncle.com", CLIP_WIDTH = 1080, CLIP_HEIGHT = 1920, CLIP_CRF = 21, CLIP_MAXRATE = "10M", CLIP_BUFSIZE = "20M", CLIP_AUDIO_BITRATE = "192k", MAX_CLIP_BYTES;
|
|
4185
|
+
var init_clips = __esm(() => {
|
|
4186
|
+
init_api();
|
|
4187
|
+
init_output();
|
|
4188
|
+
MAX_CLIP_BYTES = 100 * 1024 * 1024;
|
|
4189
|
+
});
|
|
4190
|
+
|
|
3775
4191
|
// src/commands/newsletter.ts
|
|
3776
4192
|
var exports_newsletter = {};
|
|
3777
4193
|
__export(exports_newsletter, {
|
|
@@ -3781,8 +4197,8 @@ __export(exports_newsletter, {
|
|
|
3781
4197
|
newsletterDraftCommand: () => newsletterDraftCommand,
|
|
3782
4198
|
newsletterDeleteCommand: () => newsletterDeleteCommand
|
|
3783
4199
|
});
|
|
3784
|
-
import { existsSync as
|
|
3785
|
-
function
|
|
4200
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
|
|
4201
|
+
function buildBody3(options, { requireContent }) {
|
|
3786
4202
|
const body = {};
|
|
3787
4203
|
if (options.contentFile !== undefined) {
|
|
3788
4204
|
body.contentJson = readContentFile(options.contentFile);
|
|
@@ -3801,10 +4217,10 @@ function buildBody2(options, { requireContent }) {
|
|
|
3801
4217
|
return body;
|
|
3802
4218
|
}
|
|
3803
4219
|
function readContentFile(filePath) {
|
|
3804
|
-
if (!
|
|
4220
|
+
if (!existsSync4(filePath)) {
|
|
3805
4221
|
throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
|
|
3806
4222
|
}
|
|
3807
|
-
const text =
|
|
4223
|
+
const text = readFileSync4(filePath, "utf-8");
|
|
3808
4224
|
try {
|
|
3809
4225
|
return JSON.parse(text);
|
|
3810
4226
|
} catch (error) {
|
|
@@ -3812,10 +4228,10 @@ function readContentFile(filePath) {
|
|
|
3812
4228
|
}
|
|
3813
4229
|
}
|
|
3814
4230
|
async function newsletterDraftCommand(options) {
|
|
3815
|
-
return adminApiPost("/api/admin/newsletter/editions",
|
|
4231
|
+
return adminApiPost("/api/admin/newsletter/editions", buildBody3(options, { requireContent: true }));
|
|
3816
4232
|
}
|
|
3817
4233
|
async function newsletterUpdateCommand(id, options) {
|
|
3818
|
-
return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`,
|
|
4234
|
+
return adminApiPatch(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`, buildBody3(options, { requireContent: false }));
|
|
3819
4235
|
}
|
|
3820
4236
|
async function newsletterSendCommand(id) {
|
|
3821
4237
|
return adminApiPost(`/api/admin/newsletter/editions/${encodeURIComponent(id)}/send`);
|
|
@@ -4441,7 +4857,7 @@ var COORD_FALLBACK2 = "—";
|
|
|
4441
4857
|
var init_format2 = () => {};
|
|
4442
4858
|
|
|
4443
4859
|
// src/cli.ts
|
|
4444
|
-
import { existsSync as
|
|
4860
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
4445
4861
|
import path2 from "path";
|
|
4446
4862
|
|
|
4447
4863
|
// ../../node_modules/commander/lib/error.js
|
|
@@ -6603,8 +7019,8 @@ function addListenCommands(program2) {
|
|
|
6603
7019
|
await runRecent(options, recentCommand3);
|
|
6604
7020
|
});
|
|
6605
7021
|
program2.command("mixtapes").description("Fluncle's checkpoint sets").option("--json", "Print JSON", false).action(async (options) => {
|
|
6606
|
-
const { mixtapesCommand:
|
|
6607
|
-
await runMixtapes(options,
|
|
7022
|
+
const { mixtapesCommand: mixtapesCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7023
|
+
await runMixtapes(options, mixtapesCommand3);
|
|
6608
7024
|
});
|
|
6609
7025
|
program2.command("open").description("Pick a track, open it in Spotify").argument("[target]").option("--app", "Open in the native app", false).option("--browser", "Open in the browser", false).option("--limit <limit>", "Number of recent tracks to choose from", "20").allowExcessArguments().action(async (target, options) => {
|
|
6610
7026
|
const openCommands = await Promise.resolve().then(() => (init_open(), exports_open));
|
|
@@ -6774,41 +7190,53 @@ function addAdminCommands(program2) {
|
|
|
6774
7190
|
adminMixtapes.outputHelp();
|
|
6775
7191
|
});
|
|
6776
7192
|
adminMixtapes.command("create").description("Log a new mixtape draft").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (options) => {
|
|
6777
|
-
const { mixtapeCreateCommand:
|
|
6778
|
-
await runMixtapeCreate(options,
|
|
7193
|
+
const { mixtapeCreateCommand: mixtapeCreateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7194
|
+
await runMixtapeCreate(options, mixtapeCreateCommand3);
|
|
6779
7195
|
});
|
|
6780
7196
|
adminMixtapes.command("update").description("Update a mixtape's fields").argument("[id]").option("--duration-ms <duration>", "Duration (mm:ss, h:mm:ss, or ms)").option("--json", "Print JSON", false).option("--note <text>", "Operator note").option("--recorded-at <date>", "Recorded date (ISO)").option("--soundcloud-url <url>", "SoundCloud URL (manual; YouTube + Mixcloud come from distribute)").allowExcessArguments().action(async (id, options) => {
|
|
6781
|
-
const { mixtapeUpdateCommand:
|
|
6782
|
-
await runMixtapeUpdate(id, options,
|
|
7197
|
+
const { mixtapeUpdateCommand: mixtapeUpdateCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7198
|
+
await runMixtapeUpdate(id, options, mixtapeUpdateCommand3);
|
|
6783
7199
|
});
|
|
6784
7200
|
adminMixtapes.command("members").description("Set a mixtape's tracklist (refs and/or a cue-sheet file)").argument("[id]").argument("[refs...]").option("--from <file>", "Cue-sheet or JSON file with members").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, refs, options) => {
|
|
6785
|
-
const { mixtapeMembersCommand:
|
|
6786
|
-
await runMixtapeMembers(id, refs, options,
|
|
7201
|
+
const { mixtapeMembersCommand: mixtapeMembersCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7202
|
+
await runMixtapeMembers(id, refs, options, mixtapeMembersCommand3);
|
|
6787
7203
|
});
|
|
6788
7204
|
adminMixtapes.command("publish").description("Publish a mixtape draft").argument("[id]").option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
6789
|
-
const { mixtapePublishCommand:
|
|
6790
|
-
await runMixtapePublish(id, options,
|
|
7205
|
+
const { mixtapePublishCommand: mixtapePublishCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7206
|
+
await runMixtapePublish(id, options, mixtapePublishCommand3);
|
|
6791
7207
|
});
|
|
6792
7208
|
adminMixtapes.command("delete").description("Discard a mixtape draft (published mixtapes can't be deleted)").argument("[id]").option("--json", "Print JSON", false).option("--yes", "Skip confirmation", false).allowExcessArguments().action(async (id, options) => {
|
|
6793
|
-
const { mixtapeDeleteCommand:
|
|
6794
|
-
await runMixtapeDelete(id, options,
|
|
7209
|
+
const { mixtapeDeleteCommand: mixtapeDeleteCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7210
|
+
await runMixtapeDelete(id, options, mixtapeDeleteCommand3);
|
|
6795
7211
|
});
|
|
6796
7212
|
adminMixtapes.command("list").description("List all mixtapes (including drafts)").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
|
|
6797
|
-
const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(),
|
|
7213
|
+
const { mixtapeListCommand: mixtapeListCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
6798
7214
|
await runMixtapeList(options, mixtapeListCommand3);
|
|
6799
7215
|
});
|
|
6800
7216
|
adminMixtapes.command("get").description("Show one mixtape by id or log id").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
|
|
6801
|
-
const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(),
|
|
7217
|
+
const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
6802
7218
|
await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
|
|
6803
7219
|
});
|
|
6804
7220
|
adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--set-video", "Also stage a 1080p set-video rendition (from --video) to R2 + flip setVideoAt for the /log player").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
|
|
6805
|
-
const { mixtapeDistributeCommand:
|
|
6806
|
-
await runMixtapeDistribute(idOrLogId, options,
|
|
7221
|
+
const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes2));
|
|
7222
|
+
await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
|
|
6807
7223
|
});
|
|
6808
7224
|
adminMixtapes.command("publish-youtube").description("Flip a distributed mixtape's YouTube video from unlisted to public").argument("[idOrLogId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
|
|
6809
7225
|
const { publishYoutubeCommand: publishYoutubeCommand3 } = await Promise.resolve().then(() => (init_mixtape_youtube2(), exports_mixtape_youtube2));
|
|
6810
7226
|
await runMixtapePublishYoutube(idOrLogId, options, publishYoutubeCommand3);
|
|
6811
7227
|
});
|
|
7228
|
+
const adminClips = configureCommand(admin.command("clips").description("Mixtape clip (Fluncle Studio) commands"));
|
|
7229
|
+
adminClips.action(() => {
|
|
7230
|
+
adminClips.outputHelp();
|
|
7231
|
+
});
|
|
7232
|
+
adminClips.command("list").description("List clips (filter by --status pending|done and/or --mixtape <id>)").option("--status <status>", "Filter by cut status (pending|done)").option("--mixtape <id>", "Filter by mixtape id").option("--json", "Print JSON", false).allowExcessArguments().action(async (options) => {
|
|
7233
|
+
const { clipsListCommand: clipsListCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
|
|
7234
|
+
await runClipsList(options, clipsListCommand2);
|
|
7235
|
+
});
|
|
7236
|
+
adminClips.command("cut").description("Cut one clip's framed 9:16 footage from its set rendition, then ship it").argument("[clipId]").option("--json", "Print JSON", false).allowExcessArguments().action(async (clipId, options) => {
|
|
7237
|
+
const { clipCutCommand: clipCutCommand2 } = await Promise.resolve().then(() => (init_clips(), exports_clips));
|
|
7238
|
+
await runClipsCut(clipId, options, clipCutCommand2);
|
|
7239
|
+
});
|
|
6812
7240
|
const adminNewsletter = configureCommand(admin.command("newsletter").description("Newsletter edition commands"));
|
|
6813
7241
|
adminNewsletter.action(() => {
|
|
6814
7242
|
adminNewsletter.outputHelp();
|
|
@@ -6912,7 +7340,7 @@ async function runTrackPreviewArchive(idOrLogId, options, previewArchiveUploadCo
|
|
|
6912
7340
|
console.log(` mime: ${result.mime}`);
|
|
6913
7341
|
}
|
|
6914
7342
|
async function runTrackObserve(idOrLogId, options, trackObserveCommand2) {
|
|
6915
|
-
const script = options.scriptFile ?
|
|
7343
|
+
const script = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
|
|
6916
7344
|
if (!idOrLogId || !script || !script.trim()) {
|
|
6917
7345
|
throw new Error("Usage: fluncle admin tracks observe <track_id|log_id> (--script <text> | --script-file <file>) [--voice-id <id>] [--duration-ms <ms>] [--context-note <text>] [--json]");
|
|
6918
7346
|
}
|
|
@@ -6968,7 +7396,7 @@ async function runTrackContext(idOrLogId, options, trackContextCommand2) {
|
|
|
6968
7396
|
}
|
|
6969
7397
|
}
|
|
6970
7398
|
async function runTrackNote(idOrLogId, options, trackNoteCommand2) {
|
|
6971
|
-
const note = options.scriptFile ?
|
|
7399
|
+
const note = options.scriptFile ? readFileSync5(options.scriptFile, "utf8") : options.script;
|
|
6972
7400
|
if (!idOrLogId || !note || !note.trim()) {
|
|
6973
7401
|
throw new Error("Usage: fluncle admin tracks note <track_id|log_id> (--script <text> | --script-file <file>) [--json]");
|
|
6974
7402
|
}
|
|
@@ -7114,7 +7542,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
|
|
|
7114
7542
|
return;
|
|
7115
7543
|
}
|
|
7116
7544
|
const candidate = path2.join(dir, name);
|
|
7117
|
-
return
|
|
7545
|
+
return existsSync5(candidate) ? candidate : undefined;
|
|
7118
7546
|
};
|
|
7119
7547
|
const resolveFile = (explicit, name) => {
|
|
7120
7548
|
if (explicit) {
|
|
@@ -7286,56 +7714,56 @@ async function runTrackPurgeVideo(idOrLogId, options, trackPurgeVideoCommand2) {
|
|
|
7286
7714
|
}
|
|
7287
7715
|
console.log(result.noVideo ? `${result.logId} has no video \u2014 nothing to purge.` : `Purging the stale renditions for ${result.logId} from the edge. The next play picks up the fresh render.`);
|
|
7288
7716
|
}
|
|
7289
|
-
async function runMixtapeCreate(options,
|
|
7290
|
-
const result = await
|
|
7717
|
+
async function runMixtapeCreate(options, mixtapeCreateCommand3) {
|
|
7718
|
+
const result = await mixtapeCreateCommand3(options);
|
|
7291
7719
|
if (options.json) {
|
|
7292
7720
|
printJson(result);
|
|
7293
7721
|
return;
|
|
7294
7722
|
}
|
|
7295
7723
|
console.log(`Logged draft ${result.mixtape.id}. It stays a draft until you publish it.`);
|
|
7296
7724
|
}
|
|
7297
|
-
async function runMixtapeUpdate(id, options,
|
|
7725
|
+
async function runMixtapeUpdate(id, options, mixtapeUpdateCommand3) {
|
|
7298
7726
|
if (!id) {
|
|
7299
7727
|
throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes update <id>");
|
|
7300
7728
|
}
|
|
7301
|
-
const result = await
|
|
7729
|
+
const result = await mixtapeUpdateCommand3(id, options);
|
|
7302
7730
|
if (options.json) {
|
|
7303
7731
|
printJson(result);
|
|
7304
7732
|
return;
|
|
7305
7733
|
}
|
|
7306
7734
|
console.log(`Saved ${result.mixtape.id}.`);
|
|
7307
7735
|
}
|
|
7308
|
-
async function runMixtapeMembers(id, refs, options,
|
|
7736
|
+
async function runMixtapeMembers(id, refs, options, mixtapeMembersCommand3) {
|
|
7309
7737
|
if (!id) {
|
|
7310
7738
|
throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes members <id> [refs...]");
|
|
7311
7739
|
}
|
|
7312
7740
|
if (refs.length === 0 && !options.from) {
|
|
7313
7741
|
throw new Error("Provide refs as arguments or a cue-sheet file with --from");
|
|
7314
7742
|
}
|
|
7315
|
-
const result = await
|
|
7743
|
+
const result = await mixtapeMembersCommand3(id, refs, options);
|
|
7316
7744
|
if (options.json) {
|
|
7317
7745
|
printJson(result);
|
|
7318
7746
|
return;
|
|
7319
7747
|
}
|
|
7320
7748
|
console.log(`Saved the tracklist: ${result.mixtape.memberCount} bangers on ${result.mixtape.id}.`);
|
|
7321
7749
|
}
|
|
7322
|
-
async function runMixtapePublish(id, options,
|
|
7750
|
+
async function runMixtapePublish(id, options, mixtapePublishCommand3) {
|
|
7323
7751
|
if (!id) {
|
|
7324
7752
|
throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes publish <id>");
|
|
7325
7753
|
}
|
|
7326
|
-
const result = await
|
|
7754
|
+
const result = await mixtapePublishCommand3(id);
|
|
7327
7755
|
if (options.json) {
|
|
7328
7756
|
printJson(result);
|
|
7329
7757
|
return;
|
|
7330
7758
|
}
|
|
7331
7759
|
console.log(`Minted ${result.mixtape.logId} (fluncle://${result.mixtape.logId}) \u2014 distributing. ` + "Run `distribute` to push it to the platforms.");
|
|
7332
7760
|
}
|
|
7333
|
-
async function runMixtapeDistribute(idOrLogId, options,
|
|
7761
|
+
async function runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3) {
|
|
7334
7762
|
if (!idOrLogId) {
|
|
7335
7763
|
throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes distribute <idOrLogId> --video <mp4> --audio <file>");
|
|
7336
7764
|
}
|
|
7337
7765
|
const onProgress = options.json ? () => {} : (message) => console.log(message);
|
|
7338
|
-
const result = await
|
|
7766
|
+
const result = await mixtapeDistributeCommand3(idOrLogId, options, onProgress);
|
|
7339
7767
|
if (options.json) {
|
|
7340
7768
|
printJson(result);
|
|
7341
7769
|
return;
|
|
@@ -7356,14 +7784,14 @@ async function runMixtapePublishYoutube(idOrLogId, options, publishYoutubeComman
|
|
|
7356
7784
|
}
|
|
7357
7785
|
console.log(`YouTube video is now public: ${result.url}`);
|
|
7358
7786
|
}
|
|
7359
|
-
async function runMixtapeDelete(id, options,
|
|
7787
|
+
async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
|
|
7360
7788
|
if (!id) {
|
|
7361
7789
|
throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes delete <id> --yes");
|
|
7362
7790
|
}
|
|
7363
7791
|
if (!options.yes) {
|
|
7364
7792
|
throw new Error("Pass --yes to confirm the discard. Published mixtapes can't be deleted.");
|
|
7365
7793
|
}
|
|
7366
|
-
await
|
|
7794
|
+
await mixtapeDeleteCommand3(id);
|
|
7367
7795
|
if (options.json) {
|
|
7368
7796
|
printJson({ id, ok: true });
|
|
7369
7797
|
return;
|
|
@@ -7386,6 +7814,32 @@ async function runMixtapeList(options, mixtapeListCommand3) {
|
|
|
7386
7814
|
console.log(`${coordinate3} ${status} ${mixtape.memberCount} bangers ${mixtape.title}`);
|
|
7387
7815
|
}
|
|
7388
7816
|
}
|
|
7817
|
+
async function runClipsList(options, clipsListCommand2) {
|
|
7818
|
+
const clips = await clipsListCommand2({ mixtapeId: options.mixtape, status: options.status });
|
|
7819
|
+
if (options.json) {
|
|
7820
|
+
printJson({ clips, ok: true });
|
|
7821
|
+
return;
|
|
7822
|
+
}
|
|
7823
|
+
if (clips.length === 0) {
|
|
7824
|
+
console.log("No clips.");
|
|
7825
|
+
return;
|
|
7826
|
+
}
|
|
7827
|
+
for (const clip of clips) {
|
|
7828
|
+
console.log(`${clip.id} ${clip.status} ${clip.mixtapeId} ${clip.inMs}-${clip.outMs}ms x=${clip.xOffset}`);
|
|
7829
|
+
}
|
|
7830
|
+
}
|
|
7831
|
+
async function runClipsCut(clipId, options, clipCutCommand2) {
|
|
7832
|
+
if (!clipId) {
|
|
7833
|
+
throw new Error("Missing clip id. Usage: fluncle admin clips cut <clipId>");
|
|
7834
|
+
}
|
|
7835
|
+
const onProgress = options.json ? () => {} : (message) => console.log(message);
|
|
7836
|
+
const result = await clipCutCommand2(clipId, onProgress);
|
|
7837
|
+
if (options.json) {
|
|
7838
|
+
printJson({ ok: true, ...result });
|
|
7839
|
+
return;
|
|
7840
|
+
}
|
|
7841
|
+
console.log(`Cut ${result.clipId} \u2192 ${result.url} (${(result.sizeBytes / 1e6).toFixed(1)} MB).`);
|
|
7842
|
+
}
|
|
7389
7843
|
async function runMixtapeGet(idOrLogId, options, mixtapeGetCommand3) {
|
|
7390
7844
|
if (!idOrLogId) {
|
|
7391
7845
|
throw new Error("Missing mixtape id or log id. Usage: fluncle admin mixtapes get <id|logId>");
|
|
@@ -7533,8 +7987,8 @@ async function runRecent(options, recentCommand3) {
|
|
|
7533
7987
|
console.log(trackRows2(tracks).join(`
|
|
7534
7988
|
`));
|
|
7535
7989
|
}
|
|
7536
|
-
async function runMixtapes(options,
|
|
7537
|
-
const mixtapes = await
|
|
7990
|
+
async function runMixtapes(options, mixtapesCommand3) {
|
|
7991
|
+
const mixtapes = await mixtapesCommand3();
|
|
7538
7992
|
if (options.json) {
|
|
7539
7993
|
printJson({ mixtapes, ok: true });
|
|
7540
7994
|
return;
|
package/package.json
CHANGED