// Shared configuration for the plebleader archive CLI. // Runs directly under Node >= 22 (type stripping), no build step. import * as fs from 'node:fs'; import * as path from 'node:path'; export const USER_AGENT = 'plebsaber-archive/0.1'; export const BL_API = 'https://api.beatleader.com'; export const MAIN_PLAYER_ID = '76561199407393962'; export const LEGACY_PLAYER_ID = '76561197962107242'; export const PLAYER_IDS = [MAIN_PLAYER_ID, LEGACY_PLAYER_ID]; /** Scores, replays, score stats and profile data: all history by default. */ export const DEFAULT_SINCE = 'all'; /** * BeatSaver map files (JSON + zip) only for maps played in the last two years. Older maps can * be fetched later with --maps-since all (existing files are skipped). */ export const DEFAULT_MAPS_SINCE = '2024-11-08'; /** Where ss-scraper already downloaded ScoreSaber replays (/_.dat). */ export const SS_SCRAPER_REPLAYS = process.env.SS_SCRAPER_REPLAYS ?? path.join(process.env.HOME ?? '', 'ops/beatsaber/ss-scraper/replays'); /** Parse --since: YYYY-MM-DD (UTC), unix seconds, or "all". */ export function parseSince(value: string | undefined, fallback = DEFAULT_SINCE): number { const v = value ?? fallback; if (v === 'all') return 0; if (/^\d+$/.test(v)) return Number(v); const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00Z` : v); if (Number.isNaN(ms)) throw new Error(`Invalid --since: ${v}`); return Math.floor(ms / 1000); } /** * Root of the plebleader state (archive/, auth/, db/, ...). * Production: /var/lib/plebleader. Local default: ./.data/plebleader (gitignored). */ export const ROOT = path.resolve(process.env.PLEBLEADER_DIR ?? path.join(process.cwd(), '.data', 'plebleader')); export const ARCHIVE_DIR = path.join(ROOT, 'archive'); export const AUTH_DIR = path.join(ROOT, 'auth'); export const TOKENS_FILE = path.join(AUTH_DIR, 'beatleader_tokens.json'); /** Load KEY=VALUE (optionally `export KEY=VALUE`) lines from .env without overriding the real environment. */ export function loadDotEnv(file = path.join(process.cwd(), '.env')): void { if (!fs.existsSync(file)) return; for (const line of fs.readFileSync(file, 'utf-8').split('\n')) { const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); if (!m) continue; const [, key, raw] = m; const value = raw.replace(/^(['"])(.*)\1$/, '$2'); if (process.env[key] === undefined) process.env[key] = value; } } export function clientCredentials(): { clientId: string; clientSecret: string } { const clientId = process.env.BL_CLIENT_ID; const clientSecret = process.env.BL_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error('BL_CLIENT_ID and BL_CLIENT_SECRET must be set (environment or .env).'); } return { clientId, clientSecret }; } /** Write a file atomically: temp file in the same directory, fsync, rename. */ export function writeFileAtomic(file: string, data: string | Uint8Array, mode = 0o644): void { fs.mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; const fd = fs.openSync(tmp, 'w', mode); try { fs.writeSync(fd, typeof data === 'string' ? Buffer.from(data, 'utf-8') : data); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(tmp, file); }