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.
- package/bin/fluncle.mjs +138 -23
- 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.
|
|
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.
|
|
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