fluncle 0.161.0 → 0.163.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 +138 -23
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -561,7 +561,7 @@ function parseVersion(version) {
561
561
  var currentVersion;
562
562
  var init_version = __esm(() => {
563
563
  init_output();
564
- currentVersion = "0.161.0".trim() ? "0.161.0".trim() : "0.1.0";
564
+ currentVersion = "0.163.0".trim() ? "0.163.0".trim() : "0.1.0";
565
565
  });
566
566
 
567
567
  // src/update-notifier.ts
@@ -2189,7 +2189,7 @@ function parseVersion2(version) {
2189
2189
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2190
2190
  var init_version2 = __esm(() => {
2191
2191
  init_output();
2192
- currentVersion2 = "0.161.0".trim() ? "0.161.0".trim() : "0.1.0";
2192
+ currentVersion2 = "0.163.0".trim() ? "0.163.0".trim() : "0.1.0";
2193
2193
  });
2194
2194
 
2195
2195
  // ../../packages/registry/src/index.ts
@@ -2386,6 +2386,19 @@ var init_src = __esm(() => {
2386
2386
  url: `${SITE}/albums`,
2387
2387
  weights: { web: "secondary" }
2388
2388
  },
2389
+ {
2390
+ discoveryUrl: `${SITE}/llms.txt`,
2391
+ exposedContent: [
2392
+ "/fresh — what just came out: every drum & bass track released in the trailing 30-day window, freshest first, findings in full voice and the quieter rows in the unlit register"
2393
+ ],
2394
+ kind: "web_route",
2395
+ name: "web.fresh",
2396
+ operatorNotes: "The new-releases lens over the whole archive — the SEO answer to 'new dnb releases', a weekly-refreshed query. Orders by tracks.release_date (when a tune CAME OUT), never findings.added_at (when Fluncle FOUND it) — the two are unrelated, and the copy never claims he found these. A HUB, so it is always indexable + listed unconditionally in the sitemap (like /albums), never the per-detail thin-content gate. The window read rides the tracks_release_date_idx btree so it stays a bounded range scan as the catalogue grows (lib/server/fresh.ts). The INDEX is always-200, so it is HTTP-probeable.",
2397
+ probeConfig: { cadenceMs: PROBE_CADENCE_MS, kind: "http", timeoutMs: PROBE_TIMEOUT_MS },
2398
+ route: "/fresh",
2399
+ url: `${SITE}/fresh`,
2400
+ weights: { web: "secondary" }
2401
+ },
2389
2402
  {
2390
2403
  exposedContent: [
2391
2404
  "/galaxies — the browse-by-feel lens: the archive grouped into operator-named sonic galaxies (k-means over the MuQ audio embedding space)",
@@ -2500,6 +2513,18 @@ var init_src = __esm(() => {
2500
2513
  url: `${SITE}/api/v1/tracks/random`,
2501
2514
  weights: { web: "secondary" }
2502
2515
  },
2516
+ {
2517
+ apiFormat: "application/json",
2518
+ discoveryUrl: `${SITE}/api/v1/openapi.json`,
2519
+ exposedContent: [
2520
+ "what just came out — newest drum & bass releases over a 30-day window, flat (limit max 100)"
2521
+ ],
2522
+ kind: "api",
2523
+ name: "api.fresh",
2524
+ route: "/api/v1/tracks/fresh",
2525
+ url: `${SITE}/api/v1/tracks/fresh`,
2526
+ weights: { web: "secondary" }
2527
+ },
2503
2528
  {
2504
2529
  apiFormat: "application/json",
2505
2530
  discoveryUrl: `${SITE}/api/v1/openapi.json`,
@@ -2969,6 +2994,18 @@ var init_src = __esm(() => {
2969
2994
  title: "Track ranking",
2970
2995
  weights: { status: "hidden" }
2971
2996
  },
2997
+ {
2998
+ exposedContent: [
2999
+ "find each un-anchored catalogue row's Spotify track via Apify → its spotify_uri/spotify_url anchor"
3000
+ ],
3001
+ kind: "cron",
3002
+ name: "cron.anchor",
3003
+ operatorNotes: "hourly, run by a rave-02 HOST systemd timer (docs/agents/hermes/anchor-timer/). Fills the catalogue Spotify anchor OFF the official (dev-mode) Spotify app, which starved under 429s at catalogue scale and must stay for user-facing paths: the box runs an Apify Spotify-scraper actor to find candidates for each un-anchored catalogue row and POSTs them to the agent-tier `anchor_track` op, where the WORKER re-runs verification (exact ISRC, else the folded artist+title+±2s search triple — the box's verdict is never trusted) and writes the anchor on a hit. Every attempt stamps a 14-day re-ask backoff so a miss is not re-billed. The ONE new secret is APIFY_API_TOKEN; each row is a billed Apify search (~$0.015), so the default 15 rows/hour ≈ $5-6/day while the backlog drains — pause by stopping the timer, burn attended with `--limit N`. Calls the oRPC HTTP endpoints directly (no new CLI command the pinned box CLI would lack). Source: docs/agents/hermes/scripts/anchor-sweep.*. See docs/catalogue-crawler.md § the anchor.",
3004
+ probeConfig: { cadenceMs: 60 * MINUTE_MS, cronName: "fluncle-anchor", kind: "cron" },
3005
+ statusDescription: "finds each catalogue track's Spotify link",
3006
+ title: "Spotify anchors",
3007
+ weights: { status: "hidden" }
3008
+ },
2972
3009
  {
2973
3010
  command: "fluncle admin frontier refresh",
2974
3011
  exposedContent: [
@@ -2999,6 +3036,19 @@ var init_src = __esm(() => {
2999
3036
  title: "Label logos",
3000
3037
  weights: { status: "hidden" }
3001
3038
  },
3039
+ {
3040
+ command: "fluncle admin backfills recording-mbids",
3041
+ exposedContent: [
3042
+ "fill each track's canonical MusicBrainz recording MBID (crawler PK strip + ISRC resolve)"
3043
+ ],
3044
+ kind: "cron",
3045
+ name: "cron.recording-mbids",
3046
+ operatorNotes: "every 60m, run by a rave-02 HOST systemd timer (docs/agents/hermes/recording-mbids-timer/). The MusicBrainz identity layer: gives every track its canonical MusicBrainz recording MBID — the one identifier that reconciles a track to the wider open music graph (MusicBrainz, Wikidata) and the anchor the `/log` MusicRecording emits as a `sameAs` + a KG `identifier`. Two fill paths: a FREE SQL strip of crawler-born rows' PK (`mb_<recording-mbid>` → the `mb_recording_id` column, no vendor call), then an ISRC→recording resolve of the findings/Spotify-born tail through the shared MusicBrainz client (`/isrc/<isrc>`). New crawler rows already carry the MBID at mint time, so this cron catches history up + drains the ISRC tail. METADATA IDENTITY ONLY — it certifies nothing and publishes nothing (agent tier, the `backfill_label_images` precedent). Worker-paced (the box holds no MusicBrainz budget): one bounded batch per tick, 1 req/s, circuit-broken on a throttle. The `tracks` row carries the durable reliability state (`mb_recording_id` + the `mb_recording_id_attempted_at` stamp, a miss stamped so it is not re-queried forever). Zero LLM tokens. Source: docs/agents/hermes/scripts/recording-mbids-sweep.*. See docs/catalogue-crawler.md.",
3047
+ probeConfig: { cadenceMs: 60 * MINUTE_MS, cronName: "fluncle-recording-mbids", kind: "cron" },
3048
+ statusDescription: "fills each track's MusicBrainz recording id",
3049
+ title: "Recording MBIDs",
3050
+ weights: { status: "hidden" }
3051
+ },
3002
3052
  {
3003
3053
  command: "fluncle admin backfills cover-masters",
3004
3054
  exposedContent: [
@@ -3332,6 +3382,23 @@ var init_src = __esm(() => {
3332
3382
  statusDescription: "reviews + merges the nightly audit PR",
3333
3383
  title: "Audit reviewer",
3334
3384
  weights: { status: "secondary" }
3385
+ },
3386
+ {
3387
+ exposedContent: [
3388
+ "nightly Sentry triage — reads the day's unresolved production errors and opens a fix PR for each straightforward one, files the rest (claude -p, subscription auth)"
3389
+ ],
3390
+ kind: "cron",
3391
+ name: "cron.sentry-triage",
3392
+ operatorNotes: "03:30 Amsterdam, a rave-02 host systemd timer (docs/agents/hermes/sentry-triage-timer/). Its OWN cron, not the audit rotation — checks Sentry every night. A full agentic claude -p session: reconciles merged fixes (resolves their issues), pulls new unresolved issues from both Sentry projects, fixes the straightforward ones (one PR each, `Sentry-Issue:` refs so a merge resolves the issue), files the rest to docs/sentry-backlog.md. The deterministic Sentry API work lives in sentry-triage-sweep.ts so the Sentry token never enters claude. Subscription auth (CLAUDE_CODE_OAUTH_TOKEN), zero OpenRouter tokens. Operator-gated on SENTRY_TRIAGE_TOKEN (skips cleanly until set). Source: docs/agents/hermes/scripts/sentry-triage-sweep.sh. Probed on /status as service `cron.sentry-triage`.",
3393
+ probeConfig: {
3394
+ cadenceMs: 24 * 60 * MINUTE_MS,
3395
+ cronName: "fluncle-sentry-triage",
3396
+ kind: "cron",
3397
+ schedule: { time: "03:30", tz: "Europe/Amsterdam" }
3398
+ },
3399
+ statusDescription: "nightly triage of production errors → fix PRs",
3400
+ title: "Sentry triage",
3401
+ weights: { status: "secondary" }
3335
3402
  }
3336
3403
  ];
3337
3404
  });
@@ -3837,6 +3904,7 @@ __export(exports_admin_tracks, {
3837
3904
  embedQueueCommand: () => embedQueueCommand,
3838
3905
  contextQueueCommand: () => contextQueueCommand,
3839
3906
  captureQueueCommand: () => captureQueueCommand,
3907
+ backfillRecordingMbidsCommand: () => backfillRecordingMbidsCommand,
3840
3908
  backfillLastfmCommand: () => backfillLastfmCommand,
3841
3909
  backfillDiscogsCommand: () => backfillDiscogsCommand,
3842
3910
  backfillAppleMusicCommand: () => backfillAppleMusicCommand,
@@ -4024,6 +4092,13 @@ async function backfillAppleCatalogueCommand(limit, dryRun) {
4024
4092
  const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
4025
4093
  return adminApiPost(`/api/admin/backfill/apple-catalogue?${params.toString()}`);
4026
4094
  }
4095
+ async function backfillRecordingMbidsCommand(limit, dryRun, cursor) {
4096
+ const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
4097
+ if (cursor) {
4098
+ params.set("cursor", cursor);
4099
+ }
4100
+ return adminApiPost(`/api/admin/backfill/recording-mbids?${params.toString()}`);
4101
+ }
4027
4102
  async function vehiclesCommand(limit) {
4028
4103
  const tracks = await fetchAdminTracks({ hasVideo: true, max: limit, order: "desc" });
4029
4104
  return tracks.map((track) => ({
@@ -9358,6 +9433,10 @@ function addAdminCommands(program2) {
9358
9433
  const { backfillLabelImagesCommand: backfillLabelImagesCommand2 } = await Promise.resolve().then(() => (init_admin_labels(), exports_admin_labels));
9359
9434
  await runBackfillLabelImages(options, backfillLabelImagesCommand2);
9360
9435
  });
9436
+ backfill.command("recording-mbids").description("Fill MusicBrainz recording MBIDs (crawler PK strip + ISRC resolve) over tracks").option("--dry-run", "Report the eligible worklist without any vendor call or write", false).option("--limit <limit>", "Max ISRC lookups to process", "50").option("--json", "Print JSON", false).action(async (options) => {
9437
+ const { backfillRecordingMbidsCommand: backfillRecordingMbidsCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
9438
+ await runBackfillRecordingMbids(options, backfillRecordingMbidsCommand2);
9439
+ });
9361
9440
  backfill.command("cover-masters").description("Resolve owned \u22641200\xB2 cover masters (album/artist) into R2").option("--kind <kind>", "Which entity to drain: album | artist", "album").option("--dry-run", "Report the eligible worklist without any fetch or write", false).option("--limit <limit>", "Max entities to process", "50").option("--json", "Print JSON", false).action(async (options) => {
9362
9441
  const { backfillCoverMastersCommand: backfillCoverMastersCommand2 } = await Promise.resolve().then(() => (init_admin_covers(), exports_admin_covers));
9363
9442
  await runBackfillCoverMasters(options, backfillCoverMastersCommand2);
@@ -9779,18 +9858,6 @@ async function runBackfillLastfm(options, backfillLastfmCommand2) {
9779
9858
  process.exitCode = 1;
9780
9859
  }
9781
9860
  }
9782
- function anchorOutcomeSuffix(outcome) {
9783
- switch (outcome) {
9784
- case "throttled":
9785
- return " (Spotify throttled the fill; the anchor breaker is pacing it)";
9786
- case "unauthorized":
9787
- return " (Spotify grant is gone \u2014 reconnect Spotify from /admin to resume anchoring)";
9788
- case "breaker_open":
9789
- return " (anchor fill PAUSED on its breaker; it resumes after the cooldown)";
9790
- default:
9791
- return "";
9792
- }
9793
- }
9794
9861
  async function runCrawlCatalogue(options, crawlCatalogueCommand2) {
9795
9862
  const limit = parseListLimit(options.limit);
9796
9863
  const maxHop = Number.parseInt(options.maxHop ?? "2", 10);
@@ -9802,7 +9869,6 @@ async function runCrawlCatalogue(options, crawlCatalogueCommand2) {
9802
9869
  } else {
9803
9870
  console.log(`Expanded ${result.expanded} node(s) at hop \u2264 ${result.maxHop}: ${result.tracksFound} track(s) found, ${result.tracksWritten} written, ${result.tracksSkipped} already held.`);
9804
9871
  console.log(` +${result.seeded} seed(s), +${result.nodesEnqueued} node(s) enqueued, ${result.frontierPending} pending, ${result.failed} failed.`);
9805
- console.log(` ${result.anchorsFilled} Spotify anchor(s) filled${anchorOutcomeSuffix(result.anchorOutcome)}.`);
9806
9872
  if (result.seedsRearmed > 0) {
9807
9873
  console.log(` Re-armed ${result.seedsRearmed} enabled seed label(s) to re-check for new releases.`);
9808
9874
  }
@@ -9825,14 +9891,6 @@ async function runCrawlStatus(options, crawlStatusCommand2) {
9825
9891
  }
9826
9892
  const { frontier, frontierByKind } = result;
9827
9893
  console.log(`Catalogue: ${result.catalogueTracks} uncertified track(s); ${result.anchorsPending} awaiting a Spotify anchor.`);
9828
- const { spotifyAnchor } = result;
9829
- if (spotifyAnchor.tripped) {
9830
- const mins = Math.ceil(spotifyAnchor.cooldownRemainingMs / 60000);
9831
- const why = spotifyAnchor.reason === "unauthorized" ? "the Spotify grant is gone \u2014 reconnect Spotify from /admin to resume anchoring" : "Spotify is throttling the app \u2014 it self-heals when the throttle lifts";
9832
- console.log(` Spotify anchor PAUSED (${why}); breaker cools in ~${mins} min.`);
9833
- } else if (spotifyAnchor.consecutiveFailures > 0) {
9834
- console.log(` Spotify anchor wobbling (${spotifyAnchor.consecutiveFailures} failing pass(es); reason: ${spotifyAnchor.reason ?? "unknown"}).`);
9835
- }
9836
9894
  console.log(`Frontier: ${frontier.pending} pending, ${frontier.done} done, ${frontier.failed} failed, ${frontier.skipped} skipped (${frontierByKind.label} label, ${frontierByKind.artist} artist, ${frontierByKind.release} release).`);
9837
9895
  console.log(`Seeds (${result.seedLabels.length}): ${result.seedLabels.join(", ") || "none enabled"}.`);
9838
9896
  console.log(`Awaiting your ruling: ${result.labelsUndecided} label(s).`);
@@ -10230,6 +10288,63 @@ async function runBackfillLabelImages(options, backfillLabelImagesCommand2) {
10230
10288
  process.exitCode = 1;
10231
10289
  }
10232
10290
  }
10291
+ async function runBackfillRecordingMbids(options, backfillRecordingMbidsCommand2) {
10292
+ const limit = parseListLimit(options.limit);
10293
+ const resolved = [];
10294
+ const missed = [];
10295
+ const failed = [];
10296
+ let cursor;
10297
+ let dryRun = options.dryRun;
10298
+ let prefixStripped = 0;
10299
+ let throttled = false;
10300
+ while (resolved.length + missed.length + failed.length < limit) {
10301
+ const remaining = limit - (resolved.length + missed.length + failed.length);
10302
+ const result = await backfillRecordingMbidsCommand2(remaining, options.dryRun, cursor);
10303
+ dryRun = result.dryRun;
10304
+ prefixStripped += result.prefixStripped;
10305
+ resolved.push(...result.resolved);
10306
+ missed.push(...result.missed);
10307
+ failed.push(...result.failed);
10308
+ if (!options.json) {
10309
+ const verb2 = result.dryRun ? "would resolve" : "resolved";
10310
+ console.log(` \u2026stripped ${result.prefixStripped} from PK; ${verb2} ${result.resolvedCount} by ISRC; ${result.missedCount} not in MusicBrainz; ${result.failedCount} failed`);
10311
+ }
10312
+ if (result.rateLimited) {
10313
+ throttled = true;
10314
+ break;
10315
+ }
10316
+ if (result.nextCursor === null) {
10317
+ break;
10318
+ }
10319
+ cursor = result.nextCursor;
10320
+ }
10321
+ if (options.json) {
10322
+ printJson({
10323
+ dryRun,
10324
+ failed,
10325
+ failedCount: failed.length,
10326
+ missed,
10327
+ missedCount: missed.length,
10328
+ ok: true,
10329
+ prefixStripped,
10330
+ rateLimited: throttled,
10331
+ resolved,
10332
+ resolvedCount: resolved.length
10333
+ });
10334
+ if (failed.length > 0) {
10335
+ process.exitCode = 1;
10336
+ }
10337
+ return;
10338
+ }
10339
+ const verb = dryRun ? "Would fill" : "Filled";
10340
+ console.log(`${verb} ${prefixStripped} recording MBID(s) from crawler PKs; ${resolved.length} by ISRC; ${missed.length} not in MusicBrainz; ${failed.length} failed.`);
10341
+ for (const item of failed) {
10342
+ console.log(` ${item.trackId}: ${item.error}`);
10343
+ }
10344
+ if (failed.length > 0) {
10345
+ process.exitCode = 1;
10346
+ }
10347
+ }
10233
10348
  async function runArtistResolveQueue(options, listArtistsCommand2) {
10234
10349
  const limit = parseListLimit(options.limit);
10235
10350
  const result = await listArtistsCommand2(limit ?? 50);
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.161.0"
34
+ "version": "0.163.0"
35
35
  }