fluncle 0.75.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/fluncle.mjs +256 -20
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -462,6 +462,26 @@ function versionMatches(findingTitle, candidateTitle) {
462
462
  }
463
463
  return !candidateIsRemix;
464
464
  }
465
+ function trackLabel(artists, title) {
466
+ const joined = artists.join(", ");
467
+ return joined && title ? `${joined} — ${title}` : joined || title;
468
+ }
469
+ function resolveClipTracks(options) {
470
+ const { inMs, members, outMs, setDurationMs } = options;
471
+ const cued = members.filter((member) => member.startMs != null).sort((a, b) => a.startMs - b.startMs);
472
+ if (cued.length === 0) {
473
+ return [];
474
+ }
475
+ const lastIndex = cued.length - 1;
476
+ return cued.filter((member, index) => {
477
+ const lo = index === 0 ? Math.min(member.startMs, inMs) : member.startMs;
478
+ const hi = index === lastIndex ? Math.max(setDurationMs, outMs) : cued[index + 1]?.startMs ?? Number.POSITIVE_INFINITY;
479
+ return lo < outMs && inMs < hi;
480
+ }).map((member) => ({
481
+ label: trackLabel(member.artists, member.title),
482
+ startMs: member.startMs
483
+ }));
484
+ }
465
485
  function baseTitleMatches(findingTitle, candidateTitle) {
466
486
  const want = new Set(tokenize(stripVersionSuffix(findingTitle)));
467
487
  const have = new Set(tokenize(stripVersionSuffix(candidateTitle)));
@@ -524,7 +544,7 @@ function parseVersion(version) {
524
544
  var currentVersion;
525
545
  var init_version = __esm(() => {
526
546
  init_output();
527
- currentVersion = "0.75.0".trim() ? "0.75.0".trim() : "0.1.0";
547
+ currentVersion = "0.77.0".trim() ? "0.77.0".trim() : "0.1.0";
528
548
  });
529
549
 
530
550
  // src/update-notifier.ts
@@ -1009,6 +1029,7 @@ var init_mixtape_api = __esm(() => {
1009
1029
  // src/commands/mixtape-youtube.ts
1010
1030
  var exports_mixtape_youtube = {};
1011
1031
  __export(exports_mixtape_youtube, {
1032
+ resyncYoutube: () => resyncYoutube,
1012
1033
  publishYoutubeCommand: () => publishYoutubeCommand,
1013
1034
  nextOffset: () => nextOffset,
1014
1035
  distributeYoutube: () => distributeYoutube,
@@ -1064,6 +1085,10 @@ async function publishYoutubeCommand(idOrLogId) {
1064
1085
  const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/youtube/publish`);
1065
1086
  return { url: response.url };
1066
1087
  }
1088
+ async function resyncYoutube(mixtapeId) {
1089
+ const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/youtube/resync`);
1090
+ return { url: response.url, videoId: response.videoId };
1091
+ }
1067
1092
  async function authYoutubeCommand() {
1068
1093
  const response = await adminApiGet("/api/admin/youtube/auth/start");
1069
1094
  console.log(`Open this YouTube authorization URL:
@@ -1169,8 +1194,11 @@ var init_mixtape_youtube = __esm(() => {
1169
1194
  // src/commands/mixtape-mixcloud.ts
1170
1195
  var exports_mixtape_mixcloud = {};
1171
1196
  __export(exports_mixtape_mixcloud, {
1197
+ resyncMixcloud: () => resyncMixcloud,
1172
1198
  mixtapeDescription: () => mixtapeDescription,
1173
1199
  mixcloudSections: () => mixcloudSections,
1200
+ mixcloudSectionFields: () => mixcloudSectionFields,
1201
+ mixcloudEditUrl: () => mixcloudEditUrl,
1174
1202
  distributeMixcloud: () => distributeMixcloud,
1175
1203
  authMixcloudCommand: () => authMixcloudCommand
1176
1204
  });
@@ -1198,10 +1226,8 @@ async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = f
1198
1226
  form.append(`tags-${index}-tag`, tag);
1199
1227
  }
1200
1228
  const sections = mixcloudSections(mixtape.members);
1201
- for (const [index, section] of sections.entries()) {
1202
- form.append(`sections-${index}-artist`, section.artist);
1203
- form.append(`sections-${index}-song`, section.song);
1204
- form.append(`sections-${index}-start_time`, String(section.start_time));
1229
+ for (const [name, value] of mixcloudSectionFields(sections)) {
1230
+ form.append(name, value);
1205
1231
  }
1206
1232
  if (unlisted) {
1207
1233
  form.append("unlisted", "1");
@@ -1233,6 +1259,35 @@ async function distributeMixcloud(mixtapeId, audioPath, onProgress, unlisted = f
1233
1259
  });
1234
1260
  return { url };
1235
1261
  }
1262
+ async function resyncMixcloud(mixtapeId, onProgress) {
1263
+ const token = await fetchMixcloudToken();
1264
+ const mixtape = await mixtapeGetCommand(mixtapeId);
1265
+ const sections = mixcloudSections(mixtape.members);
1266
+ if (sections.length === 0) {
1267
+ throw new CliError2("mixcloud_no_cues", "No cued members to sync — mark cues on the mixtape first.");
1268
+ }
1269
+ const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
1270
+ const mixcloud = social.posts.find((post) => post.platform === "mixcloud");
1271
+ const key = mixcloud?.externalId;
1272
+ if (!key) {
1273
+ throw new CliError2("mixcloud_not_distributed", "No Mixcloud cloudcast to re-sync — distribute the mixtape first.");
1274
+ }
1275
+ const form = new FormData;
1276
+ for (const [name, value] of mixcloudSectionFields(sections)) {
1277
+ form.append(name, value);
1278
+ }
1279
+ onProgress?.(`Mixcloud: re-syncing ${sections.length} sections (no re-upload)…`);
1280
+ const response = await fetch(`${mixcloudEditUrl(key)}?access_token=${encodeURIComponent(token)}`, { body: form, method: "POST" });
1281
+ const text = await response.text();
1282
+ if (!response.ok) {
1283
+ throwMixcloudError(response.status, text);
1284
+ }
1285
+ const result = parseUploadResult(text);
1286
+ if (!result.success) {
1287
+ throw new CliError2("mixcloud_edit_rejected", `Mixcloud rejected the section edit: ${result.message ?? text.slice(0, 300)}`);
1288
+ }
1289
+ return { url: mixcloud?.url ?? `https://www.mixcloud.com${key}` };
1290
+ }
1236
1291
  async function authMixcloudCommand() {
1237
1292
  const response = await adminApiGet("/api/admin/mixcloud/auth/start");
1238
1293
  console.log(`Open this Mixcloud authorization URL:
@@ -1263,6 +1318,18 @@ function mixcloudSections(members) {
1263
1318
  start_time: Math.floor(member.startMs / 1000)
1264
1319
  }));
1265
1320
  }
1321
+ function mixcloudSectionFields(sections) {
1322
+ return sections.flatMap((section, index) => [
1323
+ [`sections-${index}-artist`, section.artist],
1324
+ [`sections-${index}-song`, section.song],
1325
+ [`sections-${index}-start_time`, String(section.start_time)]
1326
+ ]);
1327
+ }
1328
+ function mixcloudEditUrl(key) {
1329
+ const withLeading = key.startsWith("/") ? key : `/${key}`;
1330
+ const path2 = withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
1331
+ return `${MIXCLOUD_API}/upload${path2}edit/`;
1332
+ }
1266
1333
  function mixtapeTags(_mixtape) {
1267
1334
  return ["Drum & Bass", "Fluncle"];
1268
1335
  }
@@ -1489,6 +1556,7 @@ var exports_mixtapes = {};
1489
1556
  __export(exports_mixtapes, {
1490
1557
  mixtapesCommand: () => mixtapesCommand,
1491
1558
  mixtapeUpdateCommand: () => mixtapeUpdateCommand,
1559
+ mixtapeResyncCommand: () => mixtapeResyncCommand,
1492
1560
  mixtapePublishCommand: () => mixtapePublishCommand,
1493
1561
  mixtapeMembersCommand: () => mixtapeMembersCommand,
1494
1562
  mixtapeListCommand: () => mixtapeListCommand,
@@ -1595,6 +1663,44 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1595
1663
  }
1596
1664
  return { logId, mixtapeId, results };
1597
1665
  }
1666
+ async function mixtapeResyncCommand(idOrLogId, options, onProgress = () => {}) {
1667
+ const mixtape = await mixtapeGetCommand(idOrLogId);
1668
+ if (!mixtape.id) {
1669
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1670
+ }
1671
+ if (!mixtape.logId) {
1672
+ throw new CliError2("mixtape_no_log_id", "The mixtape isn't published yet — distribute it before re-syncing.");
1673
+ }
1674
+ const mixtapeId = mixtape.id;
1675
+ const explicit = Boolean(options.youtube) || Boolean(options.mixcloud);
1676
+ let doYoutube = Boolean(options.youtube);
1677
+ let doMixcloud = Boolean(options.mixcloud);
1678
+ if (!explicit) {
1679
+ const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
1680
+ const platforms = new Set(social.posts.map((post) => post.platform));
1681
+ doYoutube = platforms.has("youtube");
1682
+ doMixcloud = platforms.has("mixcloud");
1683
+ if (!doYoutube && !doMixcloud) {
1684
+ throw new CliError2("mixtape_not_distributed", "The mixtape has no YouTube or Mixcloud link to re-sync.");
1685
+ }
1686
+ }
1687
+ const results = [];
1688
+ if (doYoutube) {
1689
+ onProgress("YouTube: re-syncing description + chapters…");
1690
+ const { resyncYoutube: resyncYoutube2 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
1691
+ const result = await resyncYoutube2(mixtapeId);
1692
+ results.push({ platform: "youtube", url: result.url });
1693
+ onProgress(`YouTube: ${result.url}`);
1694
+ }
1695
+ if (doMixcloud) {
1696
+ onProgress("Mixcloud: re-syncing sections…");
1697
+ const { resyncMixcloud: resyncMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
1698
+ const result = await resyncMixcloud2(mixtapeId, onProgress);
1699
+ results.push({ platform: "mixcloud", url: result.url });
1700
+ onProgress(`Mixcloud: ${result.url}`);
1701
+ }
1702
+ return { logId: mixtape.logId, mixtapeId, results };
1703
+ }
1598
1704
  async function probeDurationMs(filePath) {
1599
1705
  try {
1600
1706
  const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
@@ -2381,7 +2487,7 @@ function parseVersion2(version) {
2381
2487
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2382
2488
  var init_version2 = __esm(() => {
2383
2489
  init_output();
2384
- currentVersion2 = "0.75.0".trim() ? "0.75.0".trim() : "0.1.0";
2490
+ currentVersion2 = "0.77.0".trim() ? "0.77.0".trim() : "0.1.0";
2385
2491
  });
2386
2492
 
2387
2493
  // ../../packages/registry/src/index.ts
@@ -3606,6 +3712,7 @@ var init_preview_archive = __esm(() => {
3606
3712
  // src/commands/mixtape-youtube.ts
3607
3713
  var exports_mixtape_youtube2 = {};
3608
3714
  __export(exports_mixtape_youtube2, {
3715
+ resyncYoutube: () => resyncYoutube2,
3609
3716
  publishYoutubeCommand: () => publishYoutubeCommand2,
3610
3717
  nextOffset: () => nextOffset2,
3611
3718
  distributeYoutube: () => distributeYoutube2,
@@ -3661,6 +3768,10 @@ async function publishYoutubeCommand2(idOrLogId) {
3661
3768
  const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/youtube/publish`);
3662
3769
  return { url: response.url };
3663
3770
  }
3771
+ async function resyncYoutube2(mixtapeId) {
3772
+ const response = await adminApiPost(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/youtube/resync`);
3773
+ return { url: response.url, videoId: response.videoId };
3774
+ }
3664
3775
  async function authYoutubeCommand2() {
3665
3776
  const response = await adminApiGet("/api/admin/youtube/auth/start");
3666
3777
  console.log(`Open this YouTube authorization URL:
@@ -3768,6 +3879,7 @@ var exports_mixtapes2 = {};
3768
3879
  __export(exports_mixtapes2, {
3769
3880
  mixtapesCommand: () => mixtapesCommand2,
3770
3881
  mixtapeUpdateCommand: () => mixtapeUpdateCommand2,
3882
+ mixtapeResyncCommand: () => mixtapeResyncCommand2,
3771
3883
  mixtapePublishCommand: () => mixtapePublishCommand2,
3772
3884
  mixtapeMembersCommand: () => mixtapeMembersCommand2,
3773
3885
  mixtapeListCommand: () => mixtapeListCommand,
@@ -3874,6 +3986,44 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
3874
3986
  }
3875
3987
  return { logId, mixtapeId, results };
3876
3988
  }
3989
+ async function mixtapeResyncCommand2(idOrLogId, options, onProgress = () => {}) {
3990
+ const mixtape = await mixtapeGetCommand(idOrLogId);
3991
+ if (!mixtape.id) {
3992
+ throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
3993
+ }
3994
+ if (!mixtape.logId) {
3995
+ throw new CliError2("mixtape_no_log_id", "The mixtape isn't published yet — distribute it before re-syncing.");
3996
+ }
3997
+ const mixtapeId = mixtape.id;
3998
+ const explicit = Boolean(options.youtube) || Boolean(options.mixcloud);
3999
+ let doYoutube = Boolean(options.youtube);
4000
+ let doMixcloud = Boolean(options.mixcloud);
4001
+ if (!explicit) {
4002
+ const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
4003
+ const platforms = new Set(social.posts.map((post) => post.platform));
4004
+ doYoutube = platforms.has("youtube");
4005
+ doMixcloud = platforms.has("mixcloud");
4006
+ if (!doYoutube && !doMixcloud) {
4007
+ throw new CliError2("mixtape_not_distributed", "The mixtape has no YouTube or Mixcloud link to re-sync.");
4008
+ }
4009
+ }
4010
+ const results = [];
4011
+ if (doYoutube) {
4012
+ onProgress("YouTube: re-syncing description + chapters…");
4013
+ const { resyncYoutube: resyncYoutube3 } = await Promise.resolve().then(() => (init_mixtape_youtube(), exports_mixtape_youtube));
4014
+ const result = await resyncYoutube3(mixtapeId);
4015
+ results.push({ platform: "youtube", url: result.url });
4016
+ onProgress(`YouTube: ${result.url}`);
4017
+ }
4018
+ if (doMixcloud) {
4019
+ onProgress("Mixcloud: re-syncing sections…");
4020
+ const { resyncMixcloud: resyncMixcloud2 } = await Promise.resolve().then(() => (init_mixtape_mixcloud(), exports_mixtape_mixcloud));
4021
+ const result = await resyncMixcloud2(mixtapeId, onProgress);
4022
+ results.push({ platform: "mixcloud", url: result.url });
4023
+ onProgress(`Mixcloud: ${result.url}`);
4024
+ }
4025
+ return { logId: mixtape.logId, mixtapeId, results };
4026
+ }
3877
4027
  async function probeDurationMs2(filePath) {
3878
4028
  try {
3879
4029
  const proc = Bun.spawn(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { stderr: "ignore", stdout: "pipe" });
@@ -4035,20 +4185,42 @@ function brandDrawtext(options) {
4035
4185
  const value = escapeDrawtextValue(options.text);
4036
4186
  const font = options.fontFile ? `:fontfile='${options.fontFile}'` : "";
4037
4187
  const border = options.borderw && options.borderw > 0 ? `:borderw=${options.borderw}:bordercolor=${CLIP_HALO_COLOR}` : "";
4038
- return `drawtext=text='${value}'${font}:` + `fontcolor=${options.color}:fontsize=${options.size}:` + `x=${options.x}:y=${options.y}${border}`;
4188
+ const enable = options.enable ? `:enable='${options.enable}'` : "";
4189
+ return `drawtext=text='${value}'${font}:` + `fontcolor=${options.color}:fontsize=${options.size}:` + `x=${options.x}:y=${options.y}${border}${enable}`;
4190
+ }
4191
+ function resolveTitleLines(options, y) {
4192
+ const inMs = options.inMs ?? 0;
4193
+ const outMs = options.outMs ?? 0;
4194
+ const clipDur = Math.max(0, (outMs - inMs) / 1000);
4195
+ const base = { fontFile: options.sansFontFile, size: CLIP_TITLE_SIZE, x: CLIP_MARGIN_X, y };
4196
+ const resolved = options.members && options.members.length > 0 ? resolveClipTracks({
4197
+ inMs,
4198
+ members: options.members,
4199
+ outMs,
4200
+ setDurationMs: options.setDurationMs ?? 0
4201
+ }) : [];
4202
+ if (resolved.length === 0) {
4203
+ return [{ ...base, text: options.title }];
4204
+ }
4205
+ const clamp = (seconds) => Math.min(Math.max(seconds, 0), clipDur);
4206
+ return resolved.map((track, index) => {
4207
+ const relStart = clamp((track.startMs - inMs) / 1000);
4208
+ const next = resolved[index + 1];
4209
+ const relEnd = next ? clamp((next.startMs - inMs) / 1000) : clipDur;
4210
+ return {
4211
+ ...base,
4212
+ enable: `between(t,${relStart.toFixed(3)},${relEnd.toFixed(3)})`,
4213
+ text: track.label
4214
+ };
4215
+ });
4039
4216
  }
4040
4217
  function clipCutFilterComplex(options) {
4041
4218
  const xOffset = Math.max(0, Math.round(options.xOffset));
4042
4219
  const crop = `crop=ih*9/16:ih:${xOffset}:0,scale=${CLIP_WIDTH}:${CLIP_HEIGHT}`;
4043
4220
  const coordinateBox = Math.round(CLIP_COORDINATE_SIZE * 1.18);
4044
4221
  const titleBottomOffset = CLIP_SAFE_BOTTOM + coordinateBox + CLIP_LINE_GAP;
4045
- const title = {
4046
- fontFile: options.sansFontFile,
4047
- size: CLIP_TITLE_SIZE,
4048
- text: options.title,
4049
- x: CLIP_MARGIN_X,
4050
- y: `h-${titleBottomOffset}-th`
4051
- };
4222
+ const titleY = `h-${titleBottomOffset}-th`;
4223
+ const titleLines = resolveTitleLines(options, titleY);
4052
4224
  const coordinate2 = {
4053
4225
  fontFile: options.oxaniumFontFile,
4054
4226
  size: CLIP_COORDINATE_SIZE,
@@ -4057,11 +4229,11 @@ function clipCutFilterComplex(options) {
4057
4229
  y: `h-${CLIP_SAFE_BOTTOM}-th`
4058
4230
  };
4059
4231
  const haloGlyphs = [
4060
- brandDrawtext({ ...title, color: CLIP_HALO_COLOR }),
4232
+ ...titleLines.map((line) => brandDrawtext({ ...line, color: CLIP_HALO_COLOR })),
4061
4233
  brandDrawtext({ ...coordinate2, color: CLIP_HALO_COLOR })
4062
4234
  ].join(",");
4063
4235
  const sharpGlyphs = [
4064
- brandDrawtext({ ...title, borderw: CLIP_SHARP_BORDERW, color: CLIP_TITLE_COLOR }),
4236
+ ...titleLines.map((line) => brandDrawtext({ ...line, borderw: CLIP_SHARP_BORDERW, color: CLIP_TITLE_COLOR })),
4065
4237
  brandDrawtext({ ...coordinate2, borderw: CLIP_SHARP_BORDERW, color: CLIP_COORDINATE_COLOR })
4066
4238
  ].join(",");
4067
4239
  return [
@@ -4148,10 +4320,12 @@ async function clipCutCommand(clipId, onProgress = () => {}) {
4148
4320
  await runClipCut({
4149
4321
  inMs: clip.inMs,
4150
4322
  logId: mixtape.logId,
4323
+ members: mixtape.members,
4151
4324
  outMs: clip.outMs,
4152
4325
  outputPath,
4153
4326
  oxaniumFontFile: resolveClipFontFile(),
4154
4327
  sansFontFile: resolveClipSansFontFile(),
4328
+ setDurationMs: mixtape.durationMs ?? 0,
4155
4329
  setUrl,
4156
4330
  title: mixtape.title,
4157
4331
  xOffset: clip.xOffset
@@ -4214,6 +4388,7 @@ async function runClipCut(options) {
4214
4388
  }
4215
4389
  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, CLIP_TITLE_COLOR = "0xf4ead7", CLIP_COORDINATE_COLOR = "0xb7ab95", CLIP_HALO_COLOR = "0x090a0b", CLIP_HALO_CORE_SIGMA = 3, CLIP_HALO_FEATHER_SIGMA = 9, CLIP_SHARP_BORDERW = 1, CLIP_MARGIN_X = 96, CLIP_SAFE_BOTTOM = 230, CLIP_LINE_GAP = 10, CLIP_TITLE_SIZE = 40, CLIP_COORDINATE_SIZE = 22, BOX_CLIP_FONT_FILE = "/opt/fonts/Oxanium-SemiBold.ttf", BOX_CLIP_SANS_FONT_FILE = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
4216
4390
  var init_clips = __esm(() => {
4391
+ init_util();
4217
4392
  init_api();
4218
4393
  init_output();
4219
4394
  MAX_CLIP_BYTES = 100 * 1024 * 1024;
@@ -4435,8 +4610,11 @@ var init_auth = __esm(() => {
4435
4610
  // src/commands/mixtape-mixcloud.ts
4436
4611
  var exports_mixtape_mixcloud2 = {};
4437
4612
  __export(exports_mixtape_mixcloud2, {
4613
+ resyncMixcloud: () => resyncMixcloud2,
4438
4614
  mixtapeDescription: () => mixtapeDescription2,
4439
4615
  mixcloudSections: () => mixcloudSections2,
4616
+ mixcloudSectionFields: () => mixcloudSectionFields2,
4617
+ mixcloudEditUrl: () => mixcloudEditUrl2,
4440
4618
  distributeMixcloud: () => distributeMixcloud2,
4441
4619
  authMixcloudCommand: () => authMixcloudCommand2
4442
4620
  });
@@ -4464,10 +4642,8 @@ async function distributeMixcloud2(mixtapeId, audioPath, onProgress, unlisted =
4464
4642
  form.append(`tags-${index}-tag`, tag);
4465
4643
  }
4466
4644
  const sections = mixcloudSections2(mixtape.members);
4467
- for (const [index, section] of sections.entries()) {
4468
- form.append(`sections-${index}-artist`, section.artist);
4469
- form.append(`sections-${index}-song`, section.song);
4470
- form.append(`sections-${index}-start_time`, String(section.start_time));
4645
+ for (const [name, value] of mixcloudSectionFields2(sections)) {
4646
+ form.append(name, value);
4471
4647
  }
4472
4648
  if (unlisted) {
4473
4649
  form.append("unlisted", "1");
@@ -4499,6 +4675,35 @@ async function distributeMixcloud2(mixtapeId, audioPath, onProgress, unlisted =
4499
4675
  });
4500
4676
  return { url };
4501
4677
  }
4678
+ async function resyncMixcloud2(mixtapeId, onProgress) {
4679
+ const token = await fetchMixcloudToken2();
4680
+ const mixtape = await mixtapeGetCommand(mixtapeId);
4681
+ const sections = mixcloudSections2(mixtape.members);
4682
+ if (sections.length === 0) {
4683
+ throw new CliError2("mixcloud_no_cues", "No cued members to sync — mark cues on the mixtape first.");
4684
+ }
4685
+ const social = await adminApiGet(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/social`);
4686
+ const mixcloud = social.posts.find((post) => post.platform === "mixcloud");
4687
+ const key = mixcloud?.externalId;
4688
+ if (!key) {
4689
+ throw new CliError2("mixcloud_not_distributed", "No Mixcloud cloudcast to re-sync — distribute the mixtape first.");
4690
+ }
4691
+ const form = new FormData;
4692
+ for (const [name, value] of mixcloudSectionFields2(sections)) {
4693
+ form.append(name, value);
4694
+ }
4695
+ onProgress?.(`Mixcloud: re-syncing ${sections.length} sections (no re-upload)…`);
4696
+ const response = await fetch(`${mixcloudEditUrl2(key)}?access_token=${encodeURIComponent(token)}`, { body: form, method: "POST" });
4697
+ const text = await response.text();
4698
+ if (!response.ok) {
4699
+ throwMixcloudError2(response.status, text);
4700
+ }
4701
+ const result = parseUploadResult2(text);
4702
+ if (!result.success) {
4703
+ throw new CliError2("mixcloud_edit_rejected", `Mixcloud rejected the section edit: ${result.message ?? text.slice(0, 300)}`);
4704
+ }
4705
+ return { url: mixcloud?.url ?? `https://www.mixcloud.com${key}` };
4706
+ }
4502
4707
  async function authMixcloudCommand2() {
4503
4708
  const response = await adminApiGet("/api/admin/mixcloud/auth/start");
4504
4709
  console.log(`Open this Mixcloud authorization URL:
@@ -4529,6 +4734,18 @@ function mixcloudSections2(members) {
4529
4734
  start_time: Math.floor(member.startMs / 1000)
4530
4735
  }));
4531
4736
  }
4737
+ function mixcloudSectionFields2(sections) {
4738
+ return sections.flatMap((section, index) => [
4739
+ [`sections-${index}-artist`, section.artist],
4740
+ [`sections-${index}-song`, section.song],
4741
+ [`sections-${index}-start_time`, String(section.start_time)]
4742
+ ]);
4743
+ }
4744
+ function mixcloudEditUrl2(key) {
4745
+ const withLeading = key.startsWith("/") ? key : `/${key}`;
4746
+ const path2 = withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
4747
+ return `${MIXCLOUD_API2}/upload${path2}edit/`;
4748
+ }
4532
4749
  function mixtapeTags2(_mixtape) {
4533
4750
  return ["Drum & Bass", "Fluncle"];
4534
4751
  }
@@ -7250,6 +7467,10 @@ function addAdminCommands(program2) {
7250
7467
  const { publishYoutubeCommand: publishYoutubeCommand3 } = await Promise.resolve().then(() => (init_mixtape_youtube2(), exports_mixtape_youtube2));
7251
7468
  await runMixtapePublishYoutube(idOrLogId, options, publishYoutubeCommand3);
7252
7469
  });
7470
+ adminMixtapes.command("resync").description("Re-push a published mixtape's YouTube chapters + Mixcloud sections from its current cues (no re-upload)").argument("[idOrLogId]").option("--youtube", "Only re-sync YouTube").option("--mixcloud", "Only re-sync Mixcloud").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7471
+ const { mixtapeResyncCommand: mixtapeResyncCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7472
+ await runMixtapeResync(idOrLogId, options, mixtapeResyncCommand3);
7473
+ });
7253
7474
  const adminClips = configureCommand(admin.command("clips").description("Mixtape clip (Fluncle Studio) commands"));
7254
7475
  adminClips.action(() => {
7255
7476
  adminClips.outputHelp();
@@ -7809,6 +8030,21 @@ async function runMixtapePublishYoutube(idOrLogId, options, publishYoutubeComman
7809
8030
  }
7810
8031
  console.log(`YouTube video is now public: ${result.url}`);
7811
8032
  }
8033
+ async function runMixtapeResync(idOrLogId, options, mixtapeResyncCommand3) {
8034
+ if (!idOrLogId) {
8035
+ throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes resync <idOrLogId>");
8036
+ }
8037
+ const onProgress = options.json ? () => {} : (message) => console.log(message);
8038
+ const result = await mixtapeResyncCommand3(idOrLogId, options, onProgress);
8039
+ if (options.json) {
8040
+ printJson(result);
8041
+ return;
8042
+ }
8043
+ const links = result.results.map((r) => ` ${r.platform}: ${r.url}`).join(`
8044
+ `);
8045
+ console.log(`Re-synced ${result.logId} (fluncle://${result.logId}).
8046
+ ${links}`);
8047
+ }
7812
8048
  async function runMixtapeDelete(id, options, mixtapeDeleteCommand3) {
7813
8049
  if (!id) {
7814
8050
  throw new Error("Missing mixtape id. Usage: fluncle admin mixtapes delete <id> --yes");
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.75.0"
34
+ "version": "0.77.0"
35
35
  }