fluncle 0.218.0 → 0.220.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 +213 -16
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -610,7 +610,7 @@ function parseVersion(version) {
610
610
  var currentVersion;
611
611
  var init_version = __esm(() => {
612
612
  init_output();
613
- currentVersion = "0.218.0".trim() ? "0.218.0".trim() : "0.1.0";
613
+ currentVersion = "0.220.0".trim() ? "0.220.0".trim() : "0.1.0";
614
614
  });
615
615
 
616
616
  // src/update-notifier.ts
@@ -2452,7 +2452,7 @@ function parseVersion2(version) {
2452
2452
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2453
2453
  var init_version2 = __esm(() => {
2454
2454
  init_output();
2455
- currentVersion2 = "0.218.0".trim() ? "0.218.0".trim() : "0.1.0";
2455
+ currentVersion2 = "0.220.0".trim() ? "0.220.0".trim() : "0.1.0";
2456
2456
  });
2457
2457
 
2458
2458
  // ../../packages/registry/src/index.ts
@@ -2779,7 +2779,7 @@ var init_src = __esm(() => {
2779
2779
  },
2780
2780
  {
2781
2781
  exposedContent: [
2782
- "the R2 media zone each finding's video bundle + mixtape audio",
2782
+ "the R2 media zone: each finding's video bundle and mixtape audio",
2783
2783
  "the /cdn-cgi/media transform base (same zone, no cross-origin)"
2784
2784
  ],
2785
2785
  kind: "subdomain",
@@ -2810,7 +2810,7 @@ var init_src = __esm(() => {
2810
2810
  },
2811
2811
  {
2812
2812
  exposedContent: [
2813
- "the Tor onion mirror of www.fluncle.com the archive, API, RSS, and MCP over Tor"
2813
+ "the Tor onion mirror of www.fluncle.com: the archive, API, RSS, and MCP over Tor"
2814
2814
  ],
2815
2815
  kind: "subdomain",
2816
2816
  name: "subdomain.onion",
@@ -7016,15 +7016,48 @@ var init_admin_reach = __esm(() => {
7016
7016
  var exports_admin_artists = {};
7017
7017
  __export(exports_admin_artists, {
7018
7018
  resolveArtistCommand: () => resolveArtistCommand,
7019
+ removeArtistRuleCommand: () => removeArtistRuleCommand,
7019
7020
  rankArtistsCommand: () => rankArtistsCommand,
7020
7021
  listArtistsCommand: () => listArtistsCommand,
7022
+ listArtistRulesCommand: () => listArtistRulesCommand,
7021
7023
  draftArtistBioCommand: () => draftArtistBioCommand,
7022
7024
  describeArtistCommand: () => describeArtistCommand,
7023
7025
  buildBioBody: () => buildBioBody,
7024
7026
  backfillArtistsCommand: () => backfillArtistsCommand,
7025
7027
  backfillArtistImagesCommand: () => backfillArtistImagesCommand,
7026
- artistsBioQueueCommand: () => artistsBioQueueCommand
7028
+ artistsBioQueueCommand: () => artistsBioQueueCommand,
7029
+ artistRuleInput: () => artistRuleInput,
7030
+ addArtistRuleCommand: () => addArtistRuleCommand
7027
7031
  });
7032
+ function artistRuleInput(artistMbid, verdict, artistName) {
7033
+ const cleanMbid = artistMbid.trim();
7034
+ if (!MBID_PATTERN.test(cleanMbid)) {
7035
+ throw new Error("Artist MBID must be a MusicBrainz artist MBID");
7036
+ }
7037
+ if (verdict !== "allow" && verdict !== "block") {
7038
+ throw new Error("Pass --verdict allow|block");
7039
+ }
7040
+ const cleanName = artistName?.trim();
7041
+ if (artistName !== undefined && !cleanName) {
7042
+ throw new Error("--name must be a non-empty artist name");
7043
+ }
7044
+ return {
7045
+ artistMbid: cleanMbid.toLowerCase(),
7046
+ ...cleanName ? { artistName: cleanName } : {},
7047
+ verdict
7048
+ };
7049
+ }
7050
+ async function listArtistRulesCommand() {
7051
+ const response = await adminApiGet("/api/v1/admin/artist-rules");
7052
+ return response.rules;
7053
+ }
7054
+ async function addArtistRuleCommand(input) {
7055
+ const response = await adminApiPost("/api/v1/admin/artist-rules", input);
7056
+ return response.rule;
7057
+ }
7058
+ async function removeArtistRuleCommand(id) {
7059
+ return adminApiDelete(`/api/v1/admin/artist-rules/${encodeURIComponent(id)}`);
7060
+ }
7028
7061
  function buildBioBody(options) {
7029
7062
  const body = { bio: options.bio };
7030
7063
  if (options.dryRun) {
@@ -7077,8 +7110,10 @@ async function backfillArtistsCommand(limit, dryRun, cursor) {
7077
7110
  }
7078
7111
  return adminApiPost(`/api/v1/admin/backfill/artists?${params.toString()}`);
7079
7112
  }
7113
+ var MBID_PATTERN;
7080
7114
  var init_admin_artists = __esm(() => {
7081
7115
  init_api();
7116
+ MBID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7082
7117
  });
7083
7118
 
7084
7119
  // src/commands/admin-artists.ts
@@ -7103,24 +7138,88 @@ var init_admin_artists2 = __esm(() => {
7103
7138
  var exports_admin_labels = {};
7104
7139
  __export(exports_admin_labels, {
7105
7140
  updateLabelCommand: () => updateLabelCommand,
7141
+ replaceLabelArtistRulesCommand: () => replaceLabelArtistRulesCommand,
7142
+ parseLabelArtistRulesJson: () => parseLabelArtistRulesJson,
7106
7143
  mergeLabelCommand: () => mergeLabelCommand,
7144
+ listLabelArtistRulesCommand: () => listLabelArtistRulesCommand,
7107
7145
  labelsBioQueueCommand: () => labelsBioQueueCommand,
7108
7146
  draftLabelBioCommand: () => draftLabelBioCommand,
7109
7147
  describeLabelCommand: () => describeLabelCommand,
7110
7148
  backfillLabelLineageCommand: () => backfillLabelLineageCommand,
7111
7149
  backfillLabelImagesCommand: () => backfillLabelImagesCommand
7112
7150
  });
7113
- async function mergeLabelCommand(losingSlug, canonicalSlug) {
7114
- const response = await adminApiPost(`/api/v1/admin/labels/${encodeURIComponent(losingSlug)}/merge`, { canonicalSlug });
7115
- return response.result;
7151
+ function requireArtistMbid(value, at) {
7152
+ if (typeof value !== "string" || !MBID_PATTERN2.test(value.trim())) {
7153
+ throw new Error(`${at}.artistMbid must be a MusicBrainz artist MBID`);
7154
+ }
7155
+ return value.trim().toLowerCase();
7156
+ }
7157
+ function requireArtistName(value, at) {
7158
+ if (typeof value !== "string" || value.trim().length === 0) {
7159
+ throw new Error(`${at}.artistName must be a non-empty string`);
7160
+ }
7161
+ return value.trim();
7162
+ }
7163
+ function requireArtistVerdict(value, at) {
7164
+ if (value === "allow" || value === "block") {
7165
+ return value;
7166
+ }
7167
+ throw new Error(`${at}.verdict must be 'allow' or 'block'`);
7116
7168
  }
7117
- async function updateLabelCommand(slugOrId, seedState) {
7169
+ function parseLabelArtistRulesJson(source) {
7170
+ let parsed;
7171
+ try {
7172
+ parsed = JSON.parse(source);
7173
+ } catch {
7174
+ throw new Error("Rules file must contain valid JSON");
7175
+ }
7176
+ if (!Array.isArray(parsed)) {
7177
+ throw new Error("Rules file must contain a JSON array");
7178
+ }
7179
+ if (parsed.length > 100) {
7180
+ throw new Error("Rules file may contain at most 100 artist rules");
7181
+ }
7182
+ return parsed.map((entry, index) => {
7183
+ const at = `Rules file entry ${index + 1}`;
7184
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
7185
+ throw new Error(`${at} must be an object`);
7186
+ }
7187
+ const value = entry;
7188
+ return {
7189
+ artistMbid: requireArtistMbid(value.artistMbid, at),
7190
+ artistName: requireArtistName(value.artistName, at),
7191
+ verdict: requireArtistVerdict(value.verdict, at)
7192
+ };
7193
+ });
7194
+ }
7195
+ async function resolveLabel(slugOrId) {
7118
7196
  const { labels } = await adminApiGet("/api/v1/admin/labels");
7119
7197
  const match = labels.find((label) => label.slug === slugOrId || label.id === slugOrId);
7120
7198
  if (!match) {
7121
7199
  throw new Error(`No label with slug or id '${slugOrId}' — check \`fluncle labels\``);
7122
7200
  }
7123
- const response = await adminApiPatch(`/api/v1/admin/labels/${encodeURIComponent(match.id)}`, { seedState });
7201
+ return match;
7202
+ }
7203
+ async function listLabelArtistRulesCommand(slugOrId) {
7204
+ const label = await resolveLabel(slugOrId);
7205
+ const response = await adminApiGet(`/api/v1/admin/labels/${encodeURIComponent(label.id)}/artists`);
7206
+ return { label, rules: response.rules };
7207
+ }
7208
+ async function replaceLabelArtistRulesCommand(slugOrId, rules) {
7209
+ const label = await resolveLabel(slugOrId);
7210
+ const response = await adminApiPut(`/api/v1/admin/labels/${encodeURIComponent(label.id)}/artists`, { rules });
7211
+ return { label, rules: response.rules };
7212
+ }
7213
+ async function mergeLabelCommand(losingSlug, canonicalSlug) {
7214
+ const response = await adminApiPost(`/api/v1/admin/labels/${encodeURIComponent(losingSlug)}/merge`, { canonicalSlug });
7215
+ return response.result;
7216
+ }
7217
+ async function updateLabelCommand(slugOrId, seedState, rewalk = false) {
7218
+ const match = await resolveLabel(slugOrId);
7219
+ const response = await adminApiPatch(`/api/v1/admin/labels/${encodeURIComponent(match.id)}`, {
7220
+ ...rewalk ? { rewalk: true } : {},
7221
+ ...seedState === undefined ? {} : { seedState }
7222
+ });
7124
7223
  return response.label;
7125
7224
  }
7126
7225
  async function describeLabelCommand(slug, options) {
@@ -7147,9 +7246,11 @@ async function backfillLabelLineageCommand(limit, dryRun, cursor) {
7147
7246
  }
7148
7247
  return adminApiPost(`/api/v1/admin/backfill/label-lineage?${params.toString()}`);
7149
7248
  }
7249
+ var MBID_PATTERN2;
7150
7250
  var init_admin_labels = __esm(() => {
7151
7251
  init_api();
7152
7252
  init_admin_artists2();
7253
+ MBID_PATTERN2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7153
7254
  });
7154
7255
 
7155
7256
  // src/commands/admin-covers.ts
@@ -9678,6 +9779,38 @@ function toJsonFailure(error) {
9678
9779
  init_util();
9679
9780
 
9680
9781
  // src/cli.ts
9782
+ function labelUpdateLines(label, options) {
9783
+ const lines = [];
9784
+ if (options.seedState !== undefined) {
9785
+ lines.push(`${label.name} (${label.slug}) \u2192 ${label.seedState}.`);
9786
+ lines.push(label.seedState === "enabled" ? " The next crawl seeds from it and stores its releases." : " The next crawl will not seed from it; nothing already stored is touched.");
9787
+ }
9788
+ if (options.rewalk) {
9789
+ lines.push(`${label.name} (${label.slug}) \u2192 scoped re-walk armed.`);
9790
+ lines.push(" The next crawl rechecks its MusicBrainz releases.");
9791
+ }
9792
+ return lines;
9793
+ }
9794
+ var ARTIST_RULE_BOUNDARY = "Rules change what the next crawl takes. Everything already here stays.";
9795
+ function artistRuleWriteLine(prefix) {
9796
+ return `${prefix} \u2014 ${ARTIST_RULE_BOUNDARY.replace(/^R/, "r")}`;
9797
+ }
9798
+ function artistRuleLines(rules) {
9799
+ if (rules.length === 0) {
9800
+ return ["No artist rules."];
9801
+ }
9802
+ const rows = rules.map((rule) => [
9803
+ rule.id,
9804
+ rule.verdict,
9805
+ rule.resolvedName ?? rule.artistName,
9806
+ rule.resolvedMbid ?? rule.artistMbid,
9807
+ rule.artistSpotifyId ?? "unresolved"
9808
+ ]);
9809
+ const headings = ["ID", "VERDICT", "ARTIST", "MUSICBRAINZ", "SPOTIFY"];
9810
+ const widths = headings.map((heading, index) => Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)));
9811
+ const line = (row) => row.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ").trimEnd();
9812
+ return [line(headings), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)];
9813
+ }
9681
9814
  function createProgram() {
9682
9815
  const program2 = configureCommand(new Command);
9683
9816
  program2.name("fluncle").description(fluncleTagline.toLowerCase()).option("--env <local|production>", "Config profile to load (default: production)").showSuggestionAfterError(false).addHelpCommand("help [command]", "display help for command").hook("preAction", (_thisCommand, actionCommand) => {
@@ -10571,6 +10704,34 @@ JSON field reference:
10571
10704
  const { rankArtistsCommand: rankArtistsCommand2 } = await Promise.resolve().then(() => (init_admin_artists(), exports_admin_artists));
10572
10705
  await runArtistsRank(options, rankArtistsCommand2);
10573
10706
  });
10707
+ artists.command("rule").description("Always or never take one MusicBrainz artist's records (operator)").argument("<artist-mbid>", "The MusicBrainz artist MBID").requiredOption("--verdict <verdict>", "The ruling: allow or block").option("--name <name>", "Artist name (optional; the server resolves it when omitted)").option("--json", "Print JSON", false).action(async (artistMbid, options) => {
10708
+ const { addArtistRuleCommand: addArtistRuleCommand2, artistRuleInput: artistRuleInput2 } = await Promise.resolve().then(() => (init_admin_artists(), exports_admin_artists));
10709
+ const rule = await addArtistRuleCommand2(artistRuleInput2(artistMbid, options.verdict, options.name));
10710
+ if (options.json) {
10711
+ printJson({ ok: true, rule });
10712
+ return;
10713
+ }
10714
+ console.log(artistRuleWriteLine(`Rule set for ${rule.resolvedName ?? rule.artistName}: ${rule.verdict.toUpperCase()}`));
10715
+ });
10716
+ artists.command("rules").description("List the global artist crawl rules").option("--json", "Print JSON", false).action(async (options) => {
10717
+ const { listArtistRulesCommand: listArtistRulesCommand2 } = await Promise.resolve().then(() => (init_admin_artists(), exports_admin_artists));
10718
+ const rules = await listArtistRulesCommand2();
10719
+ if (options.json) {
10720
+ printJson({ ok: true, rules });
10721
+ return;
10722
+ }
10723
+ console.log(artistRuleLines(rules).join(`
10724
+ `));
10725
+ });
10726
+ artists.command("unrule").description("Remove one global artist crawl rule (operator)").argument("<id>", "The artist rule id").option("--json", "Print JSON", false).action(async (id, options) => {
10727
+ const { removeArtistRuleCommand: removeArtistRuleCommand2 } = await Promise.resolve().then(() => (init_admin_artists(), exports_admin_artists));
10728
+ const result = await removeArtistRuleCommand2(id);
10729
+ if (options.json) {
10730
+ printJson(result);
10731
+ return;
10732
+ }
10733
+ console.log(artistRuleWriteLine(`Rule removed: ${id}`));
10734
+ });
10574
10735
  artists.command("draft-bio").description("Assemble the Worker-grounded bio prompt for an artist (the sweep's trigger)").argument("<slug>").option("--json", "Print JSON", false).allowExcessArguments().action(async (slug, options) => {
10575
10736
  const { draftArtistBioCommand: draftArtistBioCommand2 } = await Promise.resolve().then(() => (init_admin_artists(), exports_admin_artists));
10576
10737
  await runEntityBioDraft("artist", slug, options, draftArtistBioCommand2);
@@ -10592,19 +10753,48 @@ JSON field reference:
10592
10753
  const { draftLabelBioCommand: draftLabelBioCommand2 } = await Promise.resolve().then(() => (init_admin_labels(), exports_admin_labels));
10593
10754
  await runEntityBioDraft("label", slug, options, draftLabelBioCommand2);
10594
10755
  });
10595
- labels.command("update").description("Rule on a label's crawl-seed state (operator; next crawl only, never storage)").argument("<slug>", "The label's slug (an exact lbl_\u2026 id also works)").option("--seed-state <state>", "The ruling: enabled, disabled, or undecided").option("--json", "Print JSON", false).action(async (slug, options) => {
10756
+ labels.command("artists").description("List or replace one label's artist crawl rules").argument("<slug>", "The label's slug (an exact lbl_\u2026 id also works)").option("--replace", "Replace the complete rule set from --rules-file", false).option("--rules-file <file>", "JSON array of { artistMbid, artistName, verdict } rules").option("--json", "Print JSON", false).action(async (slug, options) => {
10757
+ const {
10758
+ listLabelArtistRulesCommand: listLabelArtistRulesCommand2,
10759
+ parseLabelArtistRulesJson: parseLabelArtistRulesJson2,
10760
+ replaceLabelArtistRulesCommand: replaceLabelArtistRulesCommand2
10761
+ } = await Promise.resolve().then(() => (init_admin_labels(), exports_admin_labels));
10762
+ if (!options.replace && options.rulesFile !== undefined) {
10763
+ throw new Error("Pass --replace with --rules-file");
10764
+ }
10765
+ if (options.replace && options.rulesFile === undefined) {
10766
+ throw new Error("Pass --rules-file <json> with --replace");
10767
+ }
10768
+ const result = options.replace ? await replaceLabelArtistRulesCommand2(slug, parseLabelArtistRulesJson2(readFileSync7(options.rulesFile ?? "", "utf8"))) : await listLabelArtistRulesCommand2(slug);
10769
+ if (options.json) {
10770
+ printJson({ ok: true, rules: result.rules });
10771
+ return;
10772
+ }
10773
+ if (options.replace) {
10774
+ const noun = result.rules.length === 1 ? "rule" : "rules";
10775
+ console.log(artistRuleWriteLine(`${result.rules.length} ${noun} set`));
10776
+ return;
10777
+ }
10778
+ console.log(artistRuleLines(result.rules).join(`
10779
+ `));
10780
+ });
10781
+ labels.command("update").description("Rule on a label's crawl scope or arm a scoped re-walk (operator)").argument("<slug>", "The label's slug (an exact lbl_\u2026 id also works)").option("--seed-state <state>", "The ruling: enabled, disabled, or undecided").option("--rewalk", "Arm a scoped re-walk without changing the ruling", false).option("--json", "Print JSON", false).action(async (slug, options) => {
10596
10782
  const seedState = options.seedState;
10597
- if (seedState !== "enabled" && seedState !== "disabled" && seedState !== "undecided") {
10783
+ if (seedState !== undefined && seedState !== "enabled" && seedState !== "disabled" && seedState !== "undecided") {
10598
10784
  throw new Error("Pass --seed-state enabled|disabled|undecided");
10599
10785
  }
10786
+ if (seedState === undefined && !options.rewalk) {
10787
+ throw new Error("Pass --seed-state enabled|disabled|undecided or --rewalk");
10788
+ }
10600
10789
  const { updateLabelCommand: updateLabelCommand2 } = await Promise.resolve().then(() => (init_admin_labels(), exports_admin_labels));
10601
- const label = await updateLabelCommand2(slug, seedState);
10790
+ const label = await updateLabelCommand2(slug, seedState, options.rewalk);
10602
10791
  if (options.json) {
10603
10792
  printJson({ label, ok: true });
10604
10793
  return;
10605
10794
  }
10606
- console.log(`${label.name} (${label.slug}) \u2192 ${label.seedState}.`);
10607
- console.log(label.seedState === "enabled" ? " The next crawl seeds from it and stores its releases." : " The next crawl will not seed from it; nothing already stored is touched.");
10795
+ for (const line of labelUpdateLines(label, options)) {
10796
+ console.log(line);
10797
+ }
10608
10798
  });
10609
10799
  labels.command("merge").description("Merge a slug-split label into its canonical row (operator; re-points + redirects)").argument("<losingSlug>", "The duplicate label to fold away").argument("<canonicalSlug>", "The label to keep \u2014 everything re-points onto it").option("--json", "Print JSON", false).action(async (losingSlug, canonicalSlug, options) => {
10610
10800
  const { mergeLabelCommand: mergeLabelCommand2 } = await Promise.resolve().then(() => (init_admin_labels(), exports_admin_labels));
@@ -10616,12 +10806,14 @@ JSON field reference:
10616
10806
  const {
10617
10807
  aliasWritten,
10618
10808
  canonicalSlug: canon,
10809
+ droppedRules,
10619
10810
  losingSlug: loser,
10620
10811
  reconciled,
10621
10812
  repointed
10622
10813
  } = result;
10623
10814
  console.log(`Merged ${loser} \u2192 ${canon}.`);
10624
10815
  console.log(` Re-pointed: ${repointed.tracks} track(s), ${repointed.childLabels} sublabel(s), ${repointed.aliases} alias(es).`);
10816
+ console.log(` Dropped: ${droppedRules} loser-scoped artist rule(s).`);
10625
10817
  console.log(reconciled.length > 0 ? ` Filled onto ${canon} (was empty): ${reconciled.join(", ")}.` : ` Nothing to fill \u2014 ${canon} already carried every fact.`);
10626
10818
  console.log(` Seed state resolved to: ${result.seedState}.`);
10627
10819
  console.log(` Alias written: "${aliasWritten.alias}" (${aliasWritten.aliasSlug}) \u2192 confirmed, so it can never re-mint.`);
@@ -13534,6 +13726,7 @@ var stringOptions = new Set([
13534
13726
  "--mime",
13535
13727
  "--min-phrase-words",
13536
13728
  "--model",
13729
+ "--name",
13537
13730
  "--note",
13538
13731
  "--ok",
13539
13732
  "--order",
@@ -13549,6 +13742,7 @@ var stringOptions = new Set([
13549
13742
  "--reasoning",
13550
13743
  "--recorded-at",
13551
13744
  "--recording",
13745
+ "--rules-file",
13552
13746
  "--render",
13553
13747
  "--scene",
13554
13748
  "--scheduled-for",
@@ -13615,5 +13809,8 @@ if (__require.main == __require.module) {
13615
13809
  }
13616
13810
  export {
13617
13811
  parseTelemetryTimestamp,
13618
- createProgram
13812
+ labelUpdateLines,
13813
+ createProgram,
13814
+ artistRuleLines,
13815
+ ARTIST_RULE_BOUNDARY
13619
13816
  };
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.218.0"
34
+ "version": "0.220.0"
35
35
  }