fluncle 0.181.0 → 0.183.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 +76 -3
  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.181.0".trim() ? "0.181.0".trim() : "0.1.0";
564
+ currentVersion = "0.183.0".trim() ? "0.183.0".trim() : "0.1.0";
565
565
  });
566
566
 
567
567
  // src/update-notifier.ts
@@ -2260,7 +2260,7 @@ function parseVersion2(version) {
2260
2260
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2261
2261
  var init_version2 = __esm(() => {
2262
2262
  init_output();
2263
- currentVersion2 = "0.181.0".trim() ? "0.181.0".trim() : "0.1.0";
2263
+ currentVersion2 = "0.183.0".trim() ? "0.183.0".trim() : "0.1.0";
2264
2264
  });
2265
2265
 
2266
2266
  // ../../packages/registry/src/index.ts
@@ -3134,7 +3134,7 @@ var init_src = __esm(() => {
3134
3134
  ],
3135
3135
  kind: "cron",
3136
3136
  name: "cron.label-releases",
3137
- operatorNotes: "every 24h, run by a rave-02 HOST systemd timer (docs/agents/hermes/label-releases-timer/). The FRESHNESS TAP (D8): MusicBrainz WALKS the graph (cron.crawl) but lags a release ~2 weeks; Spotify has it day one, so this mints METADATA-ONLY catalogue rows (a `tracks` row with no `findings` row) for each ENABLED seed label's fresh releases with their real dates — closing the /fresh lag cliff. Runs OFF the official (dev-mode) Spotify app, which is rate-limited to death at its tier and must stay for user paths: the box reads the due seed labels (`list_label_releases_work`), runs an Apify Spotify-scraper actor per label (`label:\"<name>\" tag:new`), maps each result album (inline tracks[] + ISRCs + artists[]) to a candidate, and POSTs them to the agent-tier `backfill_label_releases` op where the WORKER re-runs the FULL gate (the box's verdict is never trusted) and mints. The gate: artist-grounding (an album's Spotify artist already in `artists.spotify_artist_id` — the PRIMARY anchor that stops cross-genre homonym junk, always required) AND, when the actor gives a label signal (album_label/copyright both null in albums-search mode, so grounding-only there), an exact fold-match to the seed name. It certifies nothing, publishes nothing, never widens the graph (no new labels, no artist hops). Deduped against the MB crawl from both directions (Spotify id/uri/ISRC + same-album title fold). Reuses the anchor sweep's APIFY_API_TOKEN (no new secret). Calls the oRPC HTTP endpoints directly (no CLI command the pinned box CLI would lack). Zero LLM tokens. Source: docs/agents/hermes/scripts/label-releases-sweep.*. See docs/catalogue-crawler.md.",
3137
+ operatorNotes: "every 24h, run by a rave-02 HOST systemd timer (docs/agents/hermes/label-releases-timer/). The FRESHNESS TAP (D8): MusicBrainz WALKS the graph (cron.crawl) but lags a release ~2 weeks; Spotify has it day one, so this mints METADATA-ONLY catalogue rows (a `tracks` row with no `findings` row) for each ENABLED seed label's fresh releases with their real dates — closing the /fresh lag cliff. The WORKER does all of it (`backfill_label_releases`, agent tier): it searches the official Spotify API (`label:\"<name>\" tag:new`), reads each hit as a SINGLE `GET /albums/{id}` then `GET /tracks/{id}` (the batch endpoints are 403 at our tier), and mints. The box sweep is a thin HTTP TRIGGER that POSTs bounded passes with the agent tokenno vendor token, no CLI dependency (a pinned box CLI missing a flag broke an earlier run). The gate, both required: artist-grounding (an album's Spotify artist already in `artists.spotify_artist_id` — the PRIMARY anchor that stops cross-genre homonym junk) AND an EXACT fold-match of the seed name in the ℗/© copyright; an album with no release_date is dropped outright (/fresh could never show it). BUDGET: the tap shares the official app's per-app window with the user-facing paths, so it paces itself against the shared call meter and stops at a FRACTION of the window (its own ceiling) — user write paths get the window, the tap takes only slack. Hitting that ceiling ends the pass cleanly and the durable per-label cadence stamps resume it next tick. It certifies nothing, publishes nothing, never widens the graph (no new labels, no artist hops). Deduped against the MB crawl from both directions (Spotify id/uri/ISRC + same-album title fold). No vendor spend and zero LLM tokens. Source: docs/agents/hermes/scripts/label-releases-sweep.*. See docs/catalogue-crawler.md.",
3138
3138
  probeConfig: {
3139
3139
  cadenceMs: 24 * 60 * MINUTE_MS,
3140
3140
  cronName: "fluncle-label-releases",
@@ -3212,6 +3212,19 @@ var init_src = __esm(() => {
3212
3212
  title: "Recording MBIDs",
3213
3213
  weights: { status: "hidden" }
3214
3214
  },
3215
+ {
3216
+ command: "fluncle admin backfills artist-edges",
3217
+ exposedContent: [
3218
+ "fold each edge-less track's artists_json names onto existing artist identities → track_artists edges"
3219
+ ],
3220
+ kind: "cron",
3221
+ name: "cron.artist-edges",
3222
+ operatorNotes: "every 60m, run by a rave-02 HOST systemd timer (docs/agents/hermes/artist-edges-timer/). The track_artists GRAPH BACKFILL (RFC artist-primary-capture, slice 0): the graph is crawl-era-only (born 2026-07-15) — only ~12.3k of ~37.5k tracks carry edges — so this folds each edge-less track's `artists_json` NAMES onto EXISTING `artists` identities and writes the `track_artists` edges, making the graph as full as honest matching allows (slice 1's identity-keyed capture authorization reads it). The matcher is IDENTITY-HONEST: each name matches by exact case-insensitive fold, then via `artist_aliases` (kind='name', status auto|confirmed — the search resolver's alias semantics); a fold two distinct identities share is ambiguous and matches nothing (fail-closed). It MINTS NOTHING — a bare name is not enough identity to create an entity — and reports the UNMATCHED RESIDUAL (credited names with no identity), which decides whether a later paced MusicBrainz credit-sweep is worth running. METADATA / GRAPH IDENTITY ONLY — it certifies nothing and publishes nothing (agent tier, the `backfill_recording_mbids` precedent). Worker-paced with NO vendor call (pure DB set-based matching, so no rate limit / circuit breaker): one bounded batch per tick folds the whole ~1.8k-row artist+alias corpus into one in-memory map and matches each track batch against it. The `tracks` row carries the durable reliability state (the `artist_edges_backfilled_at` stamp on EVERY visited row — matched, partial, or zero — so the worklist drains and a re-run is a no-op). New tracks are minted WITH edges (publish path + crawler link), so this catches history up and drains in a handful of ticks. Zero LLM tokens. Source: docs/agents/hermes/scripts/artist-edges-sweep.*. See docs/artist-relationship.md.",
3223
+ probeConfig: { cadenceMs: 60 * MINUTE_MS, cronName: "fluncle-artist-edges", kind: "cron" },
3224
+ statusDescription: "folds artists_json names onto artist identities",
3225
+ title: "Artist edges",
3226
+ weights: { status: "hidden" }
3227
+ },
3215
3228
  {
3216
3229
  command: "fluncle admin backfills label-lineage",
3217
3230
  exposedContent: [
@@ -4136,6 +4149,7 @@ __export(exports_admin_tracks, {
4136
4149
  backfillRecordingMbidsCommand: () => backfillRecordingMbidsCommand,
4137
4150
  backfillLastfmCommand: () => backfillLastfmCommand,
4138
4151
  backfillDiscogsCommand: () => backfillDiscogsCommand,
4152
+ backfillArtistEdgesCommand: () => backfillArtistEdgesCommand,
4139
4153
  backfillAppleMusicCommand: () => backfillAppleMusicCommand,
4140
4154
  backfillAppleCatalogueCommand: () => backfillAppleCatalogueCommand
4141
4155
  });
@@ -4328,6 +4342,13 @@ async function backfillRecordingMbidsCommand(limit, dryRun, cursor) {
4328
4342
  }
4329
4343
  return adminApiPost(`/api/admin/backfill/recording-mbids?${params.toString()}`);
4330
4344
  }
4345
+ async function backfillArtistEdgesCommand(limit, dryRun, cursor) {
4346
+ const params = new URLSearchParams({ dryRun: String(dryRun), limit: String(limit) });
4347
+ if (cursor) {
4348
+ params.set("cursor", cursor);
4349
+ }
4350
+ return adminApiPost(`/api/admin/backfill/artist-edges?${params.toString()}`);
4351
+ }
4331
4352
  async function vehiclesCommand(limit) {
4332
4353
  const tracks = await fetchAdminTracks({ hasVideo: true, max: limit, order: "desc" });
4333
4354
  return tracks.map((track) => ({
@@ -9741,6 +9762,10 @@ function addAdminCommands(program2) {
9741
9762
  const { backfillRecordingMbidsCommand: backfillRecordingMbidsCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
9742
9763
  await runBackfillRecordingMbids(options, backfillRecordingMbidsCommand2);
9743
9764
  });
9765
+ backfill.command("artist-edges").description("Fold artists_json names onto existing artist identities \u2192 track_artists").option("--dry-run", "Report the classification without writing edges or stamps", false).option("--limit <limit>", "Max tracks to visit", "200").option("--json", "Print JSON", false).action(async (options) => {
9766
+ const { backfillArtistEdgesCommand: backfillArtistEdgesCommand2 } = await Promise.resolve().then(() => (init_admin_tracks(), exports_admin_tracks));
9767
+ await runBackfillArtistEdges(options, backfillArtistEdgesCommand2);
9768
+ });
9744
9769
  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("--retry-none", "First re-queue a bounded batch of terminal none rows to pending, then run the pass", false).option("--limit <limit>", "Max entities to process", "50").option("--json", "Print JSON", false).action(async (options) => {
9745
9770
  const { backfillCoverMastersCommand: backfillCoverMastersCommand2 } = await Promise.resolve().then(() => (init_admin_covers(), exports_admin_covers));
9746
9771
  await runBackfillCoverMasters(options, backfillCoverMastersCommand2);
@@ -10740,6 +10765,54 @@ async function runBackfillRecordingMbids(options, backfillRecordingMbidsCommand2
10740
10765
  process.exitCode = 1;
10741
10766
  }
10742
10767
  }
10768
+ async function runBackfillArtistEdges(options, backfillArtistEdgesCommand2) {
10769
+ const limit = parseListLimit(options.limit) ?? 200;
10770
+ const fullyMatched = [];
10771
+ const partiallyMatched = [];
10772
+ const zeroMatched = [];
10773
+ let cursor;
10774
+ let dryRun = options.dryRun;
10775
+ let edgesWritten = 0;
10776
+ let scanned = 0;
10777
+ let unmatchedNames = 0;
10778
+ while (scanned < limit) {
10779
+ const remaining = limit - scanned;
10780
+ const result = await backfillArtistEdgesCommand2(remaining, options.dryRun, cursor);
10781
+ dryRun = result.dryRun;
10782
+ scanned += result.scanned;
10783
+ edgesWritten += result.edgesWritten;
10784
+ unmatchedNames += result.unmatchedNames;
10785
+ fullyMatched.push(...result.fullyMatched);
10786
+ partiallyMatched.push(...result.partiallyMatched);
10787
+ zeroMatched.push(...result.zeroMatched);
10788
+ if (!options.json) {
10789
+ const verb2 = result.dryRun ? "would write" : "wrote";
10790
+ console.log(` \u2026visited ${result.scanned}; ${verb2} ${result.edgesWritten} edge(s); ${result.fullyMatchedCount} full, ${result.partiallyMatchedCount} partial, ${result.zeroMatchedCount} zero; ${result.unmatchedNames} name(s) unmatched`);
10791
+ }
10792
+ if (result.nextCursor === null) {
10793
+ break;
10794
+ }
10795
+ cursor = result.nextCursor;
10796
+ }
10797
+ if (options.json) {
10798
+ printJson({
10799
+ dryRun,
10800
+ edgesWritten,
10801
+ fullyMatched,
10802
+ fullyMatchedCount: fullyMatched.length,
10803
+ ok: true,
10804
+ partiallyMatched,
10805
+ partiallyMatchedCount: partiallyMatched.length,
10806
+ scanned,
10807
+ unmatchedNames,
10808
+ zeroMatched,
10809
+ zeroMatchedCount: zeroMatched.length
10810
+ });
10811
+ return;
10812
+ }
10813
+ const verb = dryRun ? "Would write" : "Wrote";
10814
+ console.log(`${verb} ${edgesWritten} track_artists edge(s) over ${scanned} track(s): ${fullyMatched.length} fully matched, ${partiallyMatched.length} partially, ${zeroMatched.length} with none; ${unmatchedNames} credited name(s) left unmatched.`);
10815
+ }
10743
10816
  async function runArtistResolveQueue(options, listArtistsCommand2) {
10744
10817
  const limit = parseListLimit(options.limit);
10745
10818
  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.181.0"
34
+ "version": "0.183.0"
35
35
  }