Files
clicktrack/lib/registry/sync.ts
AJ Avezzano 8b9d72bc9d feat: analysis providers, settings UI, song search, WAV duration fix
- Multi-provider AI analysis (Anthropic, OpenAI, Ollama, Algorithmic)
- server-only guards on all provider files; client bundle fix
- /settings page with provider status, Ollama model picker, preferences
- Song search box on /analyze replacing raw MBID input (debounced, keyboard nav)
- Auto-register song via MusicBrainz on POST /api/tracks (no more 404)
- Fix WAV duration bug: last section songEnd was double-counting elapsed time
- Registry sync comment updated for self-hosted HTTPS git servers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:46:17 -04:00

181 lines
6.1 KiB
TypeScript

/**
* Git Registry Sync
*
* Pulls CTP files from a remote git repository (the "community registry")
* served over HTTPS. Compatible with any self-hosted git server
* (Gitea, Forgejo, GitLab CE, Gogs, etc.) or any public git host.
*
* The registry repo is expected to contain CTP JSON files at:
* <repo-root>/<artist-initial>/<artist-slug>/<recording-mbid>.ctp.json
*
* Configuration:
* REGISTRY_REPO — HTTPS URL of the registry repo,
* e.g. https://git.yourdomain.com/org/clicktrack-registry
* To authenticate, embed credentials in the URL:
* https://user:token@git.yourdomain.com/org/clicktrack-registry
* REGISTRY_BRANCH — branch to pull from (default: main)
*/
import { execSync } from "child_process";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { validateCTP } from "../ctp/validate";
import { upsertSong, insertTempoMap, withTransaction, query } from "../db/client";
const REGISTRY_REPO = process.env.REGISTRY_REPO ?? "";
const REGISTRY_BRANCH = process.env.REGISTRY_BRANCH ?? "main";
const CLONE_DIR = join(tmpdir(), "clicktrack-registry");
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SyncResult {
recordsAdded: number;
recordsUpdated: number;
errors: Array<{ file: string; error: string }>;
status: "success" | "partial" | "failed";
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function cloneOrPull(): void {
if (existsSync(join(CLONE_DIR, ".git"))) {
execSync(`git -C "${CLONE_DIR}" fetch origin "${REGISTRY_BRANCH}" --depth=1 -q`, {
stdio: "pipe",
});
execSync(`git -C "${CLONE_DIR}" reset --hard "origin/${REGISTRY_BRANCH}" -q`, {
stdio: "pipe",
});
} else {
execSync(
`git clone --depth=1 --branch "${REGISTRY_BRANCH}" "${REGISTRY_REPO}" "${CLONE_DIR}"`,
{ stdio: "pipe" }
);
}
}
/** Recursively finds all .ctp.json files under a directory. */
function findCtpFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...findCtpFiles(full));
} else if (entry.endsWith(".ctp.json")) {
results.push(full);
}
}
return results;
}
// ─── Main sync function ───────────────────────────────────────────────────────
export async function syncRegistry(): Promise<SyncResult> {
if (!REGISTRY_REPO) {
return {
recordsAdded: 0,
recordsUpdated: 0,
errors: [{ file: "", error: "REGISTRY_REPO is not configured" }],
status: "failed",
};
}
let recordsAdded = 0;
let recordsUpdated = 0;
const errors: SyncResult["errors"] = [];
try {
cloneOrPull();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await logSync(0, 0, "failed", `Git clone/pull failed: ${message}`);
return { recordsAdded: 0, recordsUpdated: 0, errors: [{ file: "", error: message }], status: "failed" };
}
const files = findCtpFiles(CLONE_DIR);
for (const filePath of files) {
let raw: unknown;
try {
raw = JSON.parse(readFileSync(filePath, "utf-8"));
} catch (err) {
errors.push({ file: filePath, error: `JSON parse error: ${err}` });
continue;
}
const validation = validateCTP(raw);
if (!validation.success) {
errors.push({
file: filePath,
error: `CTP validation failed: ${validation.errors.message}`,
});
continue;
}
const doc = validation.data;
try {
await withTransaction(async (client) => {
// Ensure the song exists in the songs table
if (doc.metadata.mbid) {
await upsertSong({
mbid: doc.metadata.mbid,
title: doc.metadata.title,
artist: doc.metadata.artist,
duration_seconds: doc.metadata.duration_seconds,
acousticbrainz_bpm: null,
acousticbrainz_time_sig_num: null,
source: "registry",
});
// Check if a matching tempo map already exists (same mbid + contributor)
const existing = await client.query(
`SELECT id FROM tempo_maps
WHERE song_mbid = $1 AND contributed_by = $2
LIMIT 1`,
[doc.metadata.mbid, doc.metadata.contributed_by]
);
if (existing.rowCount && existing.rowCount > 0) {
await client.query(
`UPDATE tempo_maps SET ctp_data = $1, updated_at = NOW()
WHERE song_mbid = $2 AND contributed_by = $3`,
[JSON.stringify(raw), doc.metadata.mbid, doc.metadata.contributed_by]
);
recordsUpdated++;
} else {
await insertTempoMap({
song_mbid: doc.metadata.mbid,
ctp_data: raw as Record<string, unknown>,
contributed_by: doc.metadata.contributed_by,
});
recordsAdded++;
}
}
});
} catch (err) {
errors.push({ file: filePath, error: `DB upsert error: ${err}` });
}
}
const status: SyncResult["status"] =
errors.length === 0 ? "success" : recordsAdded + recordsUpdated > 0 ? "partial" : "failed";
await logSync(recordsAdded, recordsUpdated, status, errors.length > 0 ? JSON.stringify(errors.slice(0, 5)) : undefined);
return { recordsAdded, recordsUpdated, errors, status };
}
async function logSync(
added: number,
updated: number,
status: string,
message?: string
): Promise<void> {
await query(
`INSERT INTO registry_sync_log (records_added, records_updated, status, message)
VALUES ($1, $2, $3, $4)`,
[added, updated, status, message ?? null]
);
}