@oletizi/sampler-backup
Version:
Akai sampler backup utilities for hardware samplers via PiSCSI
774 lines (773 loc) • 23.5 kB
JavaScript
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createDeviceDetector } from "@oletizi/lib-device-uuid";
import inquirer from "inquirer";
class RsyncAdapter {
/**
* Synchronize files from source to destination using rsync.
*
* Uses rsync's archive mode (-a) to preserve permissions, timestamps, etc.
* Automatically deletes files in destination that don't exist in source.
*
* @param config - Rsync configuration
* @throws {Error} If rsync command fails or is not available
*
* @example
* ```typescript
* await adapter.sync({
* sourcePath: 'pi-scsi2:/home/orion/images/',
* destPath: '~/.audiotools/backup/pi-scsi2/scsi0/',
* dryRun: true, // Preview changes first
* });
* ```
*/
async sync(config) {
const args = [
"-av",
// Archive mode, verbose
"--delete",
// Delete files that don't exist in source
"--progress"
// Show progress
];
if (config.dryRun) {
args.push("--dry-run");
}
args.push(config.sourcePath);
args.push(config.destPath);
return new Promise((resolve, reject) => {
const rsync = spawn("rsync", args, { stdio: "inherit" });
rsync.on("close", (code) => {
if (code === 0) {
resolve();
} else if (code === 23 || code === 24) {
console.warn(`
⚠️ Partial transfer (rsync code ${code}): Some files could not be transferred`);
console.warn(" Common causes: Permission denied on system files (.Spotlight-V100, .Trashes)");
console.warn(" Your data files were backed up successfully.\n");
resolve();
} else {
reject(new Error(`rsync failed with code ${code}`));
}
});
rsync.on("error", (err) => {
reject(new Error(`Failed to execute rsync: ${err.message}`));
});
});
}
/**
* Check if rsync is available on the system.
*
* Attempts to execute `rsync --version` to verify availability.
*
* @returns Promise that resolves to true if rsync is available, false otherwise
*
* @example
* ```typescript
* const available = await adapter.checkRsyncAvailable();
* if (!available) {
* throw new Error('rsync is not installed. Please install rsync to continue.');
* }
* ```
*/
async checkRsyncAvailable() {
return new Promise((resolve) => {
const check = spawn("rsync", ["--version"], { stdio: "ignore" });
check.on("close", (code) => {
resolve(code === 0);
});
check.on("error", () => {
resolve(false);
});
});
}
}
function resolveRepositoryPath(config) {
const baseDir = join(homedir(), ".audiotools", "backup");
if (!config.device) {
throw new Error("Device name is required (--device scsi0|scsi1|floppy|...)");
}
const samplerName = resolveSamplerName(config);
return join(baseDir, samplerName, config.device);
}
function resolveSamplerName(config) {
if (config.sourceType === "remote") {
if (config.sampler) {
return sanitizeSamplerName(config.sampler);
}
if (!config.host) {
throw new Error("Host is required for remote sources");
}
return sanitizeSamplerName(config.host);
} else {
if (!config.sampler) {
throw new Error(
"Sampler name is required for local sources (use --sampler flag)\nExample: --sampler s5k-studio --device floppy"
);
}
return sanitizeSamplerName(config.sampler);
}
}
function sanitizeSamplerName(name) {
return name.replace(/\.local$/i, "").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
class RemoteSource {
constructor(config, rsyncAdapter) {
this.config = config;
this.type = "remote";
if (!config.device) {
throw new Error("Device name is required (e.g., scsi0, scsi1, floppy)");
}
this.rsyncAdapter = rsyncAdapter ?? new RsyncAdapter();
this.backupPath = resolveRepositoryPath({
sourceType: "remote",
host: this.config.host,
sampler: this.config.sampler,
// Optional override
device: this.config.device
// Required
});
}
/**
* Execute remote backup using rsync
*
* Simple synchronization: user@host:/path → ~/.audiotools/backup/{sampler}/{device}/
*/
async backup(interval) {
const result = {
success: false,
interval,
configPath: "",
// Not used with rsync
errors: []
};
try {
await mkdir(this.backupPath, { recursive: true });
const source = `${this.config.host}:${this.config.sourcePath}`;
console.log(`Syncing ${source} → ${this.backupPath}`);
await this.rsyncAdapter.sync({
sourcePath: source,
destPath: this.backupPath
});
console.log(`✓ Backup complete: ${this.backupPath}`);
result.success = true;
result.snapshotPath = this.backupPath;
} catch (error) {
const errorMessage = `Remote backup failed for ${this.config.host}: ${error.message}`;
console.error(errorMessage);
result.errors.push(errorMessage);
}
return result;
}
/**
* Test if remote source is accessible
*
* Tests SSH connectivity to remote host.
*/
async test() {
try {
console.log(`Testing SSH connection to ${this.config.host}...`);
const connected = await new Promise((resolve) => {
const ssh = spawn("ssh", [this.config.host, "echo", "ok"]);
ssh.on("close", (code) => {
resolve(code === 0);
});
ssh.on("error", () => {
resolve(false);
});
});
if (connected) {
console.log(`✓ Successfully connected to ${this.config.host}`);
} else {
console.error(`✗ Failed to connect to ${this.config.host}`);
}
return connected;
} catch (error) {
console.error(`Remote source test failed: ${error.message}`);
return false;
}
}
/**
* Get source configuration
*/
getConfig() {
return this.config;
}
/**
* Get the backup path for this source
*/
getBackupPath() {
return this.backupPath;
}
}
class LocalSource {
constructor(config, rsyncAdapter) {
this.config = config;
this.type = "local";
this.rsyncAdapter = rsyncAdapter ?? new RsyncAdapter();
this.backupPath = resolveRepositoryPath({
sourceType: "local",
sampler: this.config.sampler,
device: this.config.device
});
}
/**
* Execute local backup using rsync
*/
async backup(interval) {
const result = {
success: false,
interval,
configPath: "",
// Not used with rsync
errors: []
};
try {
await mkdir(this.backupPath, { recursive: true });
console.log(`Syncing ${this.config.sourcePath} to ${this.backupPath}...`);
await this.rsyncAdapter.sync({
sourcePath: this.config.sourcePath,
destPath: this.backupPath
});
console.log("\nBackup complete");
result.success = true;
result.snapshotPath = this.backupPath;
} catch (error) {
const errorMessage = `Local backup failed for ${this.config.sourcePath}: ${error.message}`;
console.error(errorMessage);
result.errors.push(errorMessage);
}
return result;
}
/**
* Test if local source is accessible
*/
async test() {
return existsSync(this.config.sourcePath);
}
/**
* Get source configuration
*/
getConfig() {
return this.config;
}
/**
* Get backup path for this source
*/
getBackupPath() {
return this.backupPath;
}
}
class DeviceMatcher {
constructor(detector) {
this.detector = detector ?? createDeviceDetector();
}
/**
* Attempts to match a mount path to an existing backup source by UUID
*
* @param mountPath - Path where device is mounted
* @param sources - Array of configured backup sources
* @returns Match result with device info and matched source (if any)
*/
async matchDevice(mountPath, sources) {
const deviceInfo = await this.detector.detectDevice(mountPath);
if (deviceInfo.volumeUUID) {
const uuidMatches = sources.filter((s) => s.volumeUUID === deviceInfo.volumeUUID);
if (uuidMatches.length === 1) {
return {
matched: true,
source: uuidMatches[0],
deviceInfo,
reason: "uuid"
};
}
if (uuidMatches.length > 1) {
return {
matched: false,
deviceInfo,
reason: "conflict",
conflictingSources: uuidMatches
};
}
}
if (deviceInfo.volumeSerial) {
const serialMatches = sources.filter(
(s) => s.volumeSerial === deviceInfo.volumeSerial && !s.volumeUUID
);
if (serialMatches.length === 1) {
return {
matched: true,
source: serialMatches[0],
deviceInfo,
reason: "serial"
};
}
if (serialMatches.length > 1) {
return {
matched: false,
deviceInfo,
reason: "conflict",
conflictingSources: serialMatches
};
}
}
return {
matched: false,
deviceInfo,
reason: "not-found"
};
}
/**
* Registers a new device by populating UUID fields in a backup source
*
* @param source - Backup source to register
* @param deviceInfo - Device information from detection
* @returns Updated backup source with UUID fields populated
*/
registerDevice(source, deviceInfo) {
const now = (/* @__PURE__ */ new Date()).toISOString();
return {
...source,
volumeUUID: deviceInfo.volumeUUID,
volumeLabel: deviceInfo.volumeLabel,
volumeSerial: deviceInfo.volumeSerial,
lastSeen: now,
registeredAt: source.registeredAt || now
// Preserve original if exists
};
}
/**
* Updates lastSeen timestamp for a recognized device
*
* @param source - Backup source to update
* @returns Updated backup source with current lastSeen
*/
updateLastSeen(source) {
return {
...source,
lastSeen: (/* @__PURE__ */ new Date()).toISOString()
};
}
/**
* Checks if a backup source has device UUID information
*
* @param source - Backup source to check
* @returns true if source has UUID or serial
*/
hasDeviceInfo(source) {
return !!(source.volumeUUID || source.volumeSerial);
}
}
class DeviceResolver {
constructor(matcher) {
this.matcher = matcher ?? new DeviceMatcher();
}
/**
* Resolves a mount path to a backup source, registering or recognizing as needed
*
* This is the main entry point for device UUID tracking. It:
* 1. Tries to match by UUID/serial to existing sources
* 2. If matched, updates lastSeen timestamp
* 3. If not matched and source doesn't have UUID, registers device
* 4. Handles conflicts by throwing descriptive errors
*
* @param mountPath - Path where device is mounted
* @param sourceName - Name of the backup source in config
* @param config - Full audio-tools configuration
* @returns Resolution result with updated source and action taken
*/
async resolveDevice(mountPath, sourceName, config) {
const sources = config.backup?.sources || [];
const source = sources.find((s) => s.name === sourceName);
if (!source) {
throw new Error(`Backup source not found: ${sourceName}`);
}
const matchResult = await this.matcher.matchDevice(mountPath, sources);
if (matchResult.reason === "conflict") {
const sourceNames = matchResult.conflictingSources?.map((s) => s.name).join(", ");
throw new Error(
`Device UUID conflict: Device at ${mountPath} matches multiple sources: ${sourceNames}. Please check your configuration.`
);
}
if (matchResult.matched && matchResult.source && matchResult.source.name !== sourceName) {
throw new Error(
`Device mismatch: Device at ${mountPath} is registered as '${matchResult.source.name}', but you're trying to back it up as '${sourceName}'. Use the correct source name or update your configuration.`
);
}
if (matchResult.matched && matchResult.source?.name === sourceName) {
const updatedSource = this.matcher.updateLastSeen(source);
return {
source: updatedSource,
action: "recognized",
message: `Recognized device '${source.name}' at ${mountPath} (UUID: ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial})`
};
}
if (!matchResult.matched && !this.matcher.hasDeviceInfo(source)) {
const registeredSource = this.matcher.registerDevice(source, matchResult.deviceInfo);
return {
source: registeredSource,
action: "registered",
message: `Registered new device '${source.name}' at ${mountPath} (UUID: ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial})`
};
}
if (!matchResult.matched && this.matcher.hasDeviceInfo(source)) {
throw new Error(
`Device mismatch: Source '${sourceName}' expects UUID ${source.volumeUUID || source.volumeSerial}, but device at ${mountPath} has UUID ${matchResult.deviceInfo.volumeUUID || matchResult.deviceInfo.volumeSerial}. This may be a different physical device. Check your configuration.`
);
}
return {
source,
action: "no-change",
message: `No device UUID changes for '${source.name}'`
};
}
}
class AutoDetectBackup {
constructor(deviceDetector, promptService, deviceResolver) {
this.deviceDetector = deviceDetector;
this.promptService = promptService;
this.deviceResolver = deviceResolver;
}
async detectAndResolve(mountPath, config, options = {}) {
let wasPrompted = false;
const deviceInfo = await this.detectDeviceInfo(mountPath);
const existingSource = this.findExistingSource(deviceInfo, config);
if (existingSource) {
const updatedSource = {
...existingSource,
lastSeen: (/* @__PURE__ */ new Date()).toISOString()
};
return {
source: updatedSource,
action: "recognized",
deviceInfo,
wasPrompted
};
}
let deviceType = options.deviceType;
if (!deviceType) {
deviceType = this.inferDeviceType(deviceInfo);
if (!deviceType) {
deviceType = await this.promptService.promptDeviceType();
wasPrompted = true;
}
}
let sampler = options.sampler;
if (!sampler) {
sampler = await this.determineSampler(config);
wasPrompted = true;
}
const sourceName = this.generateSourceName(sampler, deviceType, deviceInfo);
const newSource = this.createBackupSource(
sourceName,
mountPath,
deviceType,
sampler,
deviceInfo
);
const action = this.hasDeviceIdentifiers(deviceInfo) ? "registered" : "created";
return {
source: newSource,
action,
deviceInfo,
wasPrompted
};
}
/**
* Detect device info with graceful fallback.
* Returns partial info if detection fails.
*/
async detectDeviceInfo(mountPath) {
try {
return await this.deviceDetector.detectDevice(mountPath);
} catch (error) {
return {
mountPath,
volumeLabel: this.extractVolumeNameFromPath(mountPath)
};
}
}
/**
* Infer device type from filesystem type.
*
* Logic:
* - FAT12/FAT16 → 'floppy' (typical for floppy disks)
* - FAT32 → 'hard-drive' (typical for SD cards, USB drives)
* - ISO9660/CDFS → 'cd-rom'
* - exFAT, NTFS → 'hard-drive'
* - Unknown → undefined (will prompt)
*/
inferDeviceType(deviceInfo) {
const fs = deviceInfo.filesystem?.toLowerCase();
if (!fs) {
return void 0;
}
if (fs.includes("fat12") || fs.includes("fat16")) {
return "floppy";
}
if (fs.includes("fat32") || fs.includes("vfat")) {
return "hard-drive";
}
if (fs.includes("iso9660") || fs.includes("cdfs") || fs.includes("udf")) {
return "cd-rom";
}
if (fs.includes("exfat") || fs.includes("ntfs") || fs.includes("ext4")) {
return "hard-drive";
}
if (fs.includes("hfs")) {
return "hard-drive";
}
return void 0;
}
/**
* Determine sampler from config or prompt.
*
* If no existing samplers, prompts for new sampler name.
* If existing samplers, prompts to select or add new.
*/
async determineSampler(config) {
const existingSamplers = this.getExistingSamplers(config);
if (existingSamplers.length === 0) {
return await this.promptService.promptNewSamplerName();
}
const result = await this.promptService.promptSampler(existingSamplers);
return result.sampler;
}
/**
* Get list of unique sampler names from config.
*/
getExistingSamplers(config) {
const sources = config.backup?.sources || [];
const samplers = sources.map((s) => s.sampler).filter((s) => !!s);
return Array.from(new Set(samplers));
}
/**
* Find existing source that matches the device info.
*
* Matches by UUID or serial number if available.
*/
findExistingSource(deviceInfo, config) {
const sources = config.backup?.sources || [];
if (deviceInfo.volumeUUID) {
const match = sources.find((s) => s.volumeUUID === deviceInfo.volumeUUID);
if (match) {
return match;
}
}
if (deviceInfo.volumeSerial) {
const match = sources.find((s) => s.volumeSerial === deviceInfo.volumeSerial);
if (match) {
return match;
}
}
return void 0;
}
/**
* Generate source name from components.
*
* Format: ${sampler}-${deviceType}-${volumeLabel || 'device'}
* Example: 's5000-floppy-SDCARD'
*
* Ensures uniqueness by appending number if needed.
*/
generateSourceName(sampler, deviceType, deviceInfo) {
const volumePart = deviceInfo.volumeLabel || "device";
const baseName = `${sampler}-${deviceType}-${volumePart}`;
return baseName.toLowerCase().replace(/\s+/g, "-");
}
/**
* Create a new BackupSource from components.
*/
createBackupSource(name, mountPath, deviceType, sampler, deviceInfo) {
const now = (/* @__PURE__ */ new Date()).toISOString();
return {
name,
type: "local",
source: mountPath,
device: deviceType,
sampler,
enabled: true,
volumeUUID: deviceInfo.volumeUUID,
volumeLabel: deviceInfo.volumeLabel,
volumeSerial: deviceInfo.volumeSerial,
registeredAt: now,
lastSeen: now
};
}
/**
* Check if device info has UUID or serial identifiers.
*/
hasDeviceIdentifiers(deviceInfo) {
return !!(deviceInfo.volumeUUID || deviceInfo.volumeSerial);
}
/**
* Extract volume name from mount path.
* E.g., '/Volumes/SDCARD' → 'SDCARD'
*/
extractVolumeNameFromPath(mountPath) {
const parts = mountPath.split("/").filter((p) => p);
return parts[parts.length - 1] || "unknown";
}
}
function createAutoDetectBackup(deviceDetector, promptService, deviceResolver) {
return new AutoDetectBackup(deviceDetector, promptService, deviceResolver);
}
class UserCancelledError extends Error {
constructor(message = "User cancelled the operation") {
super(message);
this.name = "UserCancelledError";
}
}
const DEVICE_TYPES = [
{ name: "Floppy Disk", value: "floppy", description: 'Standard 3.5" floppy disk' },
{ name: "Hard Drive", value: "hard-drive", description: "Internal or external hard drive" },
{ name: "CD-ROM", value: "cd-rom", description: "CD-ROM or optical disc" },
{ name: "Other", value: "other", description: "Other storage device" }
];
class InteractivePrompt {
constructor(options = {}) {
this.options = options;
}
/**
* Prompt user to select a device type.
*/
async promptDeviceType() {
try {
const answer = await inquirer.prompt([
{
type: "list",
name: "deviceType",
message: "What type of storage device is this?",
choices: DEVICE_TYPES.map((dt) => ({
name: `${dt.name} - ${dt.description}`,
value: dt.value
})),
default: "floppy",
...this.getStreamOptions()
}
]);
return answer.deviceType;
} catch (error) {
this.handleCancellation(error);
throw error;
}
}
/**
* Prompt user to select existing sampler or add new.
*/
async promptSampler(existingSamplers) {
try {
const ADD_NEW_VALUE = "__ADD_NEW__";
const choices = [
...existingSamplers.map((name) => ({
name,
value: name
})),
{
name: "➕ Add new sampler...",
value: ADD_NEW_VALUE
}
];
const answer = await inquirer.prompt([
{
type: "list",
name: "sampler",
message: "Select sampler for this backup source:",
choices,
...this.getStreamOptions()
}
]);
if (answer.sampler === ADD_NEW_VALUE) {
const newName = await this.promptNewSamplerName();
return {
sampler: newName,
isNew: true
};
}
return {
sampler: answer.sampler,
isNew: false
};
} catch (error) {
this.handleCancellation(error);
throw error;
}
}
/**
* Prompt user to enter a new sampler name.
*/
async promptNewSamplerName() {
try {
const answer = await inquirer.prompt([
{
type: "input",
name: "samplerName",
message: "Enter a name for the new sampler:",
validate: (input) => {
const trimmed = input.trim();
if (!trimmed) {
return "Sampler name cannot be empty";
}
if (trimmed.length > 50) {
return "Sampler name must be 50 characters or less";
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return "Sampler name can only contain letters, numbers, hyphens, and underscores";
}
return true;
},
filter: (input) => input.trim(),
...this.getStreamOptions()
}
]);
return answer.samplerName;
} catch (error) {
this.handleCancellation(error);
throw error;
}
}
/**
* Get input/output stream options for inquirer prompts.
* Used for testing with custom streams.
*/
getStreamOptions() {
const opts = {};
if (this.options.input) {
opts.input = this.options.input;
}
if (this.options.output) {
opts.output = this.options.output;
}
return opts;
}
/**
* Handle user cancellation (Ctrl+C) gracefully.
* Converts inquirer errors to UserCancelledError.
*/
handleCancellation(error) {
if (error instanceof Error) {
if (error.message.includes("User force closed") || error.message.includes("canceled")) {
throw new UserCancelledError();
}
}
}
}
function createInteractivePrompt(options) {
return new InteractivePrompt(options);
}
export {
AutoDetectBackup as A,
DeviceResolver as D,
InteractivePrompt as I,
LocalSource as L,
RemoteSource as R,
UserCancelledError as U,
DeviceMatcher as a,
DEVICE_TYPES as b,
createAutoDetectBackup as c,
createInteractivePrompt as d
};
//# sourceMappingURL=interactive-prompt-BEay0YJ3.js.map