fluncle 0.264.0 → 0.266.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 +1412 -19
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -380,6 +380,12 @@ var require_main = __commonJS(function(exports, module) {
380
380
  });
381
381
 
382
382
  // src/env.ts
383
+ var exports_env = {};
384
+ __export(exports_env, {
385
+ getApiBaseUrl: () => getApiBaseUrl,
386
+ loadEnv: () => loadEnv,
387
+ readOptionalEnv: () => readOptionalEnv
388
+ });
383
389
  import { homedir } from "node:os";
384
390
  import { join } from "node:path";
385
391
  function loadEnv(keys) {
@@ -399,6 +405,10 @@ function loadEnv(keys) {
399
405
  }
400
406
  return result;
401
407
  }
408
+ function readOptionalEnv(key) {
409
+ loadConfig();
410
+ return process.env[key] || undefined;
411
+ }
402
412
  function getApiBaseUrl() {
403
413
  loadConfig();
404
414
  return (process.env.FLUNCLE_API_BASE_URL ?? "https://www.fluncle.com").replace(/\/+$/, "");
@@ -622,7 +632,7 @@ function parseVersion(version) {
622
632
  var currentVersion;
623
633
  var init_version = __esm(() => {
624
634
  init_output();
625
- currentVersion = "0.264.0".trim() ? "0.264.0".trim() : "0.1.0";
635
+ currentVersion = "0.266.0".trim() ? "0.266.0".trim() : "0.1.0";
626
636
  });
627
637
 
628
638
  // src/update-notifier.ts
@@ -2335,12 +2345,12 @@ function parseVersion2(version) {
2335
2345
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2336
2346
  var init_version2 = __esm(() => {
2337
2347
  init_output();
2338
- currentVersion2 = "0.264.0".trim() ? "0.264.0".trim() : "0.1.0";
2348
+ currentVersion2 = "0.266.0".trim() ? "0.266.0".trim() : "0.1.0";
2339
2349
  });
2340
2350
 
2341
2351
  // ../../packages/registry/src/index.ts
2342
2352
  function liveSurfaces() {
2343
- return SURFACES.filter((surface) => surface.pending !== true);
2353
+ return SURFACES.filter((surface) => surface.pending !== true && surface.dormant === undefined);
2344
2354
  }
2345
2355
  var SITE = "https://www.fluncle.com", PROBE_CADENCE_MS, PROBE_TIMEOUT_MS, MINUTE_MS, SURFACES;
2346
2356
  var init_src = __esm(() => {
@@ -3524,12 +3534,13 @@ var init_src = __esm(() => {
3524
3534
  weights: { status: "hidden" }
3525
3535
  },
3526
3536
  {
3537
+ dormant: "the iOS app has no users, and the mirror's embedded-replica sync of the main database halves the primary's write capacity while it runs",
3527
3538
  exposedContent: [
3528
3539
  "mirror the anchored, explicitly allowlisted public catalogue into the shared read-only device replica"
3529
3540
  ],
3530
3541
  kind: "cron",
3531
3542
  name: "cron.device-mirror",
3532
- operatorNotes: "hourly, run by a host systemd timer (docs/agents/hermes/device-mirror-timer/). Explicitly synchronizes one restart-safe local embedded replica, materializes the anchored track IDs once, derives and validates one allowlisted generation locally, then stages bounded changes and atomically cuts the complete verified generation into the shared device database. Sync, derivation, upload, validation, or cutover failure leaves the previous generation visible; rebuilding the target database remains forbidden because it would reset the libSQL replication log and force every device to bootstrap again. A schema-version mismatch stops for an operator migration. Zero LLM tokens. Source: docs/agents/hermes/scripts/device-mirror.*.",
3543
+ operatorNotes: "DORMANT: parked by docs/agents/hermes/device-mirror-timer/DORMANT, which keeps the installer from enabling its timer and keeps the healthcheck, /status, and the run-ledger roster from expecting it; revival steps are in that directory's README. When live: hourly, run by a host systemd timer (docs/agents/hermes/device-mirror-timer/). Explicitly synchronizes one restart-safe local embedded replica, materializes the anchored track IDs once, derives and validates one allowlisted generation locally, then stages bounded changes and atomically cuts the complete verified generation into the shared device database. Sync, derivation, upload, validation, or cutover failure leaves the previous generation visible; rebuilding the target database remains forbidden because it would reset the libSQL replication log and force every device to bootstrap again. A schema-version mismatch stops for an operator migration. Zero LLM tokens. Source: docs/agents/hermes/scripts/device-mirror.*.",
3533
3544
  probeConfig: {
3534
3545
  cadenceMs: 60 * MINUTE_MS,
3535
3546
  cronName: "fluncle-device-mirror",
@@ -3663,11 +3674,11 @@ var init_src = __esm(() => {
3663
3674
  {
3664
3675
  command: "fluncle admin tracks context --queue",
3665
3676
  exposedContent: [
3666
- "Firecrawl facts → distilled context_note + a Texture: line (Worker-side Haiku)"
3677
+ "Firecrawl facts → distilled context_note + a Texture: line (Worker-side OpenRouter distill)"
3667
3678
  ],
3668
3679
  kind: "cron",
3669
3680
  name: "cron.context-note",
3670
- operatorNotes: "every 5m. --no-agent trigger; the Worker does the Firecrawl + Haiku distill. Zero on-box tokens.",
3681
+ operatorNotes: "every 5m. --no-agent trigger; the Worker does the Firecrawl + OpenRouter distill. Zero on-box tokens.",
3671
3682
  probeConfig: { cadenceMs: 5 * MINUTE_MS, cronName: "fluncle-context-note", kind: "cron" },
3672
3683
  statusDescription: "distills the facts behind each finding",
3673
3684
  title: "Context notes",
@@ -3714,13 +3725,32 @@ var init_src = __esm(() => {
3714
3725
  },
3715
3726
  {
3716
3727
  command: "fluncle admin labels list --seed-state undecided",
3717
- exposedContent: ["read the undecided crawl-seed pile and report whether a triage round is due"],
3728
+ exposedContent: [
3729
+ "read the undecided crawl-seed pile and, once a round is due, research it into proposals"
3730
+ ],
3718
3731
  kind: "cron",
3719
3732
  name: "cron.label-triage",
3720
- operatorNotes: "daily. A PURE trigger — zero model tokens, one countless admin read and a sort. Fires only when 40+ NEVER-LOOKED labels have accumulated; stale ones ride along but never trigger a round. It cannot rule: recording a finding is record_label_triage (agent tier) and ruling is update_label (operator tier), which 403s the box token. The batched research leg is deliberately unwired until the gate proves it reports honestly. Source: docs/agents/hermes/scripts/{label-triage-sweep.ts,label-triage-sweep.sh}.",
3733
+ operatorNotes: "daily. The gate is one countless admin read and a sort; it fires only when 40+ NEVER-LOOKED labels with a MusicBrainz identity and no artist rules have accumulated, and stale ones ride along. A fired round runs one claude -p per batch of 10 labels (capped at 3 batches and 30 labels) whose only fetcher is `fluncle admin labels evidence`; the model holds no Fluncle token, and the script records each verdict with record_label_triage (agent tier). It cannot rule: update_label is operator tier and 403s the box token. Labels a round left unfinished (a failed batch, or past the cap) are carried and fire the next day. The model runs --restricted with dontAsk permissions, confined to its batch directory and the skill. A fired round that records zero proposals, or any failed batch, exits non-zero, reads failed on /status and posts to Discord. Source: docs/agents/hermes/scripts/{label-triage-sweep.ts,label-triage-sweep.sh}.",
3721
3734
  probeConfig: { cadenceMs: 24 * 60 * MINUTE_MS, cronName: "fluncle-label-triage", kind: "cron" },
3722
- statusDescription: "reports when the label pile needs a round",
3723
- title: "Label triage gate",
3735
+ statusDescription: "researches the label pile when it needs a round",
3736
+ title: "Label triage",
3737
+ weights: { status: "hidden" }
3738
+ },
3739
+ {
3740
+ exposedContent: [
3741
+ "nightly label-outlier review list — every album and label-less single of embedded catalogue tracks scored against the rest of its own label, the far ones flagged onto /admin/label-outliers (--no-agent)"
3742
+ ],
3743
+ kind: "cron",
3744
+ name: "cron.label-outliers",
3745
+ operatorNotes: "05:30 Amsterdam daily, retry slot 06:30. Reads its scoring inputs (embedded catalogue tracks with their raw vector bytes, artist credits, album Discogs styles) from the AGENT-tier list_label_outlier_inputs op in keyset pages of 1,000, each page its own admitted heavy-read phase, into a local file it scores and deletes (about 51 pages and 280 MB a night at 50k tracks; an admission yield mid-walk leaves the payload unstarted for the retry slot). It scores each album's mean cosine to its label's leave-it-out centroid as a robust z against that label's own spread (labels under 8 albums use the whole catalogue), and keeps the ones at z <= -4 whose artists have fewer than 3 typical tracks elsewhere and whose album Discogs does not file under Drum n Bass or Jungle. A corpus under 10,000 usable vectors or under 95% usable, or more than 2,000 flags, fails loudly and writes nothing. It then fires the AGENT-tier record_label_outliers op in its own admitted phase; the Worker refuses a duplicated, partial, or collapsed run (fewer than 80% of the live embedded catalogue), replaces the stored list, and returns every visible unit not yet announced. The sweep posts one Discord summary and only then acknowledges them by unit and fingerprint (acknowledge_label_outlier_alerts), so a lost response re-alerts next run. Dismissing is operator tier (set_label_outliers_dismissed) and holds until the album's tracks change. Zero LLM tokens. Source: docs/agents/hermes/scripts/label-outliers*.ts. See docs/catalogue-crawler.md.",
3746
+ probeConfig: {
3747
+ cadenceMs: 24 * 60 * MINUTE_MS,
3748
+ cronName: "fluncle-label-outliers",
3749
+ kind: "cron",
3750
+ schedule: { time: "05:30", tz: "Europe/Amsterdam" }
3751
+ },
3752
+ statusDescription: "flags albums that sound unlike their label",
3753
+ title: "Label outliers",
3724
3754
  weights: { status: "hidden" }
3725
3755
  },
3726
3756
  {
@@ -3874,7 +3904,7 @@ var init_src = __esm(() => {
3874
3904
  ],
3875
3905
  kind: "cron",
3876
3906
  name: "cron.healthcheck",
3877
- operatorNotes: "every 10m, run by a rave-02 host systemd timer (docs/agents/hermes/healthcheck-timer/) — decoupled from the Hermes cron gateway so the prober isn't starved by the scheduler it monitors. Pure probing, zero LLM tokens. POSTs to the agent-tier record_health op that /status reads.",
3907
+ operatorNotes: "every 10m, run by a rave-02 host systemd timer (docs/agents/hermes/healthcheck-timer/) — decoupled from the Hermes cron gateway so the prober isn't starved by the scheduler it monitors. Pure probing, zero LLM tokens. POSTs to the agent-tier record_health op that /status reads, and makes one record_database_write_probe call per tick that feeds database admission's write-latency guardrail.",
3878
3908
  probeConfig: { cadenceMs: 10 * MINUTE_MS, cronName: "fluncle-healthcheck", kind: "cron" },
3879
3909
  statusDescription: "the prober behind this very page",
3880
3910
  title: "Healthcheck prober",
@@ -4020,6 +4050,21 @@ var init_src = __esm(() => {
4020
4050
  title: "Funnel snapshot",
4021
4051
  weights: { status: "hidden" }
4022
4052
  },
4053
+ {
4054
+ exposedContent: [
4055
+ "Turso usage and overage reading — the organisation's current-cycle usage priced against the plan and projected to the reset, stored in the telemetry database for the /admin/costs Turso panel (--no-agent)"
4056
+ ],
4057
+ kind: "cron",
4058
+ name: "cron.turso-usage",
4059
+ operatorNotes: "Every 6 hours (OnUnitActiveSec), no retry slot: the counters are cumulative, so the next tick loses nothing. Reads the Turso Platform API (organization usage, database list, subscription, upcoming invoice) with TURSO_PLATFORM_API_TOKEN + TURSO_PLATFORM_ORG from the sweep secrets file, then fires the AGENT-tier record_turso_usage op. The Worker prices the reading with the versioned table in apps/web/src/lib/turso-pricing.ts (list rates, so an upper bound), projects the UTC-month cycle from the recent daily run-rate, stores it in the telemetry database, and claims a (cycle, level) alert row when the projected overage reaches the turso_usage_alert_threshold_usd setting (default $50) or twice it. The sweep posts one Discord message and only then acknowledges the levels (acknowledge_turso_usage_alerts), so a lost post re-sends next run. Missing credentials fail the run by name. Zero LLM tokens. Source: docs/agents/hermes/scripts/turso-usage-sweep.*. See docs/agents/hermes/turso-usage-timer/README.md.",
4060
+ operatorOnly: "the database bill is an operator concern; the job still reports to the prober, the run ledger, and the /admin/costs Turso panel, never to the public status board",
4061
+ probeConfig: {
4062
+ cadenceMs: 6 * 60 * MINUTE_MS,
4063
+ cronName: "fluncle-turso-usage",
4064
+ kind: "cron"
4065
+ },
4066
+ weights: {}
4067
+ },
4023
4068
  {
4024
4069
  exposedContent: [
4025
4070
  "daily per-post social-metrics snapshot — appends each published post's Postiz reach (views/likes/comments/…) into an append-only ledger, one row per post per day (--no-agent)"
@@ -4039,7 +4084,7 @@ var init_src = __esm(() => {
4039
4084
  },
4040
4085
  {
4041
4086
  exposedContent: [
4042
- "nightly codebase audit — one domain/night on a 7-day rotation; opens a PR the reviewer merges (claude -p, subscription auth)"
4087
+ "nightly codebase audit — one domain/night on an 8-domain rotation; opens a PR the reviewer merges (claude -p, subscription auth)"
4043
4088
  ],
4044
4089
  kind: "cron",
4045
4090
  name: "cron.audit",
@@ -10497,7 +10542,8 @@ var init_admin_catalogue = __esm(() => {
10497
10542
  limit: number2().int().min(1).max(MAX_CRAWL_PREPARE_LIMIT).default(2),
10498
10543
  maxHop: number2().int().min(0).max(3).default(2),
10499
10544
  phase: literal("prepare"),
10500
- sampleStorableRepair: boolean2().optional()
10545
+ sampleStorableRepair: boolean2().optional(),
10546
+ skipFrontierPendingCount: boolean2().optional()
10501
10547
  }),
10502
10548
  object({
10503
10549
  phase: literal("fetch"),
@@ -10522,7 +10568,7 @@ var init_admin_catalogue = __esm(() => {
10522
10568
  object({
10523
10569
  boxFetch: boolean2(),
10524
10570
  capabilities: CrawlPhaseCapabilitiesSchema.optional(),
10525
- frontierPending: number2(),
10571
+ frontierPending: number2().optional(),
10526
10572
  initialization: CrawlPhaseInitializationSchema,
10527
10573
  items: array(object({
10528
10574
  fetchPlan: CrawlFetchPlanSchema,
@@ -11146,7 +11192,7 @@ var init_admin_albums = __esm(() => {
11146
11192
  });
11147
11193
 
11148
11194
  // ../../packages/contracts/src/orpc/admin-artifacts.ts
11149
- var ARTIFACT_CHANGE_API_MAX_LIMIT = 500, ARTIFACT_SNAPSHOT_API_MAX_LIMIT = 200, ARTIFACT_COMPACTION_API_MAX_LIMIT = 1000, ARTIFACT_SUPPORTED_CONTRACTS, ArtifactStreamSchema, ArtifactContractSchema, ConsumerIdSchema, DigestSchema, SequenceSchema, ArtifactRebuildCheckpointSchema, ArtifactConsumerStatusSchema, ArtifactChangeEventSchema, ArtifactSnapshotItemSchema, ArtifactSnapshotPageSchema, ArtifactChangePageSchema, ArtifactCompactionResultSchema, registerArtifactConsumer, getArtifactConsumer, listArtifactSnapshot, checkpointArtifactRebuild, activateArtifactConsumer, listArtifactChanges, acknowledgeArtifactChanges, inactivateArtifactConsumer, compactArtifactChanges, adminArtifactsContract;
11195
+ var ARTIFACT_CHANGE_API_MAX_LIMIT = 500, ARTIFACT_SNAPSHOT_API_MAX_LIMIT = 200, ARTIFACT_COMPACTION_API_MAX_LIMIT = 1000, ARTIFACT_SUPPORTED_CONTRACTS, ArtifactStreamSchema, ArtifactContractSchema, ConsumerIdSchema, DigestSchema, SequenceSchema, ArtifactRebuildCheckpointSchema, ArtifactConsumerStatusSchema, ArtifactChangeEventSchema, ArtifactSnapshotItemSchema, ArtifactSnapshotPageSchema, ArtifactChangePageSchema, ArtifactCompactionResultSchema, SonarSourceAfterSchema, SonarSourceLimitSchema, SonarSourceIdsSchema, listSonarTrackDigests, listSonarTracks, listSonarCentroidDigests, listSonarCentroids, registerArtifactConsumer, getArtifactConsumer, listArtifactSnapshot, checkpointArtifactRebuild, activateArtifactConsumer, listArtifactChanges, acknowledgeArtifactChanges, inactivateArtifactConsumer, compactArtifactChanges, adminArtifactsContract;
11150
11196
  var init_admin_artifacts = __esm(() => {
11151
11197
  init_dist3();
11152
11198
  init_zod();
@@ -11260,6 +11306,57 @@ var init_admin_artifacts = __esm(() => {
11260
11306
  ok: literal(true),
11261
11307
  reason: _enum(["compacted", "empty", "no_safe_barrier"])
11262
11308
  });
11309
+ SonarSourceAfterSchema = string2().min(1).max(1024).optional();
11310
+ SonarSourceLimitSchema = number3().int().min(1).max(2000).default(1000);
11311
+ SonarSourceIdsSchema = array(string2().min(1).max(1024)).min(1).max(200).refine((ids) => new Set(ids).size === ids.length);
11312
+ listSonarTrackDigests = oc.route({
11313
+ method: "GET",
11314
+ operationId: "listSonarTrackDigests",
11315
+ path: "/admin/sonar/source/tracks",
11316
+ summary: "List source track digests and revisions",
11317
+ tags: ["Admin"]
11318
+ }).input(strictObject({ after: SonarSourceAfterSchema, limit: SonarSourceLimitSchema })).output(strictObject({
11319
+ items: array(strictObject({
11320
+ payloadDigest: DigestSchema,
11321
+ revision: SequenceSchema,
11322
+ subjectId: string2().min(1).max(1024)
11323
+ })),
11324
+ nextAfter: string2().nullable(),
11325
+ ok: literal(true)
11326
+ }));
11327
+ listSonarTracks = oc.route({
11328
+ method: "POST",
11329
+ operationId: "listSonarTracks",
11330
+ path: "/admin/sonar/source/tracks/items",
11331
+ summary: "Read selected source tracks",
11332
+ tags: ["Admin"]
11333
+ }).input(strictObject({ subjectIds: SonarSourceIdsSchema })).output(strictObject({
11334
+ absentIds: array(string2()),
11335
+ items: array(ArtifactSnapshotItemSchema.extend({ revision: SequenceSchema })),
11336
+ ok: literal(true)
11337
+ }));
11338
+ listSonarCentroidDigests = oc.route({
11339
+ method: "GET",
11340
+ operationId: "listSonarCentroidDigests",
11341
+ path: "/admin/sonar/source/centroids",
11342
+ summary: "List source artist centroid digests",
11343
+ tags: ["Admin"]
11344
+ }).input(strictObject({ after: SonarSourceAfterSchema, limit: SonarSourceLimitSchema })).output(strictObject({
11345
+ items: array(strictObject({ artistId: string2(), digest: DigestSchema })),
11346
+ nextAfter: string2().nullable(),
11347
+ ok: literal(true)
11348
+ }));
11349
+ listSonarCentroids = oc.route({
11350
+ method: "POST",
11351
+ operationId: "listSonarCentroids",
11352
+ path: "/admin/sonar/source/centroids/items",
11353
+ summary: "Read selected artist centroids",
11354
+ tags: ["Admin"]
11355
+ }).input(strictObject({ artistIds: SonarSourceIdsSchema })).output(strictObject({
11356
+ absentIds: array(string2()),
11357
+ items: array(strictObject({ artistId: string2(), blobBase64: string2(), digest: DigestSchema })),
11358
+ ok: literal(true)
11359
+ }));
11263
11360
  registerArtifactConsumer = oc.route({
11264
11361
  method: "POST",
11265
11362
  operationId: "registerArtifactConsumer",
@@ -11360,6 +11457,10 @@ var init_admin_artifacts = __esm(() => {
11360
11457
  inactivate_artifact_consumer: inactivateArtifactConsumer,
11361
11458
  list_artifact_changes: listArtifactChanges,
11362
11459
  list_artifact_snapshot: listArtifactSnapshot,
11460
+ list_sonar_centroid_digests: listSonarCentroidDigests,
11461
+ list_sonar_centroids: listSonarCentroids,
11462
+ list_sonar_track_digests: listSonarTrackDigests,
11463
+ list_sonar_tracks: listSonarTracks,
11363
11464
  register_artifact_consumer: registerArtifactConsumer
11364
11465
  };
11365
11466
  });
@@ -12459,7 +12560,7 @@ var init_admin_costs = __esm(() => {
12459
12560
  });
12460
12561
 
12461
12562
  // ../../packages/contracts/src/orpc/admin-database-admission.ts
12462
- var DatabaseAdmissionActionSchema, DatabaseAdmissionLaneSchema, DatabaseAdmissionOutcomeSchema, DatabaseAdmissionYieldReasonSchema, DatabaseAdmissionInputSchema, DatabaseAdmissionResponseSchema, coordinateDatabaseAdmission, adminDatabaseAdmissionContract;
12563
+ var DatabaseAdmissionActionSchema, DatabaseAdmissionLaneSchema, DatabaseAdmissionOutcomeSchema, DatabaseAdmissionYieldReasonSchema, DatabaseAdmissionInputSchema, DatabaseAdmissionResponseSchema, coordinateDatabaseAdmission, DatabaseWriteProbeOutcomeSchema, DatabaseWriteProbeResponseSchema, recordDatabaseWriteProbe, adminDatabaseAdmissionContract;
12463
12564
  var init_admin_database_admission = __esm(() => {
12464
12565
  init_dist3();
12465
12566
  init_zod();
@@ -12478,12 +12579,15 @@ var init_admin_database_admission = __esm(() => {
12478
12579
  "database-health",
12479
12580
  "direct-read-latency",
12480
12581
  "public-latency",
12481
- "queue"
12582
+ "queue",
12583
+ "write-latency"
12482
12584
  ]);
12483
12585
  DatabaseAdmissionInputSchema = object({
12484
12586
  action: DatabaseAdmissionActionSchema,
12485
12587
  fencingToken: number2().int().positive().optional(),
12588
+ notAfterMs: number2().int().nonnegative().optional(),
12486
12589
  owner: string2().min(1).max(64).regex(/^[a-z0-9][a-z0-9.-]*$/),
12590
+ protocolVersion: literal(2).optional(),
12487
12591
  runId: string2().min(1).max(96).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
12488
12592
  }).refine((input) => input.action === "acquire" || input.action === "cancel" || input.fencingToken !== undefined, "heartbeat and release require a fencing token");
12489
12593
  DatabaseAdmissionResponseSchema = object({
@@ -12495,10 +12599,12 @@ var init_admin_database_admission = __esm(() => {
12495
12599
  holdMs: number2().int().nonnegative(),
12496
12600
  lane: DatabaseAdmissionLaneSchema,
12497
12601
  leaseExpiresAtMs: number2().int().nonnegative().nullable(),
12602
+ leaseRemainingMs: number2().int().nonnegative().nullable(),
12498
12603
  operationId: string2().min(1).max(64),
12499
12604
  outcome: DatabaseAdmissionOutcomeSchema,
12500
12605
  queueAgeMs: number2().int().nonnegative(),
12501
12606
  recovered: boolean2(),
12607
+ retryAfterMs: number2().int().nonnegative().nullable(),
12502
12608
  waitMs: number2().int().nonnegative(),
12503
12609
  yieldReason: DatabaseAdmissionYieldReasonSchema.nullable()
12504
12610
  });
@@ -12509,8 +12615,23 @@ var init_admin_database_admission = __esm(() => {
12509
12615
  summary: "Coordinate one registry-classified recurring database operation",
12510
12616
  tags: ["Admin"]
12511
12617
  }).input(DatabaseAdmissionInputSchema).output(DatabaseAdmissionResponseSchema);
12618
+ DatabaseWriteProbeOutcomeSchema = _enum(["failed", "ok", "slow", "stalled"]);
12619
+ DatabaseWriteProbeResponseSchema = object({
12620
+ latencyMs: number2().int().nonnegative().nullable(),
12621
+ ok: literal(true),
12622
+ outcome: DatabaseWriteProbeOutcomeSchema,
12623
+ recorded: boolean2()
12624
+ });
12625
+ recordDatabaseWriteProbe = oc.route({
12626
+ method: "POST",
12627
+ operationId: "recordDatabaseWriteProbe",
12628
+ path: "/admin/database-admission/write-probe",
12629
+ summary: "Time one bounded write to the primary database and record it for admission",
12630
+ tags: ["Admin"]
12631
+ }).input(object({})).output(DatabaseWriteProbeResponseSchema);
12512
12632
  adminDatabaseAdmissionContract = {
12513
- coordinate_database_admission: coordinateDatabaseAdmission
12633
+ coordinate_database_admission: coordinateDatabaseAdmission,
12634
+ record_database_write_probe: recordDatabaseWriteProbe
12514
12635
  };
12515
12636
  });
12516
12637
 
@@ -13824,6 +13945,142 @@ var init_admin_hub_counts = __esm(() => {
13824
13945
  };
13825
13946
  });
13826
13947
 
13948
+ // ../../packages/contracts/src/orpc/admin-label-outliers.ts
13949
+ var MAX_RECORDED_LABEL_OUTLIERS = 2000, MAX_LABEL_OUTLIER_DISMISSALS = 500, LabelOutlierReferenceSchema, RecordedLabelOutlierSchema, LabelOutlierRunSchema, AlertedLabelOutlierSchema, PendingLabelOutlierAlertSchema, recordLabelOutliers, acknowledgeLabelOutlierAlerts, LabelOutlierTrackSchema, LabelOutlierItemSchema, listLabelOutliers, setLabelOutliersDismissed, LabelOutlierInputTrackSchema, LabelOutlierInputAlbumSchema, listLabelOutlierInputs, adminLabelOutliersContract;
13950
+ var init_admin_label_outliers = __esm(() => {
13951
+ init_dist3();
13952
+ init_zod();
13953
+ LabelOutlierReferenceSchema = _enum(["catalogue", "label"]);
13954
+ RecordedLabelOutlierSchema = object({
13955
+ albumId: string2().min(1).max(256).nullable(),
13956
+ artistSupport: number2().int().min(0),
13957
+ fingerprint: string2().min(1).max(128),
13958
+ labelId: string2().min(1).max(256).nullable(),
13959
+ reference: LabelOutlierReferenceSchema,
13960
+ referenceMedian: number2(),
13961
+ score: number2(),
13962
+ singleTrackId: string2().min(1).max(256).nullable(),
13963
+ trackCount: number2().int().min(1),
13964
+ unitId: string2().min(1).max(256),
13965
+ z: number2()
13966
+ }).meta({ id: "RecordedLabelOutlier" });
13967
+ LabelOutlierRunSchema = object({
13968
+ flagged: number2().int().min(0),
13969
+ labelsScored: number2().int().min(0),
13970
+ ranAt: string2(),
13971
+ replicaSyncedAt: string2().nullable(),
13972
+ tracksScored: number2().int().min(0),
13973
+ unitsScored: number2().int().min(0)
13974
+ }).meta({ id: "LabelOutlierRun" });
13975
+ AlertedLabelOutlierSchema = object({
13976
+ fingerprint: string2().min(1).max(128),
13977
+ unitId: string2().min(1).max(256)
13978
+ }).meta({ id: "AlertedLabelOutlier" });
13979
+ PendingLabelOutlierAlertSchema = object({
13980
+ albumName: string2().nullable(),
13981
+ labelName: string2().nullable(),
13982
+ title: string2(),
13983
+ unitId: string2()
13984
+ }).meta({ id: "PendingLabelOutlierAlert" });
13985
+ recordLabelOutliers = oc.route({
13986
+ method: "PUT",
13987
+ operationId: "recordLabelOutliers",
13988
+ path: "/admin/label-outliers",
13989
+ summary: "Replace the nightly label-outlier review list with the box's scored set",
13990
+ tags: ["Admin"]
13991
+ }).input(object({
13992
+ labelsScored: number2().int().min(0),
13993
+ outliers: array(RecordedLabelOutlierSchema).max(MAX_RECORDED_LABEL_OUTLIERS),
13994
+ replicaSyncedAt: string2().nullable(),
13995
+ totalFlagged: number2().int().min(0),
13996
+ tracksScored: number2().int().min(0),
13997
+ unitsScored: number2().int().min(0)
13998
+ })).output(object({
13999
+ flagged: number2().int(),
14000
+ ok: literal(true),
14001
+ pendingAlertUnits: array(AlertedLabelOutlierSchema),
14002
+ pendingAlerts: array(PendingLabelOutlierAlertSchema),
14003
+ removed: number2().int()
14004
+ }));
14005
+ acknowledgeLabelOutlierAlerts = oc.route({
14006
+ method: "PUT",
14007
+ operationId: "acknowledgeLabelOutlierAlerts",
14008
+ path: "/admin/label-outliers/alerts",
14009
+ summary: "Mark flagged label outliers as announced, once their Discord summary has landed",
14010
+ tags: ["Admin"]
14011
+ }).input(object({
14012
+ units: array(AlertedLabelOutlierSchema).min(1).max(MAX_RECORDED_LABEL_OUTLIERS)
14013
+ })).output(object({ acknowledged: number2().int(), ok: literal(true) }));
14014
+ LabelOutlierTrackSchema = object({
14015
+ artists: array(object({ name: string2(), slug: string2() })),
14016
+ title: string2(),
14017
+ trackId: string2()
14018
+ }).meta({ id: "LabelOutlierTrack" });
14019
+ LabelOutlierItemSchema = object({
14020
+ album: object({ id: string2(), name: string2(), slug: string2() }).nullable(),
14021
+ artistSupport: number2(),
14022
+ discogsStyles: array(string2()),
14023
+ dismissedAt: string2().nullable(),
14024
+ firstFlaggedAt: string2(),
14025
+ label: object({ id: string2(), name: string2(), slug: string2() }).nullable(),
14026
+ reference: LabelOutlierReferenceSchema,
14027
+ referenceMedian: number2(),
14028
+ score: number2(),
14029
+ trackCount: number2(),
14030
+ tracks: array(LabelOutlierTrackSchema),
14031
+ unitId: string2(),
14032
+ z: number2()
14033
+ }).meta({ id: "LabelOutlierItem" });
14034
+ listLabelOutliers = oc.route({
14035
+ method: "GET",
14036
+ operationId: "listLabelOutliers",
14037
+ path: "/admin/label-outliers",
14038
+ summary: "The label-outlier review list: albums and singles that sound unlike their label",
14039
+ tags: ["Admin"]
14040
+ }).output(object({
14041
+ items: array(LabelOutlierItemSchema),
14042
+ lastRun: LabelOutlierRunSchema.nullable(),
14043
+ ok: literal(true)
14044
+ }));
14045
+ setLabelOutliersDismissed = oc.route({
14046
+ method: "PUT",
14047
+ operationId: "setLabelOutliersDismissed",
14048
+ path: "/admin/label-outliers/dismissed",
14049
+ summary: "Mark flagged label outliers as looking fine, or put them back on the list (operator)",
14050
+ tags: ["Admin"]
14051
+ }).input(object({
14052
+ dismissed: boolean2(),
14053
+ unitIds: array(string2().min(1).max(256)).min(1).max(MAX_LABEL_OUTLIER_DISMISSALS)
14054
+ })).output(object({ changed: number2().int(), ok: literal(true) }));
14055
+ LabelOutlierInputTrackSchema = object({
14056
+ albumId: string2().nullable(),
14057
+ artistIds: array(string2()),
14058
+ embeddingBase64: string2(),
14059
+ labelId: string2().nullable(),
14060
+ trackId: string2()
14061
+ }).meta({ id: "LabelOutlierInputTrack" });
14062
+ LabelOutlierInputAlbumSchema = object({ discogsStyles: string2(), id: string2() }).meta({ id: "LabelOutlierInputAlbum" });
14063
+ listLabelOutlierInputs = oc.route({
14064
+ method: "GET",
14065
+ operationId: "listLabelOutlierInputs",
14066
+ path: "/admin/label-outliers/inputs",
14067
+ summary: "One keyset page of the label-outlier scoring inputs: embedded catalogue tracks with their raw vector bytes, artist credits, and album Discogs styles",
14068
+ tags: ["Admin"]
14069
+ }).input(object({ cursor: string2().max(512).optional(), limit: string2().optional() })).output(object({
14070
+ albums: array(LabelOutlierInputAlbumSchema),
14071
+ nextCursor: string2().nullable(),
14072
+ ok: literal(true),
14073
+ tracks: array(LabelOutlierInputTrackSchema)
14074
+ }));
14075
+ adminLabelOutliersContract = {
14076
+ acknowledge_label_outlier_alerts: acknowledgeLabelOutlierAlerts,
14077
+ list_label_outlier_inputs: listLabelOutlierInputs,
14078
+ list_label_outliers: listLabelOutliers,
14079
+ record_label_outliers: recordLabelOutliers,
14080
+ set_label_outliers_dismissed: setLabelOutliersDismissed
14081
+ };
14082
+ });
14083
+
13827
14084
  // ../../packages/contracts/src/orpc/admin-labels.ts
13828
14085
  var LabelSeedStateSchema, LabelTriageVerdictSchema, LabelAdminItemSchema, listLabelsAdmin, updateLabel, listLabelArtistRules, LABEL_ARTIST_RULES_MAX = 100, LABEL_TRIAGE_RULE_PROPOSALS_MAX, replaceLabelArtistRules, MergeLabelResultSchema, mergeLabel, MB_LABEL_MBID_PATTERN, MintLabelOutcomeSchema, LabelTakeOverResultSchema, mintLabel, LabelAliasSourceSchema, LabelAliasKindSchema, LabelAliasCandidateSchema, listLabelAliases, confirmLabelAlias, rejectLabelAlias, DescribeLabelBodySchema, describeLabel, draftLabelBio, LabelBioWorkItemSchema, listLabelsMissingBio, TriageRuleProposalSchema, RecordLabelTriageBodySchema, recordLabelTriage, adminLabelsContract;
13829
14086
  var init_admin_labels = __esm(() => {
@@ -13833,6 +14090,7 @@ var init_admin_labels = __esm(() => {
13833
14090
  LabelSeedStateSchema = _enum(["disabled", "enabled", "undecided"]).meta({ id: "LabelSeedState" });
13834
14091
  LabelTriageVerdictSchema = _enum(["dnb", "dnb_partial", "not_dnb", "unclear"]).meta({ id: "LabelTriageVerdict" });
13835
14092
  LabelAdminItemSchema = object({
14093
+ carriesArtistRules: boolean2().optional(),
13836
14094
  createdAt: string2(),
13837
14095
  disambiguation: string2().nullable().optional(),
13838
14096
  findingCount: number2(),
@@ -15872,6 +16130,145 @@ var init_admin_telemetry2 = __esm(() => {
15872
16130
  };
15873
16131
  });
15874
16132
 
16133
+ // ../../packages/contracts/src/orpc/admin-turso-usage.ts
16134
+ var MAX_TURSO_USAGE_DATABASES = 500, TURSO_USAGE_THRESHOLD_MIN_USD = 1, TURSO_USAGE_THRESHOLD_MAX_USD = 1e5, Count, CycleKey, TursoUsageResourceKeySchema, TursoUsageRateBasisSchema, TursoUsageTotalsSchema, TursoDatabaseUsageSchema, TursoUsageResourceSchema, TursoAttributedDatabaseSchema, TursoUsageSnapshotSchema, TursoUsageHistoryDaySchema, TursoUsageAlertSchema, TursoUsageAlertKeySchema, recordTursoUsage, getTursoUsage, acknowledgeTursoUsageAlerts, setTursoUsageThreshold, adminTursoUsageContract;
16135
+ var init_admin_turso_usage = __esm(() => {
16136
+ init_dist3();
16137
+ init_zod();
16138
+ Count = number2().int().min(0);
16139
+ CycleKey = string2().regex(/^\d{4}-\d{2}$/);
16140
+ TursoUsageResourceKeySchema = _enum([
16141
+ "rowsRead",
16142
+ "rowsWritten",
16143
+ "storage",
16144
+ "embeddedSyncs"
16145
+ ]);
16146
+ TursoUsageRateBasisSchema = _enum(["recent", "cycle-to-date", "none"]);
16147
+ TursoUsageTotalsSchema = object({
16148
+ bytesSynced: Count,
16149
+ rowsRead: Count,
16150
+ rowsWritten: Count,
16151
+ storageBytes: Count
16152
+ }).meta({ id: "TursoUsageTotals" });
16153
+ TursoDatabaseUsageSchema = TursoUsageTotalsSchema.extend({
16154
+ name: string2().min(1).max(256)
16155
+ }).meta({ id: "TursoDatabaseUsage" });
16156
+ TursoUsageResourceSchema = object({
16157
+ dailyRate: number2().min(0),
16158
+ included: number2().min(0),
16159
+ key: TursoUsageResourceKeySchema,
16160
+ overageUsd: number2().min(0),
16161
+ projectedOverageUsd: number2().min(0),
16162
+ projectedUsed: number2().min(0),
16163
+ unitSize: number2().positive(),
16164
+ usdPerUnit: number2().min(0),
16165
+ used: number2().min(0)
16166
+ }).meta({ id: "TursoUsageResource" });
16167
+ TursoAttributedDatabaseSchema = TursoDatabaseUsageSchema.extend({
16168
+ attributedOverageUsd: number2().min(0)
16169
+ }).meta({ id: "TursoAttributedDatabase" });
16170
+ TursoUsageSnapshotSchema = object({
16171
+ baseUsd: number2().min(0).nullable(),
16172
+ cycle: CycleKey,
16173
+ cycleEnd: string2(),
16174
+ cycleStart: string2(),
16175
+ databases: array(TursoAttributedDatabaseSchema),
16176
+ observedAt: string2(),
16177
+ overageUsd: number2().min(0),
16178
+ overagesEnabled: boolean2(),
16179
+ plan: string2(),
16180
+ priceSource: string2(),
16181
+ priceTableVersion: string2(),
16182
+ priced: boolean2(),
16183
+ projectedBillUsd: number2().min(0).nullable(),
16184
+ projectedOverageUsd: number2().min(0),
16185
+ rateBasis: TursoUsageRateBasisSchema,
16186
+ rateWindowHours: number2().min(0).nullable(),
16187
+ resources: array(TursoUsageResourceSchema),
16188
+ timeline: string2().nullable(),
16189
+ upcomingInvoiceUsd: number2().min(0).nullable()
16190
+ }).meta({ id: "TursoUsageSnapshot" });
16191
+ TursoUsageHistoryDaySchema = object({
16192
+ bytesSynced: Count,
16193
+ day: string2(),
16194
+ overageUsd: number2().min(0),
16195
+ projectedOverageUsd: number2().min(0),
16196
+ rowsRead: Count,
16197
+ rowsWritten: Count,
16198
+ storageBytes: Count
16199
+ }).meta({ id: "TursoUsageHistoryDay" });
16200
+ TursoUsageAlertSchema = object({
16201
+ cycle: CycleKey,
16202
+ deliveredAt: string2().nullable(),
16203
+ levelUsd: number2().positive(),
16204
+ projectedOverageUsd: number2().min(0),
16205
+ raisedAt: string2()
16206
+ }).meta({ id: "TursoUsageAlert" });
16207
+ TursoUsageAlertKeySchema = object({
16208
+ cycle: CycleKey,
16209
+ levelUsd: number2().positive()
16210
+ }).meta({ id: "TursoUsageAlertKey" });
16211
+ recordTursoUsage = oc.route({
16212
+ method: "POST",
16213
+ operationId: "recordTursoUsage",
16214
+ path: "/admin/costs/turso-usage",
16215
+ summary: "Record one Turso platform usage reading and price it against the plan",
16216
+ tags: ["Admin"]
16217
+ }).input(object({
16218
+ databases: array(TursoDatabaseUsageSchema).max(MAX_TURSO_USAGE_DATABASES),
16219
+ observedAt: string2().min(1).max(64),
16220
+ plan: object({
16221
+ name: string2().min(1).max(64),
16222
+ overages: boolean2(),
16223
+ timeline: string2().max(32).nullable()
16224
+ }),
16225
+ upcomingInvoiceUsd: number2().min(0).nullable(),
16226
+ usage: TursoUsageTotalsSchema
16227
+ })).output(object({
16228
+ ok: literal(true),
16229
+ pendingAlerts: array(TursoUsageAlertSchema),
16230
+ snapshot: TursoUsageSnapshotSchema,
16231
+ stored: boolean2(),
16232
+ thresholdUsd: number2().positive()
16233
+ }));
16234
+ getTursoUsage = oc.route({
16235
+ method: "GET",
16236
+ operationId: "getTursoUsage",
16237
+ path: "/admin/costs/turso-usage",
16238
+ summary: "The latest priced Turso usage reading, its per-day history, and this cycle's alerts (operator)",
16239
+ tags: ["Admin"]
16240
+ }).output(object({
16241
+ alerts: array(TursoUsageAlertSchema),
16242
+ available: boolean2(),
16243
+ history: array(TursoUsageHistoryDaySchema),
16244
+ latest: TursoUsageSnapshotSchema.nullable(),
16245
+ ok: literal(true),
16246
+ thresholdUsd: number2().positive()
16247
+ }));
16248
+ acknowledgeTursoUsageAlerts = oc.route({
16249
+ method: "PUT",
16250
+ operationId: "acknowledgeTursoUsageAlerts",
16251
+ path: "/admin/costs/turso-usage/alerts",
16252
+ summary: "Mark Turso overage alerts as delivered, once their Discord post has landed",
16253
+ tags: ["Admin"]
16254
+ }).input(object({ alerts: array(TursoUsageAlertKeySchema).min(1).max(20) })).output(object({ acknowledged: number2().int().min(0), ok: literal(true) }));
16255
+ setTursoUsageThreshold = oc.route({
16256
+ method: "PUT",
16257
+ operationId: "setTursoUsageThreshold",
16258
+ path: "/admin/costs/turso-usage/threshold",
16259
+ summary: "Set the projected Turso overage that raises a Discord alert (operator)",
16260
+ tags: ["Admin"]
16261
+ }).input(object({
16262
+ thresholdUsd: number2().min(TURSO_USAGE_THRESHOLD_MIN_USD).max(TURSO_USAGE_THRESHOLD_MAX_USD)
16263
+ })).output(object({ ok: literal(true), thresholdUsd: number2().positive() }));
16264
+ adminTursoUsageContract = {
16265
+ acknowledge_turso_usage_alerts: acknowledgeTursoUsageAlerts,
16266
+ get_turso_usage: getTursoUsage,
16267
+ record_turso_usage: recordTursoUsage,
16268
+ set_turso_usage_threshold: setTursoUsageThreshold
16269
+ };
16270
+ });
16271
+
15875
16272
  // ../../packages/contracts/src/orpc/admin-twitch.ts
15876
16273
  var recordLiveState, adminTwitchContract;
15877
16274
  var init_admin_twitch = __esm(() => {
@@ -17286,6 +17683,7 @@ var init_orpc = __esm(() => {
17286
17683
  init_admin_funnel();
17287
17684
  init_admin_health();
17288
17685
  init_admin_hub_counts();
17686
+ init_admin_label_outliers();
17289
17687
  init_admin_labels();
17290
17688
  init_admin_logbook();
17291
17689
  init_admin_migrations();
@@ -17300,6 +17698,7 @@ var init_orpc = __esm(() => {
17300
17698
  init_admin_tokens();
17301
17699
  init_admin_tracks();
17302
17700
  init_admin_telemetry2();
17701
+ init_admin_turso_usage();
17303
17702
  init_admin_twitch();
17304
17703
  init_admin_users();
17305
17704
  init_devices();
@@ -17332,6 +17731,7 @@ var init_orpc = __esm(() => {
17332
17731
  init_mix();
17333
17732
  init_galaxies2();
17334
17733
  init_graph();
17734
+ init_admin_label_outliers();
17335
17735
  init_admin_galaxies();
17336
17736
  init_admin_albums();
17337
17737
  init_admin_artifacts();
@@ -17349,6 +17749,7 @@ var init_orpc = __esm(() => {
17349
17749
  init_admin_notes();
17350
17750
  init_admin_observations();
17351
17751
  init_admin_costs();
17752
+ init_admin_turso_usage();
17352
17753
  init_admin_database_admission();
17353
17754
  init_admin_telemetry2();
17354
17755
  init_admin_health();
@@ -17417,6 +17818,7 @@ var init_orpc = __esm(() => {
17417
17818
  ...adminHealthContract,
17418
17819
  ...adminHubCountsContract,
17419
17820
  ...followDigestContract,
17821
+ ...adminLabelOutliersContract,
17420
17822
  ...adminLabelsContract,
17421
17823
  ...adminLogbookContract,
17422
17824
  ...adminMigrationsContract,
@@ -17431,6 +17833,7 @@ var init_orpc = __esm(() => {
17431
17833
  ...adminTokensContract,
17432
17834
  ...adminTracksContract,
17433
17835
  ...adminTelemetryContract,
17836
+ ...adminTursoUsageContract,
17434
17837
  ...adminTwitchContract,
17435
17838
  ...adminUsersContract,
17436
17839
  ...devicesContract,
@@ -19858,6 +20261,974 @@ var init_admin_covers = __esm(() => {
19858
20261
  init_api();
19859
20262
  });
19860
20263
 
20264
+ // src/evidence-http.ts
20265
+ import { createHash } from "node:crypto";
20266
+ import { mkdir as mkdir2, readFile as readFile2, rename, rm, stat, writeFile as writeFile2 } from "node:fs/promises";
20267
+ import { homedir as homedir4 } from "node:os";
20268
+ import { join as join6 } from "node:path";
20269
+ function defaultCacheDir() {
20270
+ const base = process.env.XDG_CACHE_HOME || join6(homedir4(), ".cache");
20271
+ return join6(base, "fluncle", "label-evidence");
20272
+ }
20273
+ function createEvidenceHttp(overrides = {}) {
20274
+ return {
20275
+ cacheDir: defaultCacheDir(),
20276
+ cacheTtlMs: 7 * 24 * 60 * 60 * 1000,
20277
+ fetch: (url, init) => globalThis.fetch(url, init),
20278
+ now: () => Date.now(),
20279
+ policies: SOURCE_POLICIES,
20280
+ random: Math.random,
20281
+ refresh: false,
20282
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
20283
+ stats: { cacheHits: 0, requests: 0 },
20284
+ ...overrides
20285
+ };
20286
+ }
20287
+ function cachePath(http, source, key) {
20288
+ const digest = createHash("sha256").update(key).digest("hex");
20289
+ return join6(http.cacheDir, source, `${digest}.json`);
20290
+ }
20291
+ async function readCache2(http, source, key) {
20292
+ if (http.refresh) {
20293
+ return null;
20294
+ }
20295
+ try {
20296
+ const entry = JSON.parse(await readFile2(cachePath(http, source, key), "utf8"));
20297
+ if (entry.key !== key || http.now() - entry.fetchedAt > http.cacheTtlMs) {
20298
+ return null;
20299
+ }
20300
+ return entry.text;
20301
+ } catch {
20302
+ return null;
20303
+ }
20304
+ }
20305
+ async function writeCache2(http, source, key, text) {
20306
+ const path = cachePath(http, source, key);
20307
+ const temp = `${path}.${process.pid}.${Math.floor(http.random() * 1e9)}.tmp`;
20308
+ try {
20309
+ await mkdir2(join6(http.cacheDir, source), { recursive: true });
20310
+ await writeFile2(temp, JSON.stringify({ fetchedAt: http.now(), key, text }));
20311
+ await rename(temp, path);
20312
+ } catch {
20313
+ await rm(temp, { force: true }).catch(() => {
20314
+ return;
20315
+ });
20316
+ }
20317
+ }
20318
+ async function withSlotLock(http, source, critical) {
20319
+ const dir = join6(http.cacheDir, "slots");
20320
+ await mkdir2(dir, { recursive: true });
20321
+ const lock = join6(dir, `${source}.lock`);
20322
+ for (;; ) {
20323
+ try {
20324
+ await mkdir2(lock);
20325
+ break;
20326
+ } catch (error) {
20327
+ if (error.code !== "EEXIST") {
20328
+ throw error;
20329
+ }
20330
+ const held = await stat(lock).catch(() => null);
20331
+ if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
20332
+ const evicted = `${lock}.stale.${process.pid}.${Math.floor(Math.random() * 1e9)}`;
20333
+ const won = await rename(lock, evicted).then(() => true, () => false);
20334
+ if (won) {
20335
+ await rm(evicted, { force: true, recursive: true });
20336
+ }
20337
+ continue;
20338
+ }
20339
+ await new Promise((resolve) => setTimeout(resolve, 15 + Math.floor(Math.random() * 35)));
20340
+ }
20341
+ }
20342
+ try {
20343
+ return await critical(join6(dir, `${source}.next`));
20344
+ } finally {
20345
+ await rm(lock, { force: true, recursive: true });
20346
+ }
20347
+ }
20348
+ async function readNextSlot(slotFile) {
20349
+ const raw = await readFile2(slotFile, "utf8").catch(() => "0");
20350
+ const value = Number(raw);
20351
+ return Number.isFinite(value) ? value : 0;
20352
+ }
20353
+ async function reserveSlot(http, source) {
20354
+ const interval = http.policies[source].intervalMs;
20355
+ const slotAt = await withSlotLock(http, source, async (slotFile) => {
20356
+ const now = http.now();
20357
+ const slot = Math.max(now, await readNextSlot(slotFile));
20358
+ await writeFile2(slotFile, String(slot + interval));
20359
+ return slot;
20360
+ });
20361
+ const wait = slotAt - http.now();
20362
+ if (wait > 0) {
20363
+ await http.sleep(wait);
20364
+ }
20365
+ }
20366
+ async function pushSlotBack(http, source, until) {
20367
+ await withSlotLock(http, source, async (slotFile) => {
20368
+ if (await readNextSlot(slotFile) < until) {
20369
+ await writeFile2(slotFile, String(until));
20370
+ }
20371
+ });
20372
+ }
20373
+ function parseRetryAfter(value, now) {
20374
+ if (!value) {
20375
+ return null;
20376
+ }
20377
+ const seconds = Number(value);
20378
+ if (Number.isFinite(seconds)) {
20379
+ return Math.max(0, seconds * 1000);
20380
+ }
20381
+ const at = Date.parse(value);
20382
+ return Number.isNaN(at) ? null : Math.max(0, at - now);
20383
+ }
20384
+ function isRetryableStatus(status) {
20385
+ return status === 429 || status >= 500 && status !== 501;
20386
+ }
20387
+ function failureKind(status) {
20388
+ if (status === 404) {
20389
+ return "not_found";
20390
+ }
20391
+ return status === 429 || status === 503 ? "rate_limited" : "http";
20392
+ }
20393
+ async function performWithTimeout(perform, timeoutMs) {
20394
+ const controller = new AbortController;
20395
+ let timer;
20396
+ const timeout = new Promise((resolve) => {
20397
+ timer = setTimeout(() => {
20398
+ controller.abort();
20399
+ resolve({ timedOut: true });
20400
+ }, timeoutMs);
20401
+ });
20402
+ try {
20403
+ return await Promise.race([
20404
+ perform(controller.signal).then((response) => ({ response }), (error) => controller.signal.aborted ? { timedOut: true } : { error }),
20405
+ timeout
20406
+ ]);
20407
+ } finally {
20408
+ clearTimeout(timer);
20409
+ }
20410
+ }
20411
+ async function fetchEvidenceText(http, source, cacheKey, perform, accept = () => true) {
20412
+ const hit = await readCache2(http, source, cacheKey);
20413
+ if (hit !== null && accept(hit)) {
20414
+ http.stats.cacheHits += 1;
20415
+ return { cached: true, text: hit };
20416
+ }
20417
+ const policy = http.policies[source];
20418
+ let last;
20419
+ for (let attempt = 1;attempt <= policy.attempts; attempt += 1) {
20420
+ await reserveSlot(http, source);
20421
+ http.stats.requests += 1;
20422
+ const outcome = await performWithTimeout(perform, policy.timeoutMs);
20423
+ let retryAfterMs = null;
20424
+ if ("response" in outcome) {
20425
+ const { response } = outcome;
20426
+ const success = response.status >= 200 && response.status < 300;
20427
+ if (success && accept(response.text)) {
20428
+ await writeCache2(http, source, cacheKey, response.text);
20429
+ return { cached: false, text: response.text };
20430
+ }
20431
+ if (success) {
20432
+ last = new EvidenceFetchError("invalid", `${source} answered HTTP ${response.status} with a body that is not the expected data`, attempt, response.status);
20433
+ } else {
20434
+ last = new EvidenceFetchError(failureKind(response.status), `${source} answered HTTP ${response.status}`, attempt, response.status);
20435
+ if (!isRetryableStatus(response.status)) {
20436
+ throw last;
20437
+ }
20438
+ retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), http.now());
20439
+ if (retryAfterMs !== null) {
20440
+ await pushSlotBack(http, source, http.now() + Math.min(retryAfterMs, policy.maxBackoffMs));
20441
+ }
20442
+ if (retryAfterMs !== null && retryAfterMs > policy.maxBackoffMs) {
20443
+ throw new EvidenceFetchError("rate_limited", `${source} asked to wait ${Math.ceil(retryAfterMs / 1000)} s, past the ${policy.maxBackoffMs / 1000} s limit`, attempt, response.status);
20444
+ }
20445
+ }
20446
+ } else if ("timedOut" in outcome) {
20447
+ last = new EvidenceFetchError("timeout", `${source} did not answer within ${policy.timeoutMs} ms`, attempt);
20448
+ } else {
20449
+ const reason = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
20450
+ last = new EvidenceFetchError("network", `${source} request failed: ${reason}`, attempt);
20451
+ }
20452
+ if (attempt === policy.attempts) {
20453
+ break;
20454
+ }
20455
+ const exponential = policy.intervalMs * 2 ** attempt;
20456
+ const backoff = Math.min(policy.maxBackoffMs, Math.max(retryAfterMs ?? 0, exponential) + Math.floor(http.random() * 250));
20457
+ await http.sleep(backoff);
20458
+ }
20459
+ throw new EvidenceFetchError(last?.kind ?? "network", `${last?.message ?? `${source} request failed`} (gave up after ${policy.attempts} attempts)`, policy.attempts, last?.status);
20460
+ }
20461
+ function isJson(text) {
20462
+ try {
20463
+ JSON.parse(text);
20464
+ return true;
20465
+ } catch {
20466
+ return false;
20467
+ }
20468
+ }
20469
+ async function fetchEvidenceJson(http, source, url, headers = {}) {
20470
+ const { cached, text } = await fetchEvidenceText(http, source, `GET ${url}`, async (signal) => {
20471
+ const response = await http.fetch(url, {
20472
+ headers: { Accept: "application/json", "User-Agent": EVIDENCE_USER_AGENT, ...headers },
20473
+ signal
20474
+ });
20475
+ return { headers: response.headers, status: response.status, text: await response.text() };
20476
+ }, isJson);
20477
+ return { cached, data: JSON.parse(text) };
20478
+ }
20479
+ var SOURCE_POLICIES, EVIDENCE_USER_AGENT = "FluncleLabelTriage/1.0 ( https://www.fluncle.com )", EvidenceFetchError, LOCK_STALE_MS = 1e4;
20480
+ var init_evidence_http = __esm(() => {
20481
+ SOURCE_POLICIES = {
20482
+ apple: { attempts: 3, intervalMs: 3000, maxBackoffMs: 30000, timeoutMs: 1e4 },
20483
+ beatport: { attempts: 2, intervalMs: 1000, maxBackoffMs: 30000, timeoutMs: 75000 },
20484
+ discogs: { attempts: 3, intervalMs: 2500, maxBackoffMs: 30000, timeoutMs: 15000 },
20485
+ musicbrainz: { attempts: 4, intervalMs: 1100, maxBackoffMs: 30000, timeoutMs: 20000 }
20486
+ };
20487
+ EvidenceFetchError = class EvidenceFetchError extends Error {
20488
+ attempts;
20489
+ kind;
20490
+ status;
20491
+ constructor(kind, message, attempts, status) {
20492
+ super(message);
20493
+ this.name = "EvidenceFetchError";
20494
+ this.kind = kind;
20495
+ this.attempts = attempts;
20496
+ this.status = status;
20497
+ }
20498
+ };
20499
+ });
20500
+
20501
+ // src/commands/admin-labels.ts
20502
+ var exports_admin_labels = {};
20503
+ __export(exports_admin_labels, {
20504
+ resolveLabel: () => resolveLabel2
20505
+ });
20506
+ async function resolveLabel2(slugOrId) {
20507
+ const { labels } = await adminApiGet("/api/v1/admin/labels");
20508
+ const match = labels.find((label) => label.slug === slugOrId || label.id === slugOrId);
20509
+ if (!match) {
20510
+ throw new Error(`No label with slug or id '${slugOrId}' — check \`fluncle labels\``);
20511
+ }
20512
+ return match;
20513
+ }
20514
+ var init_admin_labels3 = __esm(() => {
20515
+ init_api();
20516
+ init_admin_artists3();
20517
+ });
20518
+
20519
+ // src/commands/admin-label-evidence.ts
20520
+ import { spawn } from "node:child_process";
20521
+ function parseCensusPages(value) {
20522
+ const pages = Number(value);
20523
+ if (!Number.isInteger(pages) || pages < 1 || pages > MAX_CENSUS_PAGES) {
20524
+ throw new Error(`Pass --census-pages 1-${MAX_CENSUS_PAGES}`);
20525
+ }
20526
+ return pages;
20527
+ }
20528
+ function parseEvidenceSources(value) {
20529
+ if (value === undefined || value.trim() === "") {
20530
+ return [...EVIDENCE_SOURCES];
20531
+ }
20532
+ const wanted = value.split(",").map((entry) => entry.trim().toLowerCase());
20533
+ const unknown = wanted.filter((entry) => !EVIDENCE_SOURCES.includes(entry));
20534
+ if (unknown.length > 0) {
20535
+ throw new Error(`Unknown source(s): ${unknown.join(", ")} — pick from ${EVIDENCE_SOURCES.join(", ")}`);
20536
+ }
20537
+ return EVIDENCE_SOURCES.filter((source) => wanted.includes(source));
20538
+ }
20539
+ function failureOf(error, request) {
20540
+ if (error instanceof EvidenceFetchError) {
20541
+ return {
20542
+ attempts: error.attempts,
20543
+ kind: error.kind,
20544
+ message: error.message,
20545
+ request,
20546
+ ...error.status === undefined ? {} : { status: error.status }
20547
+ };
20548
+ }
20549
+ return {
20550
+ kind: "parse",
20551
+ message: error instanceof Error ? error.message : String(error),
20552
+ request
20553
+ };
20554
+ }
20555
+ function statusFrom(parts, failures) {
20556
+ if (failures === 0) {
20557
+ return "ok";
20558
+ }
20559
+ return failures < parts ? "partial" : "error";
20560
+ }
20561
+ function tally(names) {
20562
+ const counts = new Map;
20563
+ for (const name of names) {
20564
+ counts.set(name, (counts.get(name) ?? 0) + 1);
20565
+ }
20566
+ return [...counts.entries()].map(([name, count]) => ({ count, name })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
20567
+ }
20568
+ function firstCredit(credit) {
20569
+ const head = credit?.[0];
20570
+ const mbid = head?.artist?.id;
20571
+ if (!mbid) {
20572
+ return null;
20573
+ }
20574
+ return { mbid, name: head?.artist?.name ?? head?.name ?? mbid };
20575
+ }
20576
+ function crawlRuleCredit(credit) {
20577
+ for (const entry of credit ?? []) {
20578
+ const name = entry.artist?.name ?? entry.name;
20579
+ const mbid = entry.artist?.id;
20580
+ if (name && mbid && mbid !== VARIOUS_ARTISTS_MBID) {
20581
+ return { mbid, name };
20582
+ }
20583
+ }
20584
+ return null;
20585
+ }
20586
+ function creditString(credit) {
20587
+ return (credit ?? []).map((entry) => entry.name ?? entry.artist?.name ?? "").join(" / ");
20588
+ }
20589
+ function linkOf(links, host) {
20590
+ return links.find((link) => link.url.includes(host))?.url ?? null;
20591
+ }
20592
+ function toLabelFacts(payload, mbid) {
20593
+ const relations = payload.relations ?? [];
20594
+ return {
20595
+ area: payload.area?.name ?? null,
20596
+ country: payload.country ?? null,
20597
+ disambiguation: payload.disambiguation || null,
20598
+ genres: (payload.genres ?? []).flatMap((genre) => genre.name ? [genre.name] : []),
20599
+ labelCode: payload["label-code"] ?? null,
20600
+ labelRelations: relations.flatMap((relation) => relation.label?.id ? [
20601
+ {
20602
+ direction: relation.direction ?? "forward",
20603
+ mbid: relation.label.id,
20604
+ name: relation.label.name ?? relation.label.id,
20605
+ type: relation.type ?? "unknown"
20606
+ }
20607
+ ] : []),
20608
+ lifeSpan: {
20609
+ begin: payload["life-span"]?.begin ?? null,
20610
+ end: payload["life-span"]?.end ?? null,
20611
+ ended: payload["life-span"]?.ended === true
20612
+ },
20613
+ links: relations.flatMap((relation) => relation.url?.resource ? [{ type: relation.type ?? "unknown", url: relation.url.resource }] : []),
20614
+ mbid: payload.id ?? mbid,
20615
+ name: payload.name ?? mbid,
20616
+ tags: (payload.tags ?? []).flatMap((tag) => tag.name ? [tag.name] : []),
20617
+ type: payload.type ?? null
20618
+ };
20619
+ }
20620
+ function toReleaseFacts(browse, mbid) {
20621
+ const releases = browse.releases ?? [];
20622
+ const byArtist = new Map;
20623
+ let variousArtistsReleases = 0;
20624
+ const samples = releases.map((release) => {
20625
+ const head = firstCredit(release["artist-credit"]);
20626
+ if (head?.mbid === VARIOUS_ARTISTS_MBID) {
20627
+ variousArtistsReleases += 1;
20628
+ } else if (head) {
20629
+ const entry = byArtist.get(head.mbid) ?? { mbid: head.mbid, name: head.name, releases: 0 };
20630
+ entry.releases += 1;
20631
+ byArtist.set(head.mbid, entry);
20632
+ }
20633
+ const info = release["label-info"]?.find((entry) => entry.label?.id === mbid);
20634
+ const formats = [
20635
+ ...new Set((release.media ?? []).flatMap((m) => m.format ? [m.format] : []))
20636
+ ];
20637
+ return {
20638
+ artist: creditString(release["artist-credit"]),
20639
+ barcode: release.barcode || null,
20640
+ catno: info?.["catalog-number"] ?? null,
20641
+ date: release.date || null,
20642
+ format: formats.length > 0 ? formats.join(" + ") : null,
20643
+ mbid: release.id,
20644
+ status: release.status ?? null,
20645
+ title: release.title ?? ""
20646
+ };
20647
+ });
20648
+ return {
20649
+ firstCreditArtists: [...byArtist.values()].sort((a, b) => b.releases - a.releases || a.name.localeCompare(b.name)).slice(0, FIRST_CREDIT_ARTISTS),
20650
+ releaseCount: browse["release-count"] ?? releases.length,
20651
+ releases: samples.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")).slice(0, RELEASE_SAMPLE),
20652
+ releasesRead: releases.length,
20653
+ variousArtistsReleases
20654
+ };
20655
+ }
20656
+ async function runCensus(deps, mbid, maxPages) {
20657
+ const credits = new Map;
20658
+ const seen = new Set;
20659
+ let offset = 0;
20660
+ let pagesFetched = 0;
20661
+ let releaseCount = 0;
20662
+ let allCached = true;
20663
+ let failure;
20664
+ let uncredited = 0;
20665
+ while (pagesFetched < maxPages) {
20666
+ const url = `${MB_ROOT}/release?label=${mbid}&inc=artist-credits+recordings&limit=100&offset=${offset}&fmt=json`;
20667
+ let page;
20668
+ try {
20669
+ page = await fetchEvidenceJson(deps.http, "musicbrainz", url);
20670
+ } catch (error) {
20671
+ if (pagesFetched === 0) {
20672
+ throw error;
20673
+ }
20674
+ failure = failureOf(error, url);
20675
+ break;
20676
+ }
20677
+ const { cached, data } = page;
20678
+ const releases = data.releases ?? [];
20679
+ allCached &&= cached;
20680
+ pagesFetched += 1;
20681
+ releaseCount = data["release-count"] ?? releaseCount;
20682
+ for (const release of releases) {
20683
+ for (const medium of release.media ?? []) {
20684
+ for (const track of medium.tracks ?? []) {
20685
+ const recordingId = track.recording?.id;
20686
+ if (!recordingId || seen.has(recordingId)) {
20687
+ continue;
20688
+ }
20689
+ seen.add(recordingId);
20690
+ const head = crawlRuleCredit(track.recording?.["artist-credit"] ?? release["artist-credit"]);
20691
+ if (!head) {
20692
+ uncredited += 1;
20693
+ continue;
20694
+ }
20695
+ const entry = credits.get(head.mbid) ?? {
20696
+ artistMbid: head.mbid,
20697
+ artistName: head.name,
20698
+ recordings: 0
20699
+ };
20700
+ entry.recordings += 1;
20701
+ credits.set(head.mbid, entry);
20702
+ }
20703
+ }
20704
+ }
20705
+ offset += releases.length;
20706
+ if (releases.length === 0 || offset >= releaseCount) {
20707
+ break;
20708
+ }
20709
+ }
20710
+ const sampled = offset < releaseCount;
20711
+ const firstCredits = [...credits.values()].sort((a, b) => b.recordings - a.recordings || a.artistName.localeCompare(b.artistName));
20712
+ return {
20713
+ cached: allCached,
20714
+ ...failure ? { failure } : {},
20715
+ census: {
20716
+ caveat: sampled ? `sampled: first ${offset} of ${releaseCount} releases${failure ? " (a later page failed)" : ""}` : null,
20717
+ firstCredits,
20718
+ pagesFetched,
20719
+ recordingsCounted: firstCredits.reduce((sum, entry) => sum + entry.recordings, uncredited),
20720
+ releaseCount,
20721
+ releasesRead: offset,
20722
+ sampled,
20723
+ uncreditedRecordings: uncredited
20724
+ }
20725
+ };
20726
+ }
20727
+ async function gatherMusicBrainz(deps, mbid, options) {
20728
+ const data = {};
20729
+ const errors = [];
20730
+ let parts = 2;
20731
+ let allCached = true;
20732
+ const labelUrl = `${MB_ROOT}/label/${mbid}?inc=aliases+label-rels+url-rels+tags+genres&fmt=json`;
20733
+ try {
20734
+ const { cached, data: payload } = await fetchEvidenceJson(deps.http, "musicbrainz", labelUrl);
20735
+ allCached &&= cached;
20736
+ data.label = toLabelFacts(payload, mbid);
20737
+ } catch (error) {
20738
+ errors.push(failureOf(error, labelUrl));
20739
+ }
20740
+ const releasesUrl = `${MB_ROOT}/release?label=${mbid}&inc=artist-credits+labels+media&limit=100&fmt=json`;
20741
+ try {
20742
+ const { cached, data: browse } = await fetchEvidenceJson(deps.http, "musicbrainz", releasesUrl);
20743
+ allCached &&= cached;
20744
+ data.releases = toReleaseFacts(browse, mbid);
20745
+ } catch (error) {
20746
+ errors.push(failureOf(error, releasesUrl));
20747
+ }
20748
+ if (options.census) {
20749
+ parts += 1;
20750
+ try {
20751
+ const { cached, census, failure } = await runCensus(deps, mbid, options.censusPages);
20752
+ allCached &&= cached;
20753
+ data.census = census;
20754
+ if (failure) {
20755
+ errors.push(failure);
20756
+ }
20757
+ } catch (error) {
20758
+ errors.push(failureOf(error, `${MB_ROOT}/release?label=${mbid}&inc=recordings (census)`));
20759
+ }
20760
+ }
20761
+ return {
20762
+ cached: allCached,
20763
+ data,
20764
+ ...errors.length > 0 ? { errors } : {},
20765
+ status: statusFrom(parts, errors.length)
20766
+ };
20767
+ }
20768
+ function discogsLabelIdFrom(url) {
20769
+ const match = url ? /discogs\.com\/(?:[a-z]{2}\/)?label\/(\d+)/i.exec(url) : null;
20770
+ return match?.[1] ? Number(match[1]) : null;
20771
+ }
20772
+ function beatportLabelUrlFrom(url) {
20773
+ const match = url ? /beatport\.com\/label\/([^/?#]+)\/(\d+)/i.exec(url) : null;
20774
+ return match?.[1] && match[2] ? `https://www.beatport.com/label/${match[1]}/${match[2]}` : null;
20775
+ }
20776
+ function discogsHeaders(deps) {
20777
+ return deps.discogsToken ? { Authorization: `Discogs token=${deps.discogsToken}` } : {};
20778
+ }
20779
+ async function discogsCandidates(deps, name) {
20780
+ const searchUrl = `${DISCOGS_ROOT}/database/search?type=label&q=${encodeURIComponent(name)}&per_page=5`;
20781
+ try {
20782
+ const { cached, data } = await fetchEvidenceJson(deps.http, "discogs", searchUrl, discogsHeaders(deps));
20783
+ const candidates = (data.results ?? []).flatMap((result) => result.id ? [
20784
+ {
20785
+ id: result.id,
20786
+ title: result.title ?? "",
20787
+ url: `https://www.discogs.com${result.uri ?? `/label/${result.id}`}`
20788
+ }
20789
+ ] : []);
20790
+ return {
20791
+ cached,
20792
+ data: { candidates },
20793
+ note: "The MusicBrainz label has no Discogs link. Candidates are unverified name matches: confirm the identity before citing any of them.",
20794
+ status: "no_link"
20795
+ };
20796
+ } catch (error) {
20797
+ return {
20798
+ errors: [failureOf(error, searchUrl)],
20799
+ note: "The MusicBrainz label has no Discogs link.",
20800
+ status: "no_link"
20801
+ };
20802
+ }
20803
+ }
20804
+ function toDiscogsLabel(payload, labelId, fallbackName) {
20805
+ const sublabels = (payload.sublabels ?? []).flatMap((entry) => entry.name ? [entry.name] : []);
20806
+ const parent = payload.parent_label;
20807
+ return {
20808
+ id: payload.id ?? labelId,
20809
+ name: payload.name ?? fallbackName,
20810
+ parentLabel: parent?.id === undefined ? null : { id: parent.id, name: parent.name ?? "" },
20811
+ profile: (payload.profile ?? "").slice(0, PROFILE_CHARS),
20812
+ sublabelCount: sublabels.length,
20813
+ sublabels: sublabels.slice(0, 30),
20814
+ url: payload.uri ?? `https://www.discogs.com/label/${labelId}`,
20815
+ urls: payload.urls ?? []
20816
+ };
20817
+ }
20818
+ function toDiscogsReleases(payload) {
20819
+ const releases = payload.releases ?? [];
20820
+ const ids = releases.flatMap((release) => {
20821
+ const id = release.type === "master" ? release.main_release : release.id;
20822
+ return typeof id === "number" ? [id] : [];
20823
+ });
20824
+ return {
20825
+ facts: {
20826
+ releaseCount: payload.pagination?.items ?? releases.length,
20827
+ sample: releases.slice(0, DISCOGS_RELEASE_SAMPLE).map((release) => ({
20828
+ artist: release.artist ?? "",
20829
+ catno: release.catno ?? null,
20830
+ format: release.format ?? null,
20831
+ title: release.title ?? "",
20832
+ year: release.year || null
20833
+ })),
20834
+ topArtists: tally(releases.map((release) => release.artist ?? "")).filter((entry) => entry.name !== "").slice(0, 15).map((entry) => ({ name: entry.name, releases: entry.count }))
20835
+ },
20836
+ ids
20837
+ };
20838
+ }
20839
+ async function gatherDiscogs(deps, mb) {
20840
+ const labelFacts = mb.label;
20841
+ if (!labelFacts) {
20842
+ return {
20843
+ note: "MusicBrainz label unavailable, so no Discogs link to follow",
20844
+ status: "skipped"
20845
+ };
20846
+ }
20847
+ const labelId = discogsLabelIdFrom(linkOf(labelFacts.links, "discogs.com"));
20848
+ if (labelId === null) {
20849
+ return discogsCandidates(deps, labelFacts.name);
20850
+ }
20851
+ const headers = discogsHeaders(deps);
20852
+ const data = {};
20853
+ const errors = [];
20854
+ const releaseIds = new Set;
20855
+ let allCached = true;
20856
+ const labelUrl = `${DISCOGS_ROOT}/labels/${labelId}`;
20857
+ try {
20858
+ const { cached, data: payload } = await fetchEvidenceJson(deps.http, "discogs", labelUrl, headers);
20859
+ allCached &&= cached;
20860
+ data.label = toDiscogsLabel(payload, labelId, labelFacts.name);
20861
+ } catch (error) {
20862
+ errors.push(failureOf(error, labelUrl));
20863
+ }
20864
+ const releasesUrl = `${DISCOGS_ROOT}/labels/${labelId}/releases?per_page=100&page=1`;
20865
+ try {
20866
+ const { cached, data: payload } = await fetchEvidenceJson(deps.http, "discogs", releasesUrl, headers);
20867
+ allCached &&= cached;
20868
+ const { facts, ids } = toDiscogsReleases(payload);
20869
+ data.releases = facts;
20870
+ for (const id of ids) {
20871
+ releaseIds.add(id);
20872
+ }
20873
+ } catch (error) {
20874
+ errors.push(failureOf(error, releasesUrl));
20875
+ }
20876
+ if (releaseIds.size > 0) {
20877
+ const styled = await readDiscogsStyles(deps, releaseIds, data.label?.name ?? labelFacts.name);
20878
+ allCached &&= styled.cached;
20879
+ errors.push(...styled.errors);
20880
+ data.styles = styled.styles;
20881
+ }
20882
+ return {
20883
+ cached: allCached,
20884
+ data,
20885
+ ...errors.length > 0 ? { errors } : {},
20886
+ status: discogsStatus(data, errors)
20887
+ };
20888
+ }
20889
+ function discogsStatus(data, errors) {
20890
+ if (Object.keys(data).length === 0) {
20891
+ return "error";
20892
+ }
20893
+ return errors.length > 0 ? "partial" : "ok";
20894
+ }
20895
+ function discogsSearchName(name) {
20896
+ return name.replace(/\s*\(\d+\)$/, "").replace(/^[^\p{L}\p{N}]+/u, "");
20897
+ }
20898
+ async function readDiscogsStyles(deps, releaseIds, labelName) {
20899
+ const headers = discogsHeaders(deps);
20900
+ const styled = new Map;
20901
+ const errors = [];
20902
+ let allCached = true;
20903
+ let viaSearch = 0;
20904
+ const searchUrl = `${DISCOGS_ROOT}/database/search?type=release&label=${encodeURIComponent(discogsSearchName(labelName))}&per_page=100`;
20905
+ try {
20906
+ const { cached, data } = await fetchEvidenceJson(deps.http, "discogs", searchUrl, headers);
20907
+ allCached &&= cached;
20908
+ for (const result of data.results ?? []) {
20909
+ if (typeof result.id === "number" && releaseIds.has(result.id) && !styled.has(result.id)) {
20910
+ styled.set(result.id, { genres: result.genre ?? [], styles: result.style ?? [] });
20911
+ }
20912
+ }
20913
+ viaSearch = styled.size;
20914
+ } catch (error) {
20915
+ errors.push(failureOf(error, searchUrl));
20916
+ }
20917
+ const target = Math.min(DISCOGS_STYLE_TARGET, releaseIds.size);
20918
+ const unread = [...releaseIds].filter((id) => !styled.has(id));
20919
+ let reads = 0;
20920
+ for (const id of unread) {
20921
+ if (styled.size >= target || reads >= DISCOGS_RELEASE_READS) {
20922
+ break;
20923
+ }
20924
+ reads += 1;
20925
+ const releaseUrl = `${DISCOGS_ROOT}/releases/${id}`;
20926
+ try {
20927
+ const { cached, data } = await fetchEvidenceJson(deps.http, "discogs", releaseUrl, headers);
20928
+ allCached &&= cached;
20929
+ styled.set(id, { genres: data.genres ?? [], styles: data.styles ?? [] });
20930
+ } catch (error) {
20931
+ errors.push(failureOf(error, releaseUrl));
20932
+ }
20933
+ }
20934
+ const rows = [...styled.values()];
20935
+ return {
20936
+ cached: allCached,
20937
+ errors,
20938
+ styles: {
20939
+ genres: tally(rows.flatMap((row) => row.genres)),
20940
+ labelReleasesListed: releaseIds.size,
20941
+ releasesRead: rows.length,
20942
+ styles: tally(rows.flatMap((row) => row.styles)).slice(0, 25),
20943
+ viaRelease: rows.length - viaSearch,
20944
+ viaSearch
20945
+ }
20946
+ };
20947
+ }
20948
+ function facetCounts(rows) {
20949
+ return (rows ?? []).flatMap((row) => row.name && typeof row.count === "number" ? [{ count: row.count, name: row.name }] : []).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
20950
+ }
20951
+ function parseBeatportTracksPage(html) {
20952
+ const island = /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(html);
20953
+ if (!island?.[1]) {
20954
+ return null;
20955
+ }
20956
+ let queries;
20957
+ try {
20958
+ const parsed = JSON.parse(island[1]);
20959
+ queries = parsed.props?.pageProps?.dehydratedState?.queries ?? [];
20960
+ } catch {
20961
+ return null;
20962
+ }
20963
+ const tracks = queries.find((query) => query.queryKey?.[0] === "tracks" && query.state?.data?.facets?.fields)?.state?.data;
20964
+ if (!tracks) {
20965
+ return null;
20966
+ }
20967
+ const fields = tracks.facets?.fields;
20968
+ const genres = facetCounts(fields?.genre);
20969
+ const total = genres.reduce((sum, genre) => sum + genre.count, 0);
20970
+ return {
20971
+ genres: genres.map((genre) => ({
20972
+ ...genre,
20973
+ share: total > 0 ? Math.round(genre.count / total * 1000) / 1000 : 0
20974
+ })),
20975
+ subGenres: facetCounts(fields?.sub_genre),
20976
+ topArtists: facetCounts(fields?.artists).slice(0, 15),
20977
+ trackCount: tracks.count ?? total
20978
+ };
20979
+ }
20980
+ async function gatherBeatport(deps, mb) {
20981
+ if (!mb.label) {
20982
+ return {
20983
+ note: "MusicBrainz label unavailable, so no Beatport link to follow",
20984
+ status: "skipped"
20985
+ };
20986
+ }
20987
+ const labelUrl = beatportLabelUrlFrom(linkOf(mb.label.links, "beatport.com"));
20988
+ if (labelUrl === null) {
20989
+ return { note: "The MusicBrainz label has no Beatport link.", status: "no_link" };
20990
+ }
20991
+ if (deps.scraper === null) {
20992
+ return {
20993
+ note: "Beatport needs Firecrawl: set FIRECRAWL_API_KEY or install the firecrawl CLI.",
20994
+ status: "not_configured"
20995
+ };
20996
+ }
20997
+ const scraper = deps.scraper;
20998
+ const tracksUrl = `${labelUrl}/tracks`;
20999
+ try {
21000
+ const { cached, text } = await fetchEvidenceText(deps.http, "beatport", `SCRAPE ${tracksUrl}`, (signal) => scraper(tracksUrl, signal), (html) => parseBeatportTracksPage(html) !== null);
21001
+ const facts = parseBeatportTracksPage(text);
21002
+ if (facts === null) {
21003
+ return {
21004
+ errors: [
21005
+ {
21006
+ kind: "parse",
21007
+ message: "The Beatport page carried no tracks facet (a missing label or a layout change)",
21008
+ request: tracksUrl
21009
+ }
21010
+ ],
21011
+ status: "error"
21012
+ };
21013
+ }
21014
+ return { cached, data: { ...facts, labelUrl, tracksUrl }, status: "ok" };
21015
+ } catch (error) {
21016
+ return { errors: [failureOf(error, tracksUrl)], status: "error" };
21017
+ }
21018
+ }
21019
+ async function gatherApple(deps, mb) {
21020
+ if (!mb.releases) {
21021
+ return {
21022
+ note: "MusicBrainz releases unavailable, so no barcodes to look up",
21023
+ status: "skipped"
21024
+ };
21025
+ }
21026
+ const seen = new Set;
21027
+ const targets = mb.releases.releases.filter((release) => {
21028
+ if (!release.barcode || seen.has(release.barcode)) {
21029
+ return false;
21030
+ }
21031
+ seen.add(release.barcode);
21032
+ return true;
21033
+ });
21034
+ if (targets.length === 0) {
21035
+ return { note: "No sampled MusicBrainz release carries a barcode.", status: "no_link" };
21036
+ }
21037
+ const releases = [];
21038
+ const errors = [];
21039
+ let allCached = true;
21040
+ for (const target of targets.slice(0, APPLE_LOOKUPS)) {
21041
+ const barcode = target.barcode ?? "";
21042
+ const url = `https://itunes.apple.com/lookup?upc=${encodeURIComponent(barcode)}&entity=album`;
21043
+ try {
21044
+ const { cached, data } = await fetchEvidenceJson(deps.http, "apple", url);
21045
+ allCached &&= cached;
21046
+ const hit = (data.results ?? []).find((result) => result.wrapperType === "collection");
21047
+ releases.push(hit ? {
21048
+ artistName: hit.artistName,
21049
+ barcode,
21050
+ collectionName: hit.collectionName,
21051
+ copyright: hit.copyright,
21052
+ found: true,
21053
+ mbTitle: target.title,
21054
+ primaryGenreName: hit.primaryGenreName
21055
+ } : { barcode, found: false, mbTitle: target.title });
21056
+ } catch (error) {
21057
+ errors.push(failureOf(error, url));
21058
+ }
21059
+ }
21060
+ const looked = Math.min(targets.length, APPLE_LOOKUPS);
21061
+ return {
21062
+ cached: allCached,
21063
+ data: {
21064
+ found: releases.filter((release) => release.found).length,
21065
+ genres: tally(releases.flatMap((release) => release.primaryGenreName ?? [])),
21066
+ looked,
21067
+ releases
21068
+ },
21069
+ ...errors.length > 0 ? { errors } : {},
21070
+ status: statusFrom(looked, errors.length)
21071
+ };
21072
+ }
21073
+ function skipped() {
21074
+ return { note: "Not requested (--sources).", status: "skipped" };
21075
+ }
21076
+ async function resolveTarget(input, deps) {
21077
+ const trimmed = input.trim();
21078
+ if (MBID_PATTERN3.test(trimmed)) {
21079
+ return { input: trimmed, mbLabelId: trimmed.toLowerCase() };
21080
+ }
21081
+ const label = await deps.resolveLabel(trimmed);
21082
+ if (!label.mbLabelId) {
21083
+ throw new Error(`Label '${label.slug}' has no MusicBrainz identity (mb_label_id), so nothing says which same-named label to research. Resolve it by hand first (docs/label-entity.md).`);
21084
+ }
21085
+ return {
21086
+ id: label.id,
21087
+ input: trimmed,
21088
+ mbLabelId: label.mbLabelId.toLowerCase(),
21089
+ name: label.name,
21090
+ seedState: label.seedState,
21091
+ slug: label.slug
21092
+ };
21093
+ }
21094
+ async function gatherLabelEvidence(input, options, deps) {
21095
+ const label = await resolveTarget(input, deps);
21096
+ const wants = (source) => options.sources.includes(source);
21097
+ const needsMb = wants("musicbrainz") || wants("discogs") || wants("beatport") || wants("apple");
21098
+ const musicbrainz = needsMb ? await gatherMusicBrainz(deps, label.mbLabelId, options) : skipped();
21099
+ const mb = musicbrainz.data ?? {};
21100
+ const [discogs, beatport, apple] = await Promise.all([
21101
+ wants("discogs") ? gatherDiscogs(deps, mb) : Promise.resolve(skipped()),
21102
+ wants("beatport") ? gatherBeatport(deps, mb) : Promise.resolve(skipped()),
21103
+ wants("apple") ? gatherApple(deps, mb) : Promise.resolve(skipped())
21104
+ ]);
21105
+ const sources = { apple, beatport, discogs, musicbrainz };
21106
+ const reports = Object.entries(sources);
21107
+ const errors = reports.flatMap(([source, report]) => (report.errors ?? []).map((failure) => ({ ...failure, source })));
21108
+ const requested = reports.filter(([source]) => wants(source));
21109
+ const answered = requested.filter(([, report]) => ["no_link", "ok", "partial"].includes(report.status));
21110
+ return {
21111
+ errors,
21112
+ fetchedAt: new Date(deps.http.now()).toISOString(),
21113
+ label: { ...label, name: label.name ?? mb.label?.name },
21114
+ ok: answered.length > 0,
21115
+ partial: answered.length < requested.length || errors.length > 0,
21116
+ requests: { cacheHits: deps.http.stats.cacheHits, network: deps.http.stats.requests },
21117
+ sources
21118
+ };
21119
+ }
21120
+ function firecrawlApiScraper(apiKey, http) {
21121
+ return async (url, signal) => {
21122
+ const response = await http.fetch(FIRECRAWL_SCRAPE_URL, {
21123
+ body: JSON.stringify({ formats: ["rawHtml"], url }),
21124
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
21125
+ method: "POST",
21126
+ signal
21127
+ });
21128
+ if (!response.ok) {
21129
+ return { headers: response.headers, status: response.status, text: await response.text() };
21130
+ }
21131
+ const payload = await response.json();
21132
+ return {
21133
+ headers: response.headers,
21134
+ status: payload.data?.metadata?.statusCode ?? 200,
21135
+ text: payload.data?.rawHtml ?? ""
21136
+ };
21137
+ };
21138
+ }
21139
+ function firecrawlCliScraper(binary) {
21140
+ return (url, signal) => new Promise((resolve, reject) => {
21141
+ const child = spawn(binary, ["scrape", "-f", "rawHtml", url], {
21142
+ signal,
21143
+ stdio: ["ignore", "pipe", "pipe"]
21144
+ });
21145
+ const out = [];
21146
+ const err = [];
21147
+ child.stdout.on("data", (chunk) => out.push(chunk));
21148
+ child.stderr.on("data", (chunk) => err.push(chunk));
21149
+ child.on("error", reject);
21150
+ child.on("close", (code) => {
21151
+ if (code === 0) {
21152
+ resolve({
21153
+ headers: { get: () => null },
21154
+ status: 200,
21155
+ text: Buffer.concat(out).toString("utf8")
21156
+ });
21157
+ return;
21158
+ }
21159
+ const tail = Buffer.concat(err).toString("utf8").trim().split(`
21160
+ `).slice(-2).join(" ");
21161
+ reject(new Error(`firecrawl CLI exited ${code}: ${tail}`));
21162
+ });
21163
+ });
21164
+ }
21165
+ function defaultBeatportScraper(env, http) {
21166
+ if (env.firecrawlApiKey) {
21167
+ return firecrawlApiScraper(env.firecrawlApiKey, http);
21168
+ }
21169
+ return env.firecrawlBinary ? firecrawlCliScraper(env.firecrawlBinary) : null;
21170
+ }
21171
+ async function labelEvidenceCommand(input, options) {
21172
+ const [{ readOptionalEnv }, { resolveLabel }] = await Promise.all([
21173
+ Promise.resolve().then(() => (init_env(), exports_env)),
21174
+ Promise.resolve().then(() => (init_admin_labels3(), exports_admin_labels))
21175
+ ]);
21176
+ const http = createEvidenceHttp({ refresh: options.refresh });
21177
+ const discogsToken = readOptionalEnv("DISCOGS_USER_TOKEN");
21178
+ if (discogsToken) {
21179
+ http.policies = { ...http.policies, discogs: { ...http.policies.discogs, intervalMs: 1100 } };
21180
+ }
21181
+ return gatherLabelEvidence(input, options, {
21182
+ discogsToken,
21183
+ http,
21184
+ resolveLabel,
21185
+ scraper: defaultBeatportScraper({
21186
+ firecrawlApiKey: readOptionalEnv("FIRECRAWL_API_KEY"),
21187
+ firecrawlBinary: Bun.which("firecrawl")
21188
+ }, http)
21189
+ });
21190
+ }
21191
+ function countLine(counts, limit) {
21192
+ return counts.slice(0, limit).map((entry) => `${entry.name} ${entry.count}`).join(", ");
21193
+ }
21194
+ function labelEvidenceLines(evidence) {
21195
+ const { apple, beatport, discogs, musicbrainz } = evidence.sources;
21196
+ const mb = musicbrainz.data;
21197
+ const name = evidence.label.name ?? evidence.label.mbLabelId;
21198
+ const lines = [`${name} (${evidence.label.mbLabelId})`];
21199
+ const mbParts = [];
21200
+ if (mb?.label) {
21201
+ mbParts.push([mb.label.type, mb.label.area, mb.label.lifeSpan.begin].filter(Boolean).join(", "));
21202
+ }
21203
+ if (mb?.releases) {
21204
+ const top = mb.releases.firstCreditArtists.slice(0, 6).map((artist) => `${artist.name} ${artist.releases}`).join(", ");
21205
+ mbParts.push(`${mb.releases.releaseCount} releases; first credits: ${top || "none"}`);
21206
+ }
21207
+ if (mb?.census) {
21208
+ mbParts.push(`census ${mb.census.recordingsCounted} recordings over ${mb.census.releasesRead} releases${mb.census.caveat ? ` (${mb.census.caveat})` : ""}`);
21209
+ }
21210
+ lines.push(` musicbrainz: ${musicbrainz.status}${mbParts.length ? ` — ${mbParts.join("; ")}` : ""}`);
21211
+ const dg = discogs.data;
21212
+ const dgDetail = dg?.styles ? `styles: ${countLine(dg.styles.styles, 6)} (${dg.styles.releasesRead} releases)` : dg?.candidates ? `unverified candidates: ${dg.candidates.map((c) => c.title).join(", ") || "none"}` : discogs.note;
21213
+ lines.push(` discogs: ${discogs.status}${dgDetail ? ` — ${dgDetail}` : ""}`);
21214
+ const bp = beatport.data;
21215
+ const bpDetail = bp ? `${bp.trackCount} tracks; genres: ${bp.genres.slice(0, 5).map((genre) => `${genre.name} ${Math.round(genre.share * 100)}%`).join(", ")}` : beatport.note;
21216
+ lines.push(` beatport: ${beatport.status}${bpDetail ? ` — ${bpDetail}` : ""}`);
21217
+ const ap = apple.data;
21218
+ const apDetail = ap ? `${ap.found}/${ap.looked} found; genres: ${countLine(ap.genres, 4) || "none"}` : apple.note;
21219
+ lines.push(` apple: ${apple.status}${apDetail ? ` — ${apDetail}` : ""}`);
21220
+ for (const failure of evidence.errors) {
21221
+ lines.push(` ! ${failure.source}: ${failure.message}`);
21222
+ }
21223
+ return lines;
21224
+ }
21225
+ var MBID_PATTERN3, MB_ROOT = "https://musicbrainz.org/ws/2", DISCOGS_ROOT = "https://api.discogs.com", FIRECRAWL_SCRAPE_URL = "https://api.firecrawl.dev/v2/scrape", VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377", RELEASE_SAMPLE = 12, FIRST_CREDIT_ARTISTS = 25, APPLE_LOOKUPS = 5, DISCOGS_RELEASE_SAMPLE = 8, DISCOGS_STYLE_TARGET = 10, DISCOGS_RELEASE_READS = 8, PROFILE_CHARS = 800, EVIDENCE_SOURCES, MAX_CENSUS_PAGES = 5;
21226
+ var init_admin_label_evidence = __esm(() => {
21227
+ init_evidence_http();
21228
+ MBID_PATTERN3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21229
+ EVIDENCE_SOURCES = ["musicbrainz", "discogs", "beatport", "apple"];
21230
+ });
21231
+
19861
21232
  // src/commands/admin-albums.ts
19862
21233
  async function describeAlbumCommand(slug, options) {
19863
21234
  return adminApiPost(`/api/v1/admin/albums/${encodeURIComponent(slug)}/bio`, buildBioBody2(options));
@@ -23700,8 +25071,28 @@ JSON field reference:
23700
25071
  }
23701
25072
  for (const label of labels) {
23702
25073
  const looked = label.triageCheckedAt ? label.triageVerdict : "never looked";
23703
- console.log(`${label.slug} ${label.seedState} ${looked ?? ""}`.trimEnd());
25074
+ const rules = label.carriesArtistRules ? " artist rules" : "";
25075
+ console.log(`${label.slug} ${label.seedState} ${looked ?? ""}${rules}`.trimEnd());
25076
+ }
25077
+ });
25078
+ labels.command("evidence").description("Gather a label's MusicBrainz, Discogs, Beatport and Apple evidence for triage (cached, rate-limited)").argument("<label>", "The label's MusicBrainz MBID, slug, or lbl_\u2026 id").option("--census", "Also count FIRST credits per recording over the label's releases", false).option("--census-pages <n>", "MusicBrainz pages the census may read, 1-5", "5").option("--sources <list>", "Comma list of musicbrainz,discogs,beatport,apple (default all)").option("--refresh", "Ignore cached responses and fetch fresh", false).option("--json", "Print JSON", false).action(async (input, options) => {
25079
+ await Promise.resolve().then(() => init_admin_label_evidence());
25080
+ const censusPages = parseCensusPages(options.censusPages);
25081
+ const evidence = await labelEvidenceCommand(input, {
25082
+ census: options.census,
25083
+ censusPages,
25084
+ refresh: options.refresh,
25085
+ sources: parseEvidenceSources(options.sources)
25086
+ });
25087
+ if (!evidence.ok) {
25088
+ process.exitCode = 1;
25089
+ }
25090
+ if (options.json) {
25091
+ printJson(evidence);
25092
+ return;
23704
25093
  }
25094
+ console.log(labelEvidenceLines(evidence).join(`
25095
+ `));
23705
25096
  });
23706
25097
  labels.command("triage").description("Record what a triage round found for a label (agent; never rules)").argument("<slug>", "The label slug").requiredOption("--payload <file>", "JSON file holding the round's finding for this label").option("--json", "Print JSON", false).action(async (slug, options) => {
23707
25098
  const payload = JSON.parse(readFileSync6(options.payload, "utf8"));
@@ -26691,6 +28082,7 @@ var stringOptions = new Set([
26691
28082
  "--bpm-confidence",
26692
28083
  "--bpm-source",
26693
28084
  "--calls",
28085
+ "--census-pages",
26694
28086
  "--composition",
26695
28087
  "--consumer-digest",
26696
28088
  "--consumer-item-count",
@@ -26769,6 +28161,7 @@ var stringOptions = new Set([
26769
28161
  "--since",
26770
28162
  "--soundcloud-url",
26771
28163
  "--source",
28164
+ "--sources",
26772
28165
  "--status",
26773
28166
  "--stale-before",
26774
28167
  "--stream",