fluncle 0.67.0 → 0.69.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 +302 -22
- 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.69.0".trim() ? "0.69.0".trim() : "0.1.0";
|
|
427
427
|
});
|
|
428
428
|
|
|
429
429
|
// src/update-notifier.ts
|
|
@@ -1212,6 +1212,177 @@ var init_mixtape_mixcloud = __esm(() => {
|
|
|
1212
1212
|
PICTURE_MAX_BYTES = 10 * 1024 * 1024;
|
|
1213
1213
|
});
|
|
1214
1214
|
|
|
1215
|
+
// src/commands/mixtape-set-video.ts
|
|
1216
|
+
var exports_mixtape_set_video = {};
|
|
1217
|
+
__export(exports_mixtape_set_video, {
|
|
1218
|
+
stageSetVideo: () => stageSetVideo,
|
|
1219
|
+
renditionFfmpegArgs: () => renditionFfmpegArgs,
|
|
1220
|
+
planMultipart: () => planMultipart,
|
|
1221
|
+
buildCompleteXml: () => buildCompleteXml,
|
|
1222
|
+
SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
|
|
1223
|
+
MIN_PART_SIZE: () => MIN_PART_SIZE,
|
|
1224
|
+
MAX_PARTS: () => MAX_PARTS,
|
|
1225
|
+
DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
|
|
1226
|
+
});
|
|
1227
|
+
import { randomUUID } from "node:crypto";
|
|
1228
|
+
import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
|
|
1229
|
+
import { tmpdir } from "node:os";
|
|
1230
|
+
import { join as join4 } from "node:path";
|
|
1231
|
+
function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
|
|
1232
|
+
if (!Number.isInteger(contentLength) || contentLength <= 0) {
|
|
1233
|
+
throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
|
|
1234
|
+
}
|
|
1235
|
+
let effective = Math.max(partSize, MIN_PART_SIZE);
|
|
1236
|
+
if (Math.ceil(contentLength / effective) > MAX_PARTS) {
|
|
1237
|
+
effective = Math.ceil(contentLength / MAX_PARTS);
|
|
1238
|
+
}
|
|
1239
|
+
const parts = [];
|
|
1240
|
+
let start = 0;
|
|
1241
|
+
let partNumber = 1;
|
|
1242
|
+
while (start < contentLength) {
|
|
1243
|
+
const end = Math.min(start + effective, contentLength);
|
|
1244
|
+
parts.push({ end, partNumber, size: end - start, start });
|
|
1245
|
+
start = end;
|
|
1246
|
+
partNumber += 1;
|
|
1247
|
+
}
|
|
1248
|
+
return { partCount: parts.length, partSize: effective, parts };
|
|
1249
|
+
}
|
|
1250
|
+
function escapeXml(value) {
|
|
1251
|
+
return value.replace(/[<>&'"]/g, (char) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[char] ?? char);
|
|
1252
|
+
}
|
|
1253
|
+
function buildCompleteXml(parts) {
|
|
1254
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
1255
|
+
const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
|
|
1256
|
+
return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
|
|
1257
|
+
}
|
|
1258
|
+
function renditionFfmpegArgs(inputPath, outputPath) {
|
|
1259
|
+
return [
|
|
1260
|
+
"-y",
|
|
1261
|
+
"-i",
|
|
1262
|
+
inputPath,
|
|
1263
|
+
"-vf",
|
|
1264
|
+
`scale=-2:${SET_VIDEO_RENDITION.height}`,
|
|
1265
|
+
"-c:v",
|
|
1266
|
+
"libx264",
|
|
1267
|
+
"-preset",
|
|
1268
|
+
"medium",
|
|
1269
|
+
"-crf",
|
|
1270
|
+
String(SET_VIDEO_RENDITION.crf),
|
|
1271
|
+
"-force_key_frames",
|
|
1272
|
+
`expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
|
|
1273
|
+
"-pix_fmt",
|
|
1274
|
+
"yuv420p",
|
|
1275
|
+
"-c:a",
|
|
1276
|
+
"aac",
|
|
1277
|
+
"-b:a",
|
|
1278
|
+
SET_VIDEO_RENDITION.audioBitrate,
|
|
1279
|
+
"-movflags",
|
|
1280
|
+
"+faststart",
|
|
1281
|
+
outputPath
|
|
1282
|
+
];
|
|
1283
|
+
}
|
|
1284
|
+
async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
|
|
1285
|
+
if (!existsSync(masterPath)) {
|
|
1286
|
+
throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
|
|
1287
|
+
}
|
|
1288
|
+
await assertFfmpeg();
|
|
1289
|
+
const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
|
|
1290
|
+
onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
|
|
1291
|
+
await deriveRendition(masterPath, renditionPath);
|
|
1292
|
+
try {
|
|
1293
|
+
const size = statSync2(renditionPath).size;
|
|
1294
|
+
const plan = planMultipart(size);
|
|
1295
|
+
onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
|
|
1296
|
+
const presign = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, { partCount: plan.partCount });
|
|
1297
|
+
const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
|
|
1298
|
+
const completed = [];
|
|
1299
|
+
try {
|
|
1300
|
+
for (const part of plan.parts) {
|
|
1301
|
+
const url = urlByPart.get(part.partNumber);
|
|
1302
|
+
if (!url) {
|
|
1303
|
+
throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
|
|
1304
|
+
}
|
|
1305
|
+
onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
|
|
1306
|
+
const etag = await putPart(url, renditionPath, part);
|
|
1307
|
+
completed.push({ etag, partNumber: part.partNumber });
|
|
1308
|
+
}
|
|
1309
|
+
await completeUpload(presign.completeUrl, completed);
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
await abortUpload(presign.abortUrl).catch(() => {});
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1314
|
+
await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, { setVideoAt: new Date().toISOString() });
|
|
1315
|
+
onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
|
|
1316
|
+
return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
|
|
1317
|
+
} finally {
|
|
1318
|
+
rmSync2(renditionPath, { force: true });
|
|
1319
|
+
}
|
|
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}` : ""}`);
|
|
1329
|
+
}
|
|
1330
|
+
const etag = response.headers.get("etag");
|
|
1331
|
+
if (!etag) {
|
|
1332
|
+
throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
|
|
1333
|
+
}
|
|
1334
|
+
return etag;
|
|
1335
|
+
}
|
|
1336
|
+
async function completeUpload(url, parts) {
|
|
1337
|
+
const response = await fetch(url, {
|
|
1338
|
+
body: buildCompleteXml(parts),
|
|
1339
|
+
headers: { "content-type": "application/xml" },
|
|
1340
|
+
method: "POST"
|
|
1341
|
+
});
|
|
1342
|
+
const text = await response.text().catch(() => "");
|
|
1343
|
+
if (!response.ok || text.includes("<Error>")) {
|
|
1344
|
+
throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
async function abortUpload(url) {
|
|
1348
|
+
await fetch(url, { method: "DELETE" });
|
|
1349
|
+
}
|
|
1350
|
+
async function assertFfmpeg() {
|
|
1351
|
+
try {
|
|
1352
|
+
const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
|
|
1353
|
+
await proc.exited;
|
|
1354
|
+
if (proc.exitCode !== 0) {
|
|
1355
|
+
throw new Error("ffmpeg -version exited non-zero");
|
|
1356
|
+
}
|
|
1357
|
+
} catch {
|
|
1358
|
+
throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
async function deriveRendition(inputPath, outputPath) {
|
|
1362
|
+
const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
|
|
1363
|
+
stderr: "pipe",
|
|
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}` : ""}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
|
|
1373
|
+
var init_mixtape_set_video = __esm(() => {
|
|
1374
|
+
init_api();
|
|
1375
|
+
init_output();
|
|
1376
|
+
SET_VIDEO_RENDITION = {
|
|
1377
|
+
audioBitrate: "192k",
|
|
1378
|
+
crf: 20,
|
|
1379
|
+
gopSeconds: 2,
|
|
1380
|
+
height: 1080
|
|
1381
|
+
};
|
|
1382
|
+
DEFAULT_PART_SIZE = 100 * 1024 * 1024;
|
|
1383
|
+
MIN_PART_SIZE = 5 * 1024 * 1024;
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1215
1386
|
// src/commands/mixtapes.ts
|
|
1216
1387
|
var exports_mixtapes = {};
|
|
1217
1388
|
__export(exports_mixtapes, {
|
|
@@ -1225,7 +1396,7 @@ __export(exports_mixtapes, {
|
|
|
1225
1396
|
mixtapeDeleteCommand: () => mixtapeDeleteCommand,
|
|
1226
1397
|
mixtapeCreateCommand: () => mixtapeCreateCommand
|
|
1227
1398
|
});
|
|
1228
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1399
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
1229
1400
|
async function mixtapesCommand() {
|
|
1230
1401
|
const response = await publicApiGet("/api/mixtapes");
|
|
1231
1402
|
return response.mixtapes;
|
|
@@ -1266,15 +1437,19 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1266
1437
|
if (!mixtape.id) {
|
|
1267
1438
|
throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
|
|
1268
1439
|
}
|
|
1269
|
-
const both = !options.youtube && !options.mixcloud;
|
|
1440
|
+
const both = !options.youtube && !options.mixcloud && !options.setVideo;
|
|
1270
1441
|
const doYoutube = both || Boolean(options.youtube);
|
|
1271
1442
|
const doMixcloud = both || Boolean(options.mixcloud);
|
|
1443
|
+
const doSetVideo = Boolean(options.setVideo);
|
|
1272
1444
|
if (doYoutube && !options.video) {
|
|
1273
1445
|
throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
|
|
1274
1446
|
}
|
|
1275
1447
|
if (doMixcloud && !options.audio) {
|
|
1276
1448
|
throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
|
|
1277
1449
|
}
|
|
1450
|
+
if (doSetVideo && !options.video) {
|
|
1451
|
+
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1452
|
+
}
|
|
1278
1453
|
const mixtapeId = mixtape.id;
|
|
1279
1454
|
let logId = mixtape.logId;
|
|
1280
1455
|
if (!mixtape.durationMs) {
|
|
@@ -1319,6 +1494,16 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
|
|
|
1319
1494
|
results.push({ platform: "mixcloud", url: result.url });
|
|
1320
1495
|
onProgress(`Mixcloud: ${result.url}`);
|
|
1321
1496
|
}
|
|
1497
|
+
if (doSetVideo) {
|
|
1498
|
+
if (!options.video) {
|
|
1499
|
+
throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
|
|
1500
|
+
}
|
|
1501
|
+
onProgress("Set video: staging the 1080p rendition…");
|
|
1502
|
+
const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
|
|
1503
|
+
const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
|
|
1504
|
+
results.push({ platform: "set-video", url: result.url });
|
|
1505
|
+
onProgress(`Set video: ${result.url}`);
|
|
1506
|
+
}
|
|
1322
1507
|
return { logId, mixtapeId, results };
|
|
1323
1508
|
}
|
|
1324
1509
|
async function probeDurationMs(filePath) {
|
|
@@ -1353,7 +1538,7 @@ function buildBody(options) {
|
|
|
1353
1538
|
return body;
|
|
1354
1539
|
}
|
|
1355
1540
|
function parseCueFile(filePath) {
|
|
1356
|
-
if (!
|
|
1541
|
+
if (!existsSync2(filePath)) {
|
|
1357
1542
|
throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
|
|
1358
1543
|
}
|
|
1359
1544
|
const text = readFileSync2(filePath, "utf-8");
|
|
@@ -2138,7 +2323,7 @@ function parseVersion2(version) {
|
|
|
2138
2323
|
var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
|
|
2139
2324
|
var init_version2 = __esm(() => {
|
|
2140
2325
|
init_output();
|
|
2141
|
-
currentVersion2 = "0.
|
|
2326
|
+
currentVersion2 = "0.69.0".trim() ? "0.69.0".trim() : "0.1.0";
|
|
2142
2327
|
});
|
|
2143
2328
|
|
|
2144
2329
|
// ../../packages/registry/src/index.ts
|
|
@@ -2673,10 +2858,8 @@ var init_src = __esm(() => {
|
|
|
2673
2858
|
],
|
|
2674
2859
|
kind: "extension",
|
|
2675
2860
|
name: "extension.lens",
|
|
2676
|
-
operatorNotes: "Fluncle Lens (apps/extension), MV3
|
|
2677
|
-
|
|
2678
|
-
probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
|
|
2679
|
-
url: "https://chromewebstore.google.com/search/Fluncle%20Lens",
|
|
2861
|
+
operatorNotes: "Fluncle Lens (apps/extension), MV3, LIVE on the Chrome Web Store (published 2026-06-29). Store listing reachability is Google's, not ours, so it is not on the /status board.",
|
|
2862
|
+
url: "https://chromewebstore.google.com/detail/efkkceaofendabikblfjhoepgejfpakk",
|
|
2680
2863
|
weights: { web: "secondary" }
|
|
2681
2864
|
},
|
|
2682
2865
|
{
|
|
@@ -2894,7 +3077,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
|
|
|
2894
3077
|
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
|
2895
3078
|
throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
|
|
2896
3079
|
}
|
|
2897
|
-
urls[spec.field] = `${
|
|
3080
|
+
urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
|
|
2898
3081
|
}
|
|
2899
3082
|
const manifest = files.render ? await readManifestFields(files.render) : {};
|
|
2900
3083
|
const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
|
|
@@ -3004,7 +3187,7 @@ async function trackNoteCommand(idOrLogId, options) {
|
|
|
3004
3187
|
const body = { note: options.note };
|
|
3005
3188
|
return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
|
|
3006
3189
|
}
|
|
3007
|
-
var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high",
|
|
3190
|
+
var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
|
|
3008
3191
|
var init_track = __esm(() => {
|
|
3009
3192
|
init_api();
|
|
3010
3193
|
init_output();
|
|
@@ -3162,6 +3345,73 @@ var init_admin_tracks = __esm(() => {
|
|
|
3162
3345
|
init_recent2();
|
|
3163
3346
|
});
|
|
3164
3347
|
|
|
3348
|
+
// src/version-match.ts
|
|
3349
|
+
function isRemix(title) {
|
|
3350
|
+
return REMIX_MARKER.test(title);
|
|
3351
|
+
}
|
|
3352
|
+
function stripVersionSuffix(title) {
|
|
3353
|
+
const parts = title.split(/\s+-\s+/);
|
|
3354
|
+
if (parts.length > 1 && VERSION_MARKER.test(parts[parts.length - 1] ?? "")) {
|
|
3355
|
+
return parts.slice(0, -1).join(" - ").trim();
|
|
3356
|
+
}
|
|
3357
|
+
return title.trim();
|
|
3358
|
+
}
|
|
3359
|
+
function tokenize(value) {
|
|
3360
|
+
return value.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter(Boolean);
|
|
3361
|
+
}
|
|
3362
|
+
function versionTokens(title) {
|
|
3363
|
+
const parts = title.split(/\s+-\s+/);
|
|
3364
|
+
const tail = parts.length > 1 ? parts[parts.length - 1] ?? "" : "";
|
|
3365
|
+
if (parts.length > 1 && VERSION_MARKER.test(tail)) {
|
|
3366
|
+
return new Set(tokenize(tail));
|
|
3367
|
+
}
|
|
3368
|
+
const bracketed = /[([]([^)\]]*?)[)\]]/.exec(title);
|
|
3369
|
+
if (bracketed?.[1] && VERSION_MARKER.test(bracketed[1])) {
|
|
3370
|
+
return new Set(tokenize(bracketed[1]));
|
|
3371
|
+
}
|
|
3372
|
+
return new Set;
|
|
3373
|
+
}
|
|
3374
|
+
function versionMatches(findingTitle, candidateTitle) {
|
|
3375
|
+
const findingIsRemix = isRemix(findingTitle);
|
|
3376
|
+
const candidateIsRemix = isRemix(candidateTitle);
|
|
3377
|
+
if (findingIsRemix) {
|
|
3378
|
+
if (!candidateIsRemix) {
|
|
3379
|
+
return false;
|
|
3380
|
+
}
|
|
3381
|
+
const want = [...versionTokens(findingTitle)].filter((t) => !VERSION_STOPWORDS.has(t));
|
|
3382
|
+
if (want.length === 0) {
|
|
3383
|
+
return true;
|
|
3384
|
+
}
|
|
3385
|
+
const have = versionTokens(candidateTitle);
|
|
3386
|
+
for (const token of want) {
|
|
3387
|
+
if (!have.has(token)) {
|
|
3388
|
+
return false;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
return true;
|
|
3392
|
+
}
|
|
3393
|
+
return !candidateIsRemix;
|
|
3394
|
+
}
|
|
3395
|
+
function baseTitleMatches(findingTitle, candidateTitle) {
|
|
3396
|
+
const want = new Set(tokenize(stripVersionSuffix(findingTitle)));
|
|
3397
|
+
const have = new Set(tokenize(stripVersionSuffix(candidateTitle)));
|
|
3398
|
+
if (want.size === 0) {
|
|
3399
|
+
return false;
|
|
3400
|
+
}
|
|
3401
|
+
for (const token of want) {
|
|
3402
|
+
if (!have.has(token)) {
|
|
3403
|
+
return false;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
return true;
|
|
3407
|
+
}
|
|
3408
|
+
var VERSION_MARKER, REMIX_MARKER, VERSION_STOPWORDS;
|
|
3409
|
+
var init_version_match = __esm(() => {
|
|
3410
|
+
VERSION_MARKER = /\b(mix|edit|version|remix|dub|vip|bootleg|rework|re-?edit|flip|refix|remaster(?:ed)?|instrumental)\b/i;
|
|
3411
|
+
REMIX_MARKER = /\b(remix|bootleg|vip|rework|re-?edit|flip|refix)\b/i;
|
|
3412
|
+
VERSION_STOPWORDS = new Set(["mix", "the", "and", "feat", "ft", "edit", "version", "remix"]);
|
|
3413
|
+
});
|
|
3414
|
+
|
|
3165
3415
|
// src/commands/preview-archive.ts
|
|
3166
3416
|
var exports_preview_archive = {};
|
|
3167
3417
|
__export(exports_preview_archive, {
|
|
@@ -3265,16 +3515,22 @@ async function resolveDeezerByIsrc(isrc) {
|
|
|
3265
3515
|
return;
|
|
3266
3516
|
}
|
|
3267
3517
|
}
|
|
3518
|
+
function durationAgrees(findingMs, candidateSeconds) {
|
|
3519
|
+
if (!findingMs || !candidateSeconds) {
|
|
3520
|
+
return true;
|
|
3521
|
+
}
|
|
3522
|
+
return Math.abs(candidateSeconds - findingMs / 1000) <= DURATION_TOLERANCE_S;
|
|
3523
|
+
}
|
|
3268
3524
|
async function resolveDeezerSearch(track) {
|
|
3269
3525
|
const artist = track.artists[0]?.trim();
|
|
3270
3526
|
if (!artist || !track.title.trim()) {
|
|
3271
3527
|
return;
|
|
3272
3528
|
}
|
|
3273
3529
|
try {
|
|
3274
|
-
const query = `artist:"${artist}" track:"${track.title.trim()}"`;
|
|
3530
|
+
const query = `artist:"${artist}" track:"${stripVersionSuffix(track.title.trim())}"`;
|
|
3275
3531
|
const response = await fetch(`https://api.deezer.com/search?q=${encodeURIComponent(query)}`);
|
|
3276
3532
|
const body = await response.json();
|
|
3277
|
-
const hit = (body.data ?? []).find((item) => item.preview);
|
|
3533
|
+
const hit = (body.data ?? []).find((item) => item.preview && versionMatches(track.title, item.title ?? "") && baseTitleMatches(track.title, item.title ?? "") && durationAgrees(track.durationMs, item.duration));
|
|
3278
3534
|
return downloadPreview(hit?.preview, "deezer:search");
|
|
3279
3535
|
} catch {
|
|
3280
3536
|
return;
|
|
@@ -3288,7 +3544,7 @@ async function resolveItunes(track) {
|
|
|
3288
3544
|
try {
|
|
3289
3545
|
const response = await fetch(`https://itunes.apple.com/search?term=${encodeURIComponent(`${artist} ${track.title}`)}&media=music&limit=10`);
|
|
3290
3546
|
const body = await response.json();
|
|
3291
|
-
const hit = (body.results ?? []).find((item) => item.previewUrl && normalize(item.artistName ?? "").includes(normalize(artist)));
|
|
3547
|
+
const hit = (body.results ?? []).find((item) => item.previewUrl && normalize(item.artistName ?? "").includes(normalize(artist)) && versionMatches(track.title, item.trackName ?? "") && baseTitleMatches(track.title, item.trackName ?? "") && durationAgrees(track.durationMs, item.trackTimeMillis ? item.trackTimeMillis / 1000 : undefined));
|
|
3292
3548
|
return downloadPreview(hit?.previewUrl, "itunes");
|
|
3293
3549
|
} catch {
|
|
3294
3550
|
return;
|
|
@@ -3350,8 +3606,10 @@ function extensionForMime(mime) {
|
|
|
3350
3606
|
function normalize(value) {
|
|
3351
3607
|
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
3352
3608
|
}
|
|
3609
|
+
var DURATION_TOLERANCE_S = 5;
|
|
3353
3610
|
var init_preview_archive = __esm(() => {
|
|
3354
3611
|
init_api();
|
|
3612
|
+
init_version_match();
|
|
3355
3613
|
});
|
|
3356
3614
|
|
|
3357
3615
|
// src/commands/mixtape-youtube.ts
|
|
@@ -3362,7 +3620,7 @@ __export(exports_mixtape_youtube2, {
|
|
|
3362
3620
|
distributeYoutube: () => distributeYoutube2,
|
|
3363
3621
|
authYoutubeCommand: () => authYoutubeCommand2
|
|
3364
3622
|
});
|
|
3365
|
-
import { statSync as
|
|
3623
|
+
import { statSync as statSync3 } from "node:fs";
|
|
3366
3624
|
async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
|
|
3367
3625
|
const contentLength = fileSize2(videoPath);
|
|
3368
3626
|
const contentType = "video/mp4";
|
|
@@ -3494,7 +3752,7 @@ async function resolveMixtapeId2(idOrLogId) {
|
|
|
3494
3752
|
}
|
|
3495
3753
|
function fileSize2(path2) {
|
|
3496
3754
|
try {
|
|
3497
|
-
return
|
|
3755
|
+
return statSync3(path2).size;
|
|
3498
3756
|
} catch {
|
|
3499
3757
|
throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
|
|
3500
3758
|
}
|
|
@@ -3520,9 +3778,10 @@ __export(exports_newsletter, {
|
|
|
3520
3778
|
newsletterUpdateCommand: () => newsletterUpdateCommand,
|
|
3521
3779
|
newsletterSendCommand: () => newsletterSendCommand,
|
|
3522
3780
|
newsletterListCommand: () => newsletterListCommand,
|
|
3523
|
-
newsletterDraftCommand: () => newsletterDraftCommand
|
|
3781
|
+
newsletterDraftCommand: () => newsletterDraftCommand,
|
|
3782
|
+
newsletterDeleteCommand: () => newsletterDeleteCommand
|
|
3524
3783
|
});
|
|
3525
|
-
import { existsSync as
|
|
3784
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
3526
3785
|
function buildBody2(options, { requireContent }) {
|
|
3527
3786
|
const body = {};
|
|
3528
3787
|
if (options.contentFile !== undefined) {
|
|
@@ -3542,7 +3801,7 @@ function buildBody2(options, { requireContent }) {
|
|
|
3542
3801
|
return body;
|
|
3543
3802
|
}
|
|
3544
3803
|
function readContentFile(filePath) {
|
|
3545
|
-
if (!
|
|
3804
|
+
if (!existsSync3(filePath)) {
|
|
3546
3805
|
throw new CliError2("file_not_found", `Content file not found: ${filePath}`);
|
|
3547
3806
|
}
|
|
3548
3807
|
const text = readFileSync3(filePath, "utf-8");
|
|
@@ -3565,6 +3824,9 @@ async function newsletterListCommand() {
|
|
|
3565
3824
|
const response = await adminApiGet("/api/admin/newsletter/editions");
|
|
3566
3825
|
return response.editions;
|
|
3567
3826
|
}
|
|
3827
|
+
async function newsletterDeleteCommand(id) {
|
|
3828
|
+
return adminApiDelete(`/api/admin/newsletter/editions/${encodeURIComponent(id)}`);
|
|
3829
|
+
}
|
|
3568
3830
|
var init_newsletter = __esm(() => {
|
|
3569
3831
|
init_api();
|
|
3570
3832
|
init_output();
|
|
@@ -4179,7 +4441,7 @@ var COORD_FALLBACK2 = "—";
|
|
|
4179
4441
|
var init_format2 = () => {};
|
|
4180
4442
|
|
|
4181
4443
|
// src/cli.ts
|
|
4182
|
-
import { existsSync as
|
|
4444
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
4183
4445
|
import path2 from "path";
|
|
4184
4446
|
|
|
4185
4447
|
// ../../node_modules/commander/lib/error.js
|
|
@@ -6539,7 +6801,7 @@ function addAdminCommands(program2) {
|
|
|
6539
6801
|
const { mixtapeGetCommand: mixtapeGetCommand3 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
|
|
6540
6802
|
await runMixtapeGet(idOrLogId, options, mixtapeGetCommand3);
|
|
6541
6803
|
});
|
|
6542
|
-
adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
|
|
6804
|
+
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) => {
|
|
6543
6805
|
const { mixtapeDistributeCommand: mixtapeDistributeCommand2 } = await Promise.resolve().then(() => (init_mixtapes2(), exports_mixtapes));
|
|
6544
6806
|
await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand2);
|
|
6545
6807
|
});
|
|
@@ -6567,6 +6829,10 @@ function addAdminCommands(program2) {
|
|
|
6567
6829
|
const { newsletterListCommand: newsletterListCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
|
|
6568
6830
|
await runNewsletterList(options, newsletterListCommand2);
|
|
6569
6831
|
});
|
|
6832
|
+
adminNewsletter.command("delete").description("Delete an edition (draft or sent) \u2014 OPERATOR only; reopens the send window").argument("[id]").option("--yes", "Confirm the delete", false).option("--json", "Print JSON", false).allowExcessArguments().action(async (id, options) => {
|
|
6833
|
+
const { newsletterDeleteCommand: newsletterDeleteCommand2 } = await Promise.resolve().then(() => (init_newsletter(), exports_newsletter));
|
|
6834
|
+
await runNewsletterDelete(id, options, newsletterDeleteCommand2);
|
|
6835
|
+
});
|
|
6570
6836
|
const submissions = configureCommand(admin.command("submissions").description("Review listener submissions"));
|
|
6571
6837
|
submissions.option("--json", "Print JSON", false).action(async (options) => {
|
|
6572
6838
|
const { listSubmissionsCommand: listSubmissionsCommand2 } = await Promise.resolve().then(() => (init_submissions(), exports_submissions));
|
|
@@ -6848,7 +7114,7 @@ async function runTrackVideo(idOrLogId, options, trackVideoCommand2) {
|
|
|
6848
7114
|
return;
|
|
6849
7115
|
}
|
|
6850
7116
|
const candidate = path2.join(dir, name);
|
|
6851
|
-
return
|
|
7117
|
+
return existsSync4(candidate) ? candidate : undefined;
|
|
6852
7118
|
};
|
|
6853
7119
|
const resolveFile = (explicit, name) => {
|
|
6854
7120
|
if (explicit) {
|
|
@@ -7185,6 +7451,20 @@ async function runNewsletterSend(id, options, newsletterSendCommand2) {
|
|
|
7185
7451
|
const number = result.edition.number;
|
|
7186
7452
|
console.log(number === undefined ? `Sent edition ${result.edition.id}.` : `Sent edition #${number} \u2014 it's out to the list and in the archive.`);
|
|
7187
7453
|
}
|
|
7454
|
+
async function runNewsletterDelete(id, options, newsletterDeleteCommand2) {
|
|
7455
|
+
if (!id) {
|
|
7456
|
+
throw new Error("Missing edition id. Usage: fluncle admin newsletter delete <id> --yes");
|
|
7457
|
+
}
|
|
7458
|
+
if (!options.yes) {
|
|
7459
|
+
throw new Error("Pass --yes to confirm \u2014 this hard-deletes the edition (draft or sent).");
|
|
7460
|
+
}
|
|
7461
|
+
const result = await newsletterDeleteCommand2(id);
|
|
7462
|
+
if (options.json) {
|
|
7463
|
+
printJson({ ...result, ok: true });
|
|
7464
|
+
return;
|
|
7465
|
+
}
|
|
7466
|
+
console.log(`Deleted edition ${result.id}. The send window reopens to cover its finds.`);
|
|
7467
|
+
}
|
|
7188
7468
|
async function runNewsletterList(options, newsletterListCommand2) {
|
|
7189
7469
|
const editions = await newsletterListCommand2();
|
|
7190
7470
|
if (options.json) {
|
package/package.json
CHANGED