feat: Complete cleanup of desktop sync tool - Remove unused services (minio, syncEngine, rclone) - Clean up excessive debugging and logging - Remove sync queue functionality (not needed for AWS CLI) - Update SyncConfig interface and fix type conflicts - Replace mc commands with AWS CLI equivalents - Improve code organization and readability

This commit is contained in:
Geert Rademakes 2025-08-28 11:26:18 +02:00
parent dc8254772c
commit 73d9a41ca8
11 changed files with 4179 additions and 0 deletions

View File

@ -0,0 +1,56 @@
{
"name": "rekordbox-sync",
"version": "1.0.0",
"description": "Desktop companion for bidirectional S3 sync with Rekordbox music library",
"main": "dist/main.js",
"scripts": {
"dev": "tsc && electron .",
"build": "tsc",
"start": "electron dist/main.js",
"package": "tsc && electron-builder",
"dist": "tsc && electron-builder --publish=never",
"postinstall": "node scripts/check-aws-cli.js",
"check-aws-cli": "node scripts/check-aws-cli.js",
"install-aws-cli": "./install-aws-cli.sh"
},
"keywords": [
"music",
"sync",
"s3",
"rekordbox",
"garage"
],
"author": "Geert Rademakers",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.0.0",
"electron": "^28.0.0",
"electron-builder": "^24.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"chokidar": "^3.5.0",
"dotenv": "^17.2.1",
"electron-store": "^8.1.0"
},
"build": {
"appId": "com.geertrademakers.rekordbox-sync",
"productName": "Rekordbox Sync",
"directories": {
"output": "dist-build"
},
"files": [
"dist/**/*",
"node_modules/**/*"
],
"mac": {
"category": "public.app-category.music"
},
"win": {
"target": "nsis"
},
"linux": {
"target": "AppImage"
}
}
}

View File

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rekordbox Sync</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<h1><i class="fas fa-sync-alt"></i> Rekordbox Sync</h1>
</div>
<div class="header-right">
<button id="settingsBtn" class="btn btn-secondary" title="Settings">
<i class="fas fa-cog"></i>
</button>
<button id="minimizeBtn" class="btn btn-secondary" title="Minimize">
<i class="fas fa-window-minimize"></i>
</button>
<button id="closeBtn" class="btn btn-secondary" title="Close">
<i class="fas fa-times"></i>
</button>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Status Panel -->
<div class="status-panel">
<div class="status-item">
<span class="status-label">Sync Status:</span>
<span id="syncStatus" class="status-value">Stopped</span>
</div>
<div class="status-item">
<span class="status-label">Last Sync:</span>
<span id="lastSync" class="status-value">Never</span>
</div>
<div class="status-item">
<span class="status-label">Local Files:</span>
<span id="filesSynced" class="status-value">0</span>
</div>
<div class="status-item">
<span class="status-label">Progress:</span>
<div class="progress-container">
<div id="progressBar" class="progress-bar" style="width: 0%"></div>
</div>
</div>
<div id="syncDetails" class="sync-details"></div>
</div>
<!-- Control Panel -->
<div class="control-panel">
<button id="startSyncBtn" class="btn btn-primary">
<i class="fas fa-play"></i> Start Sync
</button>
<button id="stopSyncBtn" class="btn btn-danger" disabled>
<i class="fas fa-stop"></i> Stop Sync
</button>
<button id="forceSyncBtn" class="btn btn-secondary">
<i class="fas fa-sync"></i> Force Full Sync
</button>
<button id="immediateSyncBtn" class="btn btn-info">
<i class="fas fa-bolt"></i> Immediate Sync
</button>
</div>
<!-- Recent Activity -->
<div class="activity-panel">
<h3><i class="fas fa-history"></i> Recent Activity</h3>
<div id="activityLog" class="activity-log">
<div class="empty-activity">No recent activity</div>
</div>
</div>
</main>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2><i class="fas fa-cog"></i> Settings</h2>
<button id="closeSettingsBtn" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<!-- S3 Configuration -->
<div class="settings-section">
<h3><i class="fas fa-cloud"></i> S3 Configuration</h3>
<div class="form-group">
<label for="s3Endpoint">Endpoint:</label>
<input type="url" id="s3Endpoint" placeholder="https://garage.geertrademakers.nl">
</div>
<div class="form-group">
<label for="s3Region">Region:</label>
<input type="text" id="s3Region" placeholder="garage">
</div>
<div class="form-group">
<label for="s3AccessKey">Access Key ID:</label>
<input type="text" id="s3AccessKey" placeholder="Your access key">
</div>
<div class="form-group">
<label for="s3SecretKey">Secret Access Key:</label>
<input type="password" id="s3SecretKey" placeholder="Your secret key">
</div>
<div class="form-group">
<label for="s3Bucket">Bucket Name:</label>
<input type="text" id="s3Bucket" placeholder="music">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="s3UseSSL" checked>
Use SSL
</label>
</div>
<button id="testS3Btn" class="btn btn-secondary">Test Connection</button>
</div>
<!-- Sync Configuration -->
<div class="settings-section">
<h3><i class="fas fa-sync"></i> Sync Configuration</h3>
<div class="form-group">
<label for="localPath">Local Music Folder:</label>
<div class="path-input-group">
<input type="text" id="localPath" placeholder="Select music folder" readonly>
<button id="selectFolderBtn" class="btn btn-secondary">Browse</button>
</div>
</div>
<div class="form-group">
<label for="syncInterval">Sync Interval (seconds):</label>
<input type="number" id="syncInterval" min="5" max="300" value="30">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="autoStart">
Auto-start sync on app launch
</label>
</div>
<div class="form-group">
<label for="conflictResolution">Conflict Resolution:</label>
<select id="conflictResolution">
<option value="newer-wins">Newer file wins</option>
<option value="local-wins">Local file wins</option>
<option value="remote-wins">Remote file wins</option>
</select>
</div>
</div>
<!-- UI Configuration -->
<div class="settings-section">
<h3><i class="fas fa-palette"></i> Interface</h3>
<div class="form-group">
<label for="theme">Theme:</label>
<select id="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="notifications" checked>
Show notifications
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="minimizeToTray" checked>
Minimize to system tray
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button id="exportEnvBtn" class="btn btn-secondary">Export to .env</button>
<button id="saveSettingsBtn" class="btn btn-primary">Save Settings</button>
<button id="cancelSettingsBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<!-- Notification Container -->
<div id="notificationContainer" class="notification-container"></div>
</div>
<script>
console.log('🚀 HTML script tag executed!');
console.log('🔍 Testing basic JavaScript execution...');
// Test if we can access the DOM
if (document.getElementById('syncStatus')) {
console.log('✅ DOM element syncStatus found!');
} else {
console.log('❌ DOM element syncStatus NOT found!');
}
// Test if we can access window
console.log('🔍 Window object:', typeof window);
console.log('🔍 Document object:', typeof document);
// Test if we can access the electronAPI
console.log('🔍 Electron API available:', typeof window.electronAPI);
// Test if we can access the DOM elements
const elements = ['syncStatus', 'filesSynced', 'lastSync', 'startSyncBtn', 'stopSyncBtn'];
elements.forEach(id => {
const element = document.getElementById(id);
console.log(`🔍 Element ${id}:`, element ? 'Found' : 'NOT Found');
});
// Test if we can modify DOM elements
const testElement = document.getElementById('syncStatus');
if (testElement) {
testElement.textContent = 'TEST - JavaScript is working!';
console.log('✅ Successfully modified DOM element!');
}
</script>
<script src="renderer.js"></script>
</body>
</html>

View File

@ -0,0 +1,584 @@
class RekordboxSyncRenderer {
constructor() {
this.initializeElements();
this.setupEventListeners();
this.setupElectronListeners();
this.loadInitialState();
}
/**
* Initialize DOM elements
*/
initializeElements() {
// Buttons
this.startBtn = document.getElementById('startSyncBtn');
this.stopBtn = document.getElementById('stopSyncBtn');
this.exportEnvBtn = document.getElementById('exportEnvBtn');
// Status elements
this.syncStatusElement = document.getElementById('syncStatus');
this.filesSyncedElement = document.getElementById('filesSynced');
this.lastSyncElement = document.getElementById('lastSync');
this.syncDetailsElement = document.getElementById('syncDetails');
// Activity log
this.activityLogElement = document.getElementById('activityLog');
// Configuration elements
this.s3EndpointInput = document.getElementById('s3Endpoint');
this.s3AccessKeyInput = document.getElementById('s3AccessKey');
this.s3SecretKeyInput = document.getElementById('s3SecretKey');
this.s3BucketInput = document.getElementById('s3Bucket');
this.s3RegionInput = document.getElementById('s3Region');
this.localPathInput = document.getElementById('localPath');
this.syncIntervalInput = document.getElementById('syncInterval');
// Save button
this.saveConfigBtn = document.getElementById('saveConfigBtn');
}
/**
* Setup event listeners for UI elements
*/
setupEventListeners() {
// Sync control buttons
if (this.startBtn) {
this.startBtn.addEventListener('click', () => this.startSync());
}
if (this.stopBtn) {
this.stopBtn.addEventListener('click', () => this.stopSync());
}
if (this.exportEnvBtn) {
this.exportEnvBtn.addEventListener('click', () => this.exportToEnv());
}
// Configuration save button
if (this.saveConfigBtn) {
this.saveConfigBtn.addEventListener('click', () => this.saveConfiguration());
}
// Force sync button
const forceSyncBtn = document.getElementById('forceSyncBtn');
if (forceSyncBtn) {
forceSyncBtn.addEventListener('click', () => this.forceFullSync());
}
// Immediate sync button
const immediateSyncBtn = document.getElementById('immediateSyncBtn');
if (immediateSyncBtn) {
immediateSyncBtn.addEventListener('click', () => this.triggerImmediateSync());
}
}
/**
* Setup Electron IPC listeners
*/
setupElectronListeners() {
console.log('🔌 Setting up Electron IPC listeners...');
console.log('🔍 Window object:', window);
console.log('🔍 Electron API object:', window.electronAPI);
if (!window.electronAPI) {
console.error('❌ Electron API not available');
console.error('❌ This means the preload script failed to load');
return;
}
console.log('✅ Electron API is available, setting up listeners...');
// Sync status updates
window.electronAPI.on('sync-status-changed', (status) => {
console.log('📊 Received sync status update:', status);
console.log('🔍 Status details:', {
isRunning: status.isRunning,
currentPhase: status.currentPhase,
actualFileCount: status.actualFileCount,
stats: status.stats
});
console.log('🔄 Calling updateSyncStatus...');
this.updateSyncStatus(status);
console.log('✅ updateSyncStatus completed');
});
// File change events
window.electronAPI.on('file-changed', (event) => {
console.log('📁 File changed:', event);
this.addActivityLog('info', `File changed: ${event.path}`);
});
window.electronAPI.on('file-added', (event) => {
console.log(' File added:', event);
this.addActivityLog('success', `File added: ${event.path}`);
});
window.electronAPI.on('file-removed', (event) => {
console.log(' File removed:', event);
this.addActivityLog('info', `File removed: ${event.path}`);
});
// Sync operation updates
window.electronAPI.on('sync-operation-started', (operation) => {
console.log('🔄 Operation started:', operation);
this.addActivityLog('info', `Started ${operation.type}: ${operation.s3Key || operation.localPath}`);
});
window.electronAPI.on('sync-operation-completed', (operation) => {
console.log('✅ Operation completed:', operation);
this.addActivityLog('success', `Completed ${operation.type}: ${operation.s3Key || operation.localPath}`);
});
window.electronAPI.on('sync-operation-failed', (operation) => {
console.log('❌ Operation failed:', operation);
this.addActivityLog('error', `Failed ${operation.type}: ${operation.s3Key || operation.localPath} - ${operation.error || 'Unknown error'}`);
});
// Sync lifecycle events
window.electronAPI.on('sync-started', (type) => {
console.log('🚀 Sync started:', type);
this.addActivityLog('info', `Sync started: ${type}`);
});
window.electronAPI.on('sync-completed', (type) => {
console.log('🎉 Sync completed:', type);
this.addActivityLog('success', `Sync completed: ${type}`);
});
window.electronAPI.on('sync-error', (error) => {
console.log('💥 Sync error:', error);
this.addActivityLog('error', `Sync error: ${error.message || 'Unknown error'}`);
// Update UI to show error state
if (this.syncStatusElement) {
this.syncStatusElement.textContent = 'Error - Sync failed';
this.syncStatusElement.className = 'status-value error';
}
// Re-enable start button on error
if (this.startBtn) this.startBtn.disabled = false;
if (this.stopBtn) this.stopBtn.disabled = true;
// Reset progress bar on error
const progressBar = document.getElementById('progressBar');
if (progressBar) {
progressBar.style.width = '0%';
}
});
// Engine events
window.electronAPI.on('sync-engine-started', () => {
console.log('✅ Sync engine started');
this.addActivityLog('success', 'Sync engine started');
});
window.electronAPI.on('sync-engine-stopped', () => {
console.log('⏹️ Sync engine stopped');
this.addActivityLog('info', 'Sync engine stopped');
});
// MinIO output events
window.electronAPI.on('aws-output', (output) => {
console.log('🔍 AWS S3 output received:', output);
this.addActivityLog('info', `AWS S3 ${output.direction}: ${output.output}`);
});
// File change events
window.electronAPI.on('file-changed', (event) => {
console.log('📁 File changed:', event);
this.addActivityLog('info', `File changed: ${event.path}`);
});
}
/**
* Load initial application state
*/
async loadInitialState() {
try {
// Load configuration
const config = await window.electronAPI.invoke('config:get');
this.populateConfigurationForm(config);
// Load current sync status
const status = await window.electronAPI.invoke('sync:get-status');
if (status) {
this.updateSyncStatus(status);
}
} catch (error) {
console.error('❌ Failed to load initial state:', error);
this.addActivityLog('error', `Failed to load initial state: ${error.message || 'Unknown error'}`);
}
}
/**
* Start sync
*/
async startSync() {
try {
this.addActivityLog('info', 'Starting sync...');
await window.electronAPI.invoke('sync:start');
this.addActivityLog('success', 'Sync started successfully');
} catch (error) {
console.error('❌ Failed to start sync:', error);
this.addActivityLog('error', `Failed to start sync: ${error.message || 'Unknown error'}`);
}
}
/**
* Stop sync
*/
async stopSync() {
try {
this.addActivityLog('info', 'Stopping sync...');
await window.electronAPI.invoke('sync:stop');
this.addActivityLog('info', 'Sync stopped');
} catch (error) {
console.error('❌ Failed to stop sync:', error);
this.addActivityLog('error', `Failed to stop sync: ${error.message || 'Unknown error'}`);
}
}
/**
* Force full sync
*/
async forceFullSync() {
try {
this.addActivityLog('info', 'Forcing full sync...');
await window.electronAPI.invoke('sync:force-full');
this.addActivityLog('success', 'Full sync initiated');
} catch (error) {
console.error('❌ Failed to force full sync:', error);
this.addActivityLog('error', `Failed to force full sync: ${error.message || 'Unknown error'}`);
}
}
/**
* Update sync status display
*/
updateSyncStatus(status) {
if (!status) return;
// Update main status with more detailed information
if (this.syncStatusElement) {
if (status.isRunning) {
let statusText = 'Running';
// Add current phase information
if (status.currentPhase) {
statusText += ` - ${status.currentPhase}`;
}
// Add file counts (cleaner display)
if (status.stats && status.stats.totalFilesSynced > 0) {
statusText += ` - ${status.stats.totalFilesSynced} local files`;
}
this.syncStatusElement.textContent = statusText;
this.syncStatusElement.className = 'status-value running';
if (this.startBtn) this.startBtn.disabled = true;
if (this.stopBtn) this.stopBtn.disabled = false;
} else {
if (status.completedCount > 0 && status.pendingCount === 0 && status.inProgressCount === 0) {
this.syncStatusElement.textContent = 'Completed';
this.syncStatusElement.className = 'status-value completed';
} else {
this.syncStatusElement.textContent = 'Stopped';
this.syncStatusElement.className = 'status-value stopped';
// Reset progress bar when stopped
const progressBar = document.getElementById('progressBar');
if (progressBar) {
progressBar.style.width = '0%';
}
}
if (this.startBtn) this.startBtn.disabled = false;
if (this.stopBtn) this.stopBtn.disabled = true;
}
// Also update the force sync button state
const forceSyncBtn = document.getElementById('forceSyncBtn');
if (forceSyncBtn) {
forceSyncBtn.disabled = status.isRunning;
}
}
// Update files synced count with more detail
if (this.filesSyncedElement) {
console.log('🔍 Updating files synced element with:', {
actualFileCount: status.actualFileCount,
statsTotalFiles: status.stats?.totalFilesSynced
});
// Use actual file count from main process if available
if (status.actualFileCount !== undefined) {
const text = `${status.actualFileCount} files in local folder`;
console.log('📝 Setting files synced text to:', text);
this.filesSyncedElement.textContent = text;
} else if (status.stats && status.stats.totalFilesSynced > 0) {
const text = `${status.stats.totalFilesSynced} files in local folder`;
console.log('📝 Setting files synced text to:', text);
this.filesSyncedElement.textContent = text;
} else {
console.log('📝 Setting files synced text to: 0 files');
this.filesSyncedElement.textContent = '0 files';
}
} else {
console.warn('⚠️ filesSyncedElement not found!');
}
// Update last sync time
if (this.lastSyncElement) {
if (status.lastSync) {
const date = new Date(status.lastSync);
this.lastSyncElement.textContent = date.toLocaleString();
} else {
this.lastSyncElement.textContent = 'Never';
}
}
// Update detailed status
this.updateDetailedStatus(status);
// Update phase and progress
if (status.currentPhase && status.progressMessage) {
this.addActivityLog('info', `${status.currentPhase}: ${status.progressMessage}`);
}
// Update progress bar
if (status.progress && status.progress.percent > 0) {
const progressBar = document.getElementById('progressBar');
if (progressBar) {
progressBar.style.width = `${status.progress.percent}%`;
}
// Only log progress changes to reduce spam
const currentProgress = status.progress.percent;
if (!this.lastLoggedProgress || Math.abs(currentProgress - this.lastLoggedProgress) >= 10) {
const progressText = `Progress: ${currentProgress}% - ${status.progress.message || 'Syncing files...'}`;
this.addActivityLog('info', progressText);
this.lastLoggedProgress = currentProgress;
}
}
// Add file count updates (but only when they change significantly)
if (status.stats && status.stats.totalFilesSynced > 0) {
const currentFileCount = status.stats.totalFilesSynced;
if (!this.lastLoggedFileCount || Math.abs(currentFileCount - this.lastLoggedFileCount) >= 50) {
const fileText = `Local files: ${currentFileCount} (${status.stats.filesDownloaded} downloaded, ${status.stats.filesUploaded} uploaded)`;
this.addActivityLog('success', fileText);
this.lastLoggedFileCount = currentFileCount;
}
}
}
/**
* Update detailed status display
*/
updateDetailedStatus(status) {
if (!this.syncDetailsElement) return;
let detailsHTML = '';
if (status.pendingCount > 0) {
detailsHTML += `<div class="status-detail pending">📋 ${status.pendingCount} pending</div>`;
}
if (status.inProgressCount > 0) {
detailsHTML += `<div class="status-detail in-progress">🔄 ${status.inProgressCount} in progress</div>`;
}
if (status.completedCount > 0) {
detailsHTML += `<div class="status-detail completed">✅ ${status.completedCount} completed</div>`;
}
if (status.failedCount > 0) {
detailsHTML += `<div class="status-detail failed">❌ ${status.failedCount} failed</div>`;
}
if (status.errors && status.errors.length > 0) {
detailsHTML += `<div class="status-detail errors">⚠️ ${status.errors.length} errors</div>`;
}
this.syncDetailsElement.innerHTML = detailsHTML;
}
/**
* Populate configuration form
*/
populateConfigurationForm(config) {
if (!config) return;
if (this.s3EndpointInput) this.s3EndpointInput.value = config.s3?.endpoint || '';
if (this.s3AccessKeyInput) this.s3AccessKeyInput.value = config.s3?.accessKeyId || '';
if (this.s3SecretKeyInput) this.s3SecretKeyInput.value = config.s3?.secretAccessKey || '';
if (this.s3BucketInput) this.s3BucketInput.value = config.s3?.bucketName || '';
if (this.s3RegionInput) this.s3RegionInput.value = config.s3?.region || '';
if (this.localPathInput) this.localPathInput.value = config.sync?.localPath || '';
if (this.syncIntervalInput) this.syncIntervalInput.value = config.sync?.syncInterval || 30000;
}
/**
* Save configuration
*/
async saveConfiguration() {
try {
const config = {
s3: {
endpoint: this.s3EndpointInput?.value || '',
accessKeyId: this.s3AccessKeyInput?.value || '',
secretAccessKey: this.s3SecretKeyInput?.value || '',
bucketName: this.s3BucketInput?.value || '',
region: this.s3RegionInput?.value || '',
},
sync: {
localPath: this.localPathInput?.value || '',
syncInterval: parseInt(this.syncIntervalInput?.value || '30000'),
}
};
// Update S3 config
await window.electronAPI.invoke('config:update-s3', config.s3);
// Update sync config
await window.electronAPI.invoke('config:update-sync', config.sync);
this.addActivityLog('success', 'Configuration saved successfully');
// Test S3 connection
try {
const testResult = await window.electronAPI.invoke('config:test-s3');
if (testResult.success) {
this.addActivityLog('success', 'S3 connection test successful');
} else {
this.addActivityLog('error', `S3 connection test failed: ${testResult.error}`);
}
} catch (error) {
this.addActivityLog('error', `S3 connection test failed: ${error.message || 'Unknown error'}`);
}
} catch (error) {
console.error('❌ Failed to save configuration:', error);
this.addActivityLog('error', `Failed to save configuration: ${error.message || 'Unknown error'}`);
}
}
/**
* Force full sync
*/
async forceFullSync() {
try {
this.addActivityLog('info', '🔄 Starting force full sync...');
await window.electronAPI.invoke('sync:force-full');
this.addActivityLog('success', '✅ Force full sync completed');
} catch (error) {
console.error('❌ Force full sync failed:', error);
this.addActivityLog('error', `❌ Force full sync failed: ${error.message || 'Unknown error'}`);
}
}
/**
* Trigger immediate sync to propagate local changes
*/
async triggerImmediateSync() {
try {
this.addActivityLog('info', '🚀 Triggering immediate sync to propagate local changes...');
await window.electronAPI.invoke('sync:trigger-immediate');
this.addActivityLog('success', '✅ Immediate sync completed');
} catch (error) {
console.error('❌ Immediate sync failed:', error);
this.addActivityLog('error', `❌ Immediate sync failed: ${error.message || 'Unknown error'}`);
}
}
/**
* Export configuration to .env file
*/
async exportToEnv() {
try {
const result = await window.electronAPI.invoke('config:export-env');
if (result.success) {
this.addActivityLog('success', 'Configuration exported to .env file successfully');
} else {
this.addActivityLog('error', `Failed to export configuration: ${result.error}`);
}
} catch (error) {
console.error('❌ Failed to export configuration:', error);
this.addActivityLog('error', `Failed to export configuration: ${error.message || 'Unknown error'}`);
}
}
/**
* Add entry to activity log
*/
addActivityLog(type, message) {
if (!this.activityLogElement) return;
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = `log-entry ${type}`;
logEntry.innerHTML = `
<span class="log-timestamp">[${timestamp}]</span>
<span class="log-message">${message}</span>
`;
this.activityLogElement.appendChild(logEntry);
// Scroll to bottom
this.activityLogElement.scrollTop = this.activityLogElement.scrollHeight;
// Limit log entries to prevent memory issues
const entries = this.activityLogElement.querySelectorAll('.log-entry');
if (entries.length > 100) {
entries[0].remove();
}
// Update the activity count in the header if it exists
this.updateActivityCount();
}
/**
* Update activity count in the header
*/
updateActivityCount() {
const entries = this.activityLogElement?.querySelectorAll('.log-entry') || [];
const count = entries.length;
// Find and update any activity count display
const activityCountElement = document.querySelector('.activity-count');
if (activityCountElement) {
activityCountElement.textContent = count;
}
}
/**
* Show notification
*/
showNotification(type, title, message) {
// You could implement desktop notifications here
}
}
// Initialize the renderer when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
try {
window.rekordboxSyncRenderer = new RekordboxSyncRenderer();
} catch (error) {
console.error('❌ Failed to initialize renderer:', error);
}
});
// Fallback initialization if DOMContentLoaded doesn't fire
if (document.readyState === 'loading') {
// Wait for DOMContentLoaded
} else {
try {
window.rekordboxSyncRenderer = new RekordboxSyncRenderer();
} catch (error) {
console.error('❌ Failed to initialize renderer (fallback):', error);
}
}

View File

@ -0,0 +1,620 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
height: 100vh;
overflow: hidden;
}
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
/* Header */
.app-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.header-left h1 {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-left h1 i {
color: #3498db;
}
.header-right {
display: flex;
gap: 0.5rem;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, #2980b9, #1f5f8b);
}
.btn-secondary {
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
color: white;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #7f8c8d, #6c7b7d);
}
.btn-danger {
background: linear-gradient(135deg, #e74c3c, #c0392b);
color: white;
}
.btn-danger:hover {
background: linear-gradient(135deg, #c0392b, #a93226);
}
.btn-info {
background: linear-gradient(135deg, #17a2b8, #138496);
color: white;
}
.btn-info:hover {
background: linear-gradient(135deg, #138496, #117a8b);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn:disabled:hover {
transform: none;
box-shadow: none;
}
/* Main content */
.main-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto 1fr;
gap: 1.5rem;
grid-template-areas:
"status control"
"activity activity"
"activity activity";
}
/* Status Panel */
.status-panel {
grid-area: status;
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-around;
align-items: center;
}
.status-item {
text-align: center;
}
.status-label {
display: block;
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
display: block;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
#syncStatus {
padding: 0.25rem 0.75rem;
border-radius: 20px;
background: #ecf0f1;
color: #7f8c8d;
}
#syncStatus.running {
background: #d5f4e6;
color: #27ae60;
}
#syncStatus.stopped {
background: #fadbd8;
color: #e74c3c;
}
#syncStatus.completed {
background: #d4edda;
color: #28a745;
}
#syncStatus.error {
background: #f8d7da;
color: #721c24;
}
/* Progress Bar */
.progress-container {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #3498db, #2ecc71);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Control Panel */
.control-panel {
grid-area: control;
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
}
/* Activity Panel */
.activity-panel {
grid-area: activity;
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.activity-panel h3 {
margin-bottom: 1rem;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.activity-log {
flex: 1;
overflow-y: auto;
}
.empty-activity {
text-align: center;
color: #95a5a6;
font-style: italic;
padding: 2rem;
}
.activity-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 6px;
background: #f8f9fa;
border-left: 4px solid #95a5a6;
font-size: 0.9rem;
}
.activity-item.info {
border-left-color: #3498db;
}
.activity-item.success {
border-left-color: #27ae60;
}
.activity-item.warning {
border-left-color: #f39c12;
}
.activity-item.error {
border-left-color: #e74c3c;
}
.activity-time {
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0.25rem;
}
.activity-message {
color: #2c3e50;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.close-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.modal-body {
padding: 1.5rem;
max-height: 60vh;
overflow-y: auto;
}
.modal-footer {
padding: 1.5rem;
background: #f8f9fa;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Settings sections */
.settings-section {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #ecf0f1;
}
.settings-section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.settings-section h3 {
color: #2c3e50;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
}
.form-group input[type="text"],
.form-group input[type="url"],
.form-group input[type="password"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.75rem;
border: 2px solid #ecf0f1;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3498db;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.path-input-group {
display: flex;
gap: 0.5rem;
}
.path-input-group input {
flex: 1;
}
/* Notifications */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.notification {
background: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border-left: 4px solid #3498db;
min-width: 300px;
animation: notificationSlideIn 0.3s ease-out;
}
.notification.success {
border-left-color: #27ae60;
}
.notification.warning {
border-left-color: #f39c12;
}
.notification.error {
border-left-color: #e74c3c;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.notification-title {
font-weight: 600;
color: #2c3e50;
}
.notification-close {
background: none;
border: none;
color: #95a5a6;
cursor: pointer;
font-size: 1.2rem;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.notification-close:hover {
background-color: #ecf0f1;
}
.notification-message {
color: #7f8c8d;
font-size: 0.9rem;
}
@keyframes notificationSlideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Responsive design */
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
grid-template-areas:
"status"
"control"
"queue"
"activity";
}
.status-panel {
flex-direction: column;
gap: 1rem;
}
.control-panel {
flex-direction: column;
}
.modal-content {
width: 95%;
margin: 1rem;
}
.path-input-group {
flex-direction: column;
}
}
/* Sync Details Styling */
.sync-details {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status-detail {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-detail.pending {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.status-detail.in-progress {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.status-detail.completed {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-detail.failed {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-detail.errors {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}

View File

@ -0,0 +1,689 @@
import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain } from 'electron';
import * as path from 'path';
import { ConfigManager } from './services/configManager';
import { SyncManager, SyncConfig } from './services/syncManager';
class RekordboxSyncApp {
private mainWindow: BrowserWindow | null = null;
private tray: Tray | null = null;
private configManager: ConfigManager;
private syncManager: SyncManager | null = null;
private isQuitting: boolean = false;
constructor() {
this.configManager = new ConfigManager();
// Set memory limits to prevent crashes
process.setMaxListeners(50);
// Handle uncaught exceptions to prevent EPIPE popups
process.on('uncaughtException', (error) => {
console.error('❌ Uncaught Exception:', error);
this.handleUncaughtException(error);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
this.handleUnhandledRejection(reason, promise);
});
// App event handlers
app.whenReady().then(() => this.initialize());
app.on('window-all-closed', () => this.handleWindowAllClosed());
app.on('before-quit', () => this.handleBeforeQuit());
app.on('activate', () => this.handleActivate());
// IPC handlers
this.setupIpcHandlers();
}
/**
* Initialize the application
*/
private async initialize(): Promise<void> {
await this.createMainWindow();
this.createTray();
this.setupMenu();
// Auto-start sync if configured
const syncConfig = this.configManager.getSyncConfig();
if (syncConfig.autoStart) {
this.startSync();
}
// Start periodic status updates to keep UI informed
this.startPeriodicStatusUpdates();
}
/**
* Create the main application window
*/
private async createMainWindow(): Promise<void> {
this.mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
icon: path.join(__dirname, '../assets/icon.png'),
show: false,
});
// Load the main HTML file
await this.mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
// Show window when ready
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
// Handle window close
this.mainWindow.on('close', (event) => {
if (!this.isQuitting) {
event.preventDefault();
this.mainWindow?.hide();
}
});
// Handle window closed
this.mainWindow.on('closed', () => {
this.mainWindow = null;
});
// Add error handling for the renderer process
this.mainWindow.webContents.on('crashed', (event, killed) => {
console.error('❌ Renderer process crashed:', { event, killed });
this.handleRendererCrash();
});
this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('❌ Renderer failed to load:', { errorCode, errorDescription });
this.handleRendererFailure();
});
}
/**
* Create system tray icon
*/
private createTray(): void {
const iconPath = path.join(__dirname, '../assets/icon.png');
const icon = nativeImage.createFromPath(iconPath);
this.tray = new Tray(icon);
this.tray.setToolTip('Rekordbox Sync');
this.tray.on('click', () => {
if (this.mainWindow?.isVisible()) {
this.mainWindow.hide();
} else {
this.mainWindow?.show();
this.mainWindow?.focus();
}
});
this.tray.on('right-click', () => {
this.showTrayContextMenu();
});
}
/**
* Show tray context menu
*/
private showTrayContextMenu(): void {
if (!this.tray) return;
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show App',
click: () => {
this.mainWindow?.show();
this.mainWindow?.focus();
},
},
{
label: 'Start Sync',
click: () => this.startSync(),
enabled: !this.syncManager?.getState().isRunning,
},
{
label: 'Stop Sync',
click: () => this.stopSync(),
enabled: this.syncManager?.getState().isRunning || false,
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
this.isQuitting = true;
app.quit();
},
},
]);
this.tray.popUpContextMenu(contextMenu);
}
/**
* Setup application menu
*/
private setupMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
click: () => this.showSettings(),
},
{ type: 'separator' },
{
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
this.isQuitting = true;
app.quit();
},
},
],
},
{
label: 'Sync',
submenu: [
{
label: 'Start Sync',
click: () => this.startSync(),
enabled: !this.syncManager?.getState().isRunning,
},
{
label: 'Stop Sync',
click: () => this.stopSync(),
enabled: this.syncManager?.getState().isRunning || false,
},
{ type: 'separator' },
{
label: 'Force Full Sync',
click: () => this.forceFullSync(),
},
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
/**
* Setup IPC handlers
*/
private setupIpcHandlers(): void {
console.log('🔌 Setting up IPC handlers...');
// Sync control
ipcMain.handle('sync:start', () => {
return this.startSync();
});
ipcMain.handle('sync:stop', () => {
return this.stopSync();
});
ipcMain.handle('sync:force-full', () => {
return this.forceFullSync();
});
ipcMain.handle('sync:trigger-immediate', () => {
return this.triggerImmediateSync();
});
ipcMain.handle('sync:get-status', () => {
return this.syncManager?.getState() || null;
});
// Configuration
ipcMain.handle('config:get', () => this.configManager.getConfig());
ipcMain.handle('config:update-s3', (event, config) => this.configManager.updateS3Config(config));
ipcMain.handle('config:update-sync', (event, config) => this.configManager.updateSyncConfig(config));
ipcMain.handle('config:update-ui', (event, config) => this.configManager.updateUIConfig(config));
ipcMain.handle('config:test-s3', () => this.configManager.testS3Connection());
ipcMain.handle('config:export-env', async (event, exportPath) => {
try {
await this.configManager.exportToEnv(exportPath);
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
// File operations
ipcMain.handle('dialog:select-folder', async () => {
// This would need to be implemented with electron dialog
return null;
});
}
/**
* Start sync
*/
private async startSync(): Promise<void> {
try {
// Force reload .env file to ensure latest changes are picked up
this.configManager.reloadEnvironmentVariables();
// Always get fresh config and recreate service to ensure .env changes are picked up
const appConfig = this.configManager.getConfig();
const syncConfig: SyncConfig = {
s3: {
endpoint: appConfig.s3.endpoint,
region: appConfig.s3.region,
accessKeyId: appConfig.s3.accessKeyId,
secretAccessKey: appConfig.s3.secretAccessKey,
bucketName: appConfig.s3.bucketName,
useSSL: appConfig.s3.useSSL
},
sync: {
localPath: appConfig.sync.localPath,
interval: appConfig.sync.syncInterval,
autoStart: appConfig.sync.autoStart,
conflictResolution: appConfig.sync.conflictResolution
}
};
// Recreate sync manager to ensure new config is used
if (this.syncManager) {
this.syncManager.destroy();
}
this.syncManager = new SyncManager(syncConfig);
this.setupSyncManagerEventHandlers();
await this.syncManager.startSync();
} catch (error) {
console.error('❌ Failed to start sync:', error);
throw error;
}
}
/**
* Stop sync
*/
private stopSync(): void {
try {
if (this.syncManager) {
this.syncManager.stopSync();
}
} catch (error) {
console.error('❌ Failed to stop sync:', error);
}
}
/**
* Trigger an immediate sync to propagate local changes (including deletions)
*/
private async triggerImmediateSync(): Promise<void> {
try {
if (this.syncManager) {
await this.syncManager.triggerImmediateSync();
}
} catch (error) {
console.error('❌ Immediate sync failed:', error);
}
}
/**
* Force full sync
*/
private async forceFullSync(): Promise<void> {
try {
if (this.syncManager) {
await this.syncManager.forceFullSync();
console.log('✅ Force full sync completed');
}
} catch (error) {
console.error('❌ Force full sync failed:', error);
}
}
/**
* Setup sync manager event handlers
*/
private setupSyncManagerEventHandlers(): void {
if (!this.syncManager) return;
// Helper function to safely send messages
const safeSend = (channel: string, data: any) => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, data);
}
} catch (error) {
console.warn(`⚠️ Failed to send message to renderer (${channel}):`, error);
}
};
// Sync manager events
this.syncManager.on('stateChanged', (state: any) => {
console.log('📊 Sync state changed:', state);
// Ensure the state is properly formatted for the UI
const uiState = {
...state,
// Add actual file counts from the file system
actualFileCount: this.getActualLocalFileCount(),
lastUpdate: new Date().toISOString()
};
safeSend('sync-status-changed', uiState);
this.updateTrayTooltip(uiState);
});
// Add more detailed sync events
this.syncManager.on('fileChanged', (event: any) => {
safeSend('file-changed', {
path: event.path || event,
type: event.type,
timestamp: new Date().toISOString()
});
});
this.syncManager.on('fileAdded', (filePath: string) => {
safeSend('file-added', { path: filePath, timestamp: new Date().toISOString() });
});
this.syncManager.on('fileRemoved', (filePath: string) => {
safeSend('file-removed', { path: filePath, timestamp: new Date().toISOString() });
// Trigger immediate sync to propagate deletion to S3
this.triggerImmediateSync();
});
this.syncManager.on('syncError', (error: any) => {
console.log('💥 Sync error:', error);
safeSend('sync-error', error);
});
this.syncManager.on('syncStarted', () => {
console.log('🚀 Sync started');
safeSend('sync-started', {});
});
this.syncManager.on('syncStopped', () => {
console.log('⏹️ Sync stopped');
safeSend('sync-stopped', {});
});
this.syncManager.on('forceSyncCompleted', () => {
console.log('🔄 Force sync completed');
safeSend('force-sync-completed', {});
});
this.syncManager.on('awsOutput', (output: any) => {
console.log('🔍 AWS S3 output:', output);
safeSend('aws-output', output);
});
}
/**
* Update tray tooltip with sync status
*/
private updateTrayTooltip(status: any): void {
if (!this.tray) return;
const statusText = status.isRunning ? 'Running' : 'Stopped';
const phaseText = status.currentPhase ? ` - ${status.currentPhase}` : '';
this.tray.setToolTip(`Rekordbox Sync - ${statusText}${phaseText}`);
}
/**
* Show settings dialog
*/
private showSettings(): void {
if (this.mainWindow) {
this.mainWindow.webContents.send('show-settings');
}
}
/**
* Handle window all closed event
*/
private handleWindowAllClosed(): void {
if (process.platform !== 'darwin') {
app.quit();
}
}
/**
* Handle before quit event
*/
private handleBeforeQuit(): void {
this.isQuitting = true;
this.stopSync();
}
/**
* Handle activate event (macOS)
*/
private handleActivate(): void {
if (BrowserWindow.getAllWindows().length === 0) {
this.createMainWindow();
} else {
this.mainWindow?.show();
}
}
/**
* Handle renderer process crash
*/
private handleRendererCrash(): void {
console.log('🔄 Attempting to recover from renderer crash...');
// Stop sync to prevent further issues
if (this.syncManager?.getState().isRunning) {
console.log('⏹️ Stopping sync due to renderer crash');
this.stopSync();
}
// Try to reload the renderer
setTimeout(() => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log('🔄 Reloading renderer...');
this.mainWindow.reload();
}
} catch (error) {
console.error('❌ Failed to reload renderer:', error);
}
}, 2000);
}
/**
* Handle renderer process failure
*/
private handleRendererFailure(): void {
console.log('🔄 Attempting to recover from renderer failure...');
// Try to reload the renderer
setTimeout(() => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log('🔄 Reloading renderer...');
this.mainWindow.reload();
}
} catch (error) {
console.error('❌ Failed to reload renderer:', error);
}
}, 1000);
}
/**
* Handle uncaught exceptions to prevent EPIPE popups
*/
private handleUncaughtException(error: Error): void {
console.error('🚨 Handling uncaught exception:', error);
// Don't show error popup for EPIPE errors
if (error.message.includes('EPIPE') || error.message.includes('write EPIPE')) {
console.log('🔇 Suppressing EPIPE error popup');
return;
}
// For other errors, try to recover gracefully
console.log('🔄 Attempting to recover from uncaught exception...');
// Stop sync to prevent further issues
if (this.syncManager?.getState().isRunning) {
console.log('⏹️ Stopping sync due to uncaught exception');
this.stopSync();
}
}
/**
* Start periodic status updates to keep UI informed
*/
private startPeriodicStatusUpdates(): void {
// Send status updates every 2 seconds to keep UI responsive
setInterval(() => {
if (this.syncManager) {
const status = this.syncManager.getState();
// Add actual file counts to the status
if (status.stats) {
try {
const fs = require('fs');
const path = require('path');
let localFileCount = 0;
const countFiles = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
countFiles(fullPath);
} else if (stat.isFile()) {
const ext = path.extname(item).toLowerCase();
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
localFileCount++;
}
}
}
} catch (error) {
// Ignore errors for individual files
}
};
const syncPath = this.configManager.getSyncConfig().localPath;
if (fs.existsSync(syncPath)) {
countFiles(syncPath);
}
// Update the stats with actual file count
status.stats.totalFilesSynced = localFileCount;
status.stats.filesDownloaded = localFileCount;
} catch (error) {
console.warn('⚠️ Error updating file count:', error);
}
}
this.safeSendToRenderer('sync-status-changed', status);
}
}, 2000);
}
/**
* Get actual file count from local sync folder
*/
private getActualLocalFileCount(): number {
try {
const fs = require('fs');
const path = require('path');
let fileCount = 0;
const syncPath = this.configManager.getSyncConfig().localPath;
if (!fs.existsSync(syncPath)) {
return 0;
}
const countFiles = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
countFiles(fullPath);
} else if (stat.isFile()) {
const ext = path.extname(item).toLowerCase();
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
fileCount++;
}
}
}
} catch (error) {
console.warn(`⚠️ Error counting files in ${dirPath}:`, error);
}
};
countFiles(syncPath);
return fileCount;
} catch (error) {
console.error('❌ Error getting actual file count:', error);
return 0;
}
}
/**
* Safe send to renderer with better error handling
*/
private safeSendToRenderer(channel: string, data: any): void {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log(`📤 Periodic update to renderer (${channel}):`, data);
this.mainWindow.webContents.send(channel, data);
}
} catch (error) {
console.warn(`⚠️ Failed to send periodic update to renderer (${channel}):`, error);
}
}
/**
* Handle unhandled promise rejections
*/
private handleUnhandledRejection(reason: any, promise: Promise<any>): void {
console.error('🚨 Handling unhandled rejection:', { reason, promise });
// Don't show error popup for EPIPE-related rejections
if (reason && reason.message && reason.message.includes('EPIPE')) {
console.log('🔇 Suppressing EPIPE rejection popup');
return;
}
// For other rejections, log and continue
console.log('🔄 Continuing after unhandled rejection...');
}
}
// Create and run the application
new RekordboxSyncApp();

View File

@ -0,0 +1,81 @@
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
// Sync control
invoke: (channel: string, ...args: any[]) => {
const validChannels = [
'sync:start',
'sync:stop',
'sync:force-full',
'sync:trigger-immediate',
'sync:get-status',
'config:get',
'config:update-s3',
'config:update-sync',
'config:update-ui',
'config:test-s3',
'config:export-env',
'dialog:select-folder'
];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
throw new Error(`Invalid channel: ${channel}`);
},
// Event listeners for sync updates
on: (channel: string, func: (...args: any[]) => void) => {
const validChannels = [
'sync-status-changed',
'sync-operation-started',
'sync-operation-completed',
'sync-operation-failed',
'sync-started',
'sync-completed',
'sync-error',
'sync-engine-started',
'sync-engine-stopped',
'file-changed',
'aws-output',
'show-settings'
];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event: any, ...args: any[]) => func(...args));
}
},
// Remove event listeners
removeAllListeners: (channel: string) => {
const validChannels = [
'sync-status-changed',
'sync-operation-started',
'sync-operation-completed',
'sync-operation-failed',
'sync-started',
'sync-completed',
'sync-error',
'sync-engine-started',
'sync-engine-stopped',
'file-changed',
'aws-output',
'show-settings'
];
if (validChannels.includes(channel)) {
ipcRenderer.removeAllListeners(channel);
}
},
// Export environment variables
exportEnv: async (exportPath?: string) => {
return await ipcRenderer.invoke('config:export-env', exportPath);
}
});
// Note: Type definitions are handled in a separate .d.ts file

View File

@ -0,0 +1,715 @@
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
export interface AwsS3Config {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region: string;
useSSL: boolean;
localPath: string;
configManager?: any; // Reference to ConfigManager for persistent storage
}
export interface AwsS3Status {
isRunning: boolean;
lastSync: Date | null;
filesSynced: number;
currentPhase: string;
progressMessage: string;
progressPercent: number;
transferSpeed: string;
eta: string;
}
export class AwsS3Service extends EventEmitter {
private config: AwsS3Config;
private isRunning: boolean = false;
private currentProcess: ChildProcess | null = null;
private lastSyncTime: Date | null = null;
private currentPhase: string = 'idle';
private progressMessage: string = 'Ready';
private progressPercent: number = 0;
private transferSpeed: string = '';
private eta: string = '';
private filesSynced: number = 0;
private watchProcesses: ChildProcess[] = [];
private retryCount: number = 0;
private maxRetries: number = 3;
constructor(config: AwsS3Config) {
super();
this.config = config;
}
/**
* Start AWS S3 bidirectional sync
*/
async startSync(): Promise<void> {
if (this.isRunning) {
throw new Error('Sync is already running');
}
this.isRunning = true;
this.currentPhase = 'starting';
this.progressMessage = 'Initializing AWS S3 sync...';
this.emitStatusUpdate();
try {
// First, ensure AWS CLI is available
await this.ensureAwsCliAvailable();
// Configure AWS credentials for Garage
await this.configureAwsCredentials();
// Run the sync strategy
await this.runBidirectionalSync();
} catch (error) {
console.error('❌ AWS S3 sync failed:', error);
this.currentPhase = 'error';
this.progressMessage = `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.isRunning = false;
this.emitStatusUpdate();
throw error;
}
}
/**
* Stop the current sync process
*/
stopSync(): void {
if (!this.isRunning) {
return;
}
console.log('⏹️ Stopping AWS S3 sync...');
this.currentPhase = 'stopping';
this.progressMessage = 'Stopping sync...';
this.emitStatusUpdate();
try {
// Stop all watch processes
this.watchProcesses.forEach(process => {
if (!process.killed) {
process.kill('SIGTERM');
}
});
this.watchProcesses = [];
// Stop current process
if (this.currentProcess && !this.currentProcess.killed) {
this.currentProcess.kill('SIGTERM');
}
} catch (error) {
console.error('❌ Error stopping AWS S3 service:', error);
}
this.isRunning = false;
this.currentProcess = null;
this.currentPhase = 'stopped';
this.progressMessage = 'Sync stopped';
this.emitStatusUpdate();
}
/**
* Force a full sync (delete and re-sync)
*/
async forceFullSync(): Promise<void> {
if (this.isRunning) {
this.stopSync();
// Wait a bit for the process to stop
await new Promise(resolve => setTimeout(resolve, 2000));
}
console.log('🔄 Starting force full sync...');
this.currentPhase = 'force-sync';
this.progressMessage = 'Preparing force sync...';
this.emitStatusUpdate();
try {
await this.ensureAwsCliAvailable();
await this.configureAwsCredentials();
await this.runForceFullSync();
} catch (error) {
console.error('❌ Force full sync failed:', error);
this.currentPhase = 'error';
this.progressMessage = `Force sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.isRunning = false;
this.emitStatusUpdate();
throw error;
}
}
/**
* Get current status
*/
getStatus(): AwsS3Status {
return {
isRunning: this.isRunning,
lastSync: this.lastSyncTime,
filesSynced: this.filesSynced,
currentPhase: this.currentPhase as any,
progressMessage: this.progressMessage,
progressPercent: this.progressPercent,
transferSpeed: this.transferSpeed,
eta: this.eta
};
}
/**
* Check if AWS CLI is available
*/
private async ensureAwsCliAvailable(): Promise<void> {
return new Promise((resolve, reject) => {
const process = spawn('aws', ['--version'], { stdio: 'pipe' });
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error('AWS CLI is not available. Please install AWS CLI v2.'));
}
});
process.on('error', () => {
reject(new Error('AWS CLI is not available. Please install AWS CLI v2.'));
});
});
}
/**
* Configure AWS credentials for Garage
*/
private async configureAwsCredentials(): Promise<void> {
return new Promise((resolve, reject) => {
// Set environment variables for this process
process.env.AWS_ACCESS_KEY_ID = this.config.accessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = this.config.secretAccessKey;
process.env.AWS_DEFAULT_REGION = this.config.region;
// Configure AWS CLI for Garage endpoint
const configureProcess = spawn('aws', [
'configure', 'set', 'default.s3.endpoint_url', this.config.endpoint
], { stdio: 'pipe' });
configureProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error('Failed to configure AWS credentials'));
}
});
configureProcess.on('error', (error) => {
reject(new Error(`Failed to configure AWS credentials: ${error.message}`));
});
});
}
/**
* Run bidirectional sync strategy
*/
private async runBidirectionalSync(): Promise<void> {
return new Promise(async (resolve, reject) => {
try {
// Step 0: Clean up temporary files before syncing
console.log('🧹 Step 0: Cleaning up temporary files...');
this.currentPhase = 'cleaning';
this.progressMessage = 'Cleaning up temporary files...';
this.emitStatusUpdate();
await this.cleanupTemporaryFiles();
// Step 1: Initial sync from S3 to local (download everything first time)
const lastSync = this.getLastSyncTime();
if (!lastSync) {
console.log('📥 Step 1: Initial sync - downloading all files from S3...');
this.currentPhase = 'downloading';
this.progressMessage = 'Initial sync - downloading all files from S3...';
this.emitStatusUpdate();
await this.runAwsCommand([
's3', 'sync',
`s3://${this.config.bucketName}/`,
this.config.localPath,
'--delete', // Remove local files that don't exist in S3
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
} else {
console.log(`📥 Step 1: Incremental sync - downloading new/changed files from S3...`);
this.currentPhase = 'downloading';
this.progressMessage = 'Incremental sync - downloading new/changed files from S3...';
this.emitStatusUpdate();
await this.runAwsCommand([
's3', 'sync',
`s3://${this.config.bucketName}/`,
this.config.localPath,
'--delete', // Remove local files that don't exist in S3
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
}
// Step 2: Upload new/changed files from local to S3 (with delete to propagate local deletions)
console.log('📤 Step 2: Uploading new/changed files to S3 and propagating local deletions...');
this.currentPhase = 'uploading';
this.progressMessage = 'Uploading new/changed files to S3 and propagating local deletions...';
this.emitStatusUpdate();
await this.runAwsCommand([
's3', 'sync',
this.config.localPath,
`s3://${this.config.bucketName}/`,
'--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
// Step 3: Start continuous bidirectional sync
console.log('🔄 Step 3: Starting continuous bidirectional sync...');
this.currentPhase = 'watching';
this.progressMessage = 'Continuous bidirectional sync active...';
this.emitStatusUpdate();
await this.startContinuousSync();
console.log('✅ Bidirectional sync completed successfully');
this.currentPhase = 'completed';
this.progressMessage = 'Bidirectional sync completed successfully!';
// Save the last sync time persistently
const syncTime = new Date();
this.lastSyncTime = syncTime;
if (this.config.configManager && this.config.configManager.setLastSyncTime) {
this.config.configManager.setLastSyncTime(syncTime);
}
this.emitStatusUpdate();
resolve();
} catch (error) {
console.error('❌ Bidirectional sync failed:', error);
this.currentPhase = 'error';
this.progressMessage = `Bidirectional sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.emitStatusUpdate();
reject(error);
}
});
}
/**
* Run force full sync
*/
private async runForceFullSync(): Promise<void> {
return new Promise(async (resolve, reject) => {
try {
// Step 1: Force download from S3 to local (with delete)
console.log('📥 Step 1: Force downloading from S3 (with delete)...');
this.currentPhase = 'force-downloading';
this.progressMessage = 'Force downloading from S3...';
this.emitStatusUpdate();
await this.runAwsCommand([
's3', 'sync',
`s3://${this.config.bucketName}/`,
this.config.localPath,
'--delete',
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
// Step 2: Force upload from local to S3 (with delete)
console.log('📤 Step 2: Force uploading to S3 (with delete)...');
this.currentPhase = 'force-uploading';
this.progressMessage = 'Force uploading to S3...';
this.emitStatusUpdate();
await this.runAwsCommand([
's3', 'sync',
this.config.localPath,
`s3://${this.config.bucketName}/`,
'--delete',
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
console.log('✅ Force full sync completed successfully');
this.currentPhase = 'completed';
this.progressMessage = 'Force full sync completed successfully!';
this.isRunning = false;
this.emitStatusUpdate();
resolve();
} catch (error) {
console.error('❌ Force full sync failed:', error);
this.currentPhase = 'error';
this.progressMessage = `Force full sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.isRunning = false;
this.emitStatusUpdate();
reject(error);
}
});
}
/**
* Start continuous sync with periodic intervals
*/
private async startContinuousSync(): Promise<void> {
return new Promise((resolve) => {
try {
console.log('🔄 Starting continuous bidirectional sync...');
// Set up periodic sync every 30 seconds
const syncInterval = setInterval(async () => {
if (!this.isRunning) {
clearInterval(syncInterval);
return;
}
try {
console.log('🔄 Running periodic bidirectional sync...');
// Step 1: Download changes from S3 (propagate S3 deletions to local)
console.log('📥 Periodic sync: Downloading changes from S3...');
await this.runAwsCommand([
's3', 'sync',
`s3://${this.config.bucketName}/`,
this.config.localPath,
'--delete', // Remove local files that don't exist in S3
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
// Step 2: Upload changes to S3 (propagate local deletions to S3)
console.log('📤 Periodic sync: Uploading changes to S3 and propagating local deletions...');
await this.runAwsCommand([
's3', 'sync',
this.config.localPath,
`s3://${this.config.bucketName}/`,
'--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
console.log('✅ Periodic sync completed');
} catch (error) {
console.error('❌ Periodic sync failed:', error);
this.emit('syncError', error);
}
}, 30000); // 30 seconds
// Store the interval for cleanup
this.watchProcesses.push({
killed: false,
kill: () => clearInterval(syncInterval)
} as any);
// Resolve after a short delay to ensure the interval is set up
setTimeout(() => {
console.log('✅ Continuous bidirectional sync started');
resolve();
}, 2000);
} catch (error) {
console.error('❌ Failed to start continuous sync:', error);
resolve(); // Don't fail the sync for this
}
});
}
/**
* Trigger an immediate sync to propagate local changes (including deletions)
*/
async triggerImmediateSync(): Promise<void> {
if (!this.isRunning) {
console.log('⚠️ Cannot trigger immediate sync - service not running');
return;
}
try {
console.log('🚀 Triggering immediate sync to propagate local changes...');
// Step 1: Upload local changes to S3 (including deletions)
console.log('📤 Immediate sync: Uploading local changes and propagating deletions to S3...');
await this.runAwsCommand([
's3', 'sync',
this.config.localPath,
`s3://${this.config.bucketName}/`,
'--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
'--exclude', '*.tmp',
'--exclude', '*.temp',
'--exclude', '*.part',
'--exclude', '.DS_Store',
'--exclude', '*.crdownload'
]);
console.log('✅ Immediate sync completed');
} catch (error) {
console.error('❌ Immediate sync failed:', error);
this.emit('syncError', error);
}
}
/**
* Clean up temporary files before syncing
*/
private async cleanupTemporaryFiles(): Promise<void> {
return new Promise((resolve) => {
try {
console.log('🧹 Cleaning up temporary files in:', this.config.localPath);
const cleanupDir = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
cleanupDir(fullPath);
} else if (stat.isFile()) {
const ext = path.extname(item).toLowerCase();
const name = item.toLowerCase();
// Check if it's a temporary file
if (ext === '.tmp' || ext === '.temp' || ext === '.part' ||
ext === '.crdownload' || name === '.ds_store' ||
name.includes('.tmp') || name.includes('.temp') ||
name.includes('.part') || name.includes('.crdownload')) {
try {
fs.unlinkSync(fullPath);
console.log('🗑️ Removed temporary file:', fullPath);
} catch (error) {
console.warn('⚠️ Could not remove temporary file:', fullPath, error);
}
}
}
}
} catch (error) {
console.warn('⚠️ Error cleaning directory:', dirPath, error);
}
};
if (fs.existsSync(this.config.localPath)) {
cleanupDir(this.config.localPath);
}
console.log('✅ Temporary file cleanup completed');
resolve();
} catch (error) {
console.warn('⚠️ Error during temporary file cleanup:', error);
resolve(); // Don't fail the sync for cleanup errors
}
});
}
/**
* Run AWS CLI command with retry logic
*/
private async runAwsCommand(command: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const runCommand = () => {
// Add endpoint URL to the command if it's an S3 command
const commandWithEndpoint = command[0] === 's3' ?
[...command, '--endpoint-url', this.config.endpoint] :
command;
this.currentProcess = spawn('aws', commandWithEndpoint, {
stdio: 'pipe',
env: {
...process.env,
AWS_ACCESS_KEY_ID: this.config.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.config.secretAccessKey,
AWS_DEFAULT_REGION: this.config.region
}
});
this.currentProcess.stdout?.on('data', (data) => {
this.parseAwsOutput(data.toString());
});
this.currentProcess.stderr?.on('data', (data) => {
this.parseAwsOutput(data.toString());
});
this.currentProcess.on('close', (code) => {
if (code === 0) {
console.log('✅ AWS CLI command completed successfully');
this.retryCount = 0; // Reset retry count on success
resolve();
} else if (code === null) {
console.log('⚠️ AWS CLI command was interrupted (SIGINT/SIGTERM)');
resolve(); // Don't treat interruption as an error
} else {
console.error('❌ AWS CLI command failed with code:', code);
// Retry logic
if (this.retryCount < this.maxRetries) {
this.retryCount++;
console.log(`🔄 Retrying command (attempt ${this.retryCount}/${this.maxRetries})...`);
setTimeout(() => runCommand(), 2000); // Wait 2 seconds before retry
} else {
this.retryCount = 0;
reject(new Error(`AWS CLI command failed with exit code ${code} after ${this.maxRetries} retries`));
}
}
});
this.currentProcess.on('error', (error) => {
console.error('❌ Failed to start AWS CLI command:', error);
reject(error);
});
};
runCommand();
});
}
/**
* Parse AWS CLI output for progress and status
*/
private parseAwsOutput(output: string): void {
const lines = output.split('\n');
for (const line of lines) {
if (line.trim()) {
// Emit AWS output to renderer for activity log
this.emit('awsOutput', {
direction: this.getCurrentDirection(),
output: line.trim(),
timestamp: new Date().toISOString()
});
}
// Parse file transfers
const transferMatch = line.match(/(\d+)\/(\d+)/);
if (transferMatch) {
const current = parseInt(transferMatch[1]);
const total = parseInt(transferMatch[2]);
this.filesSynced = current;
this.progressPercent = Math.round((current / total) * 100);
this.progressMessage = `${this.getCurrentDirection()}: ${current}/${total} files`;
this.emitStatusUpdate();
}
// Parse progress messages
if (line.includes('Completed') || line.includes('Done')) {
this.progressMessage = `${this.getCurrentDirection()} completed`;
this.emitStatusUpdate();
}
// Parse deletion messages (important for tracking local deletions)
if (line.includes('delete:') || line.includes('removing') || line.includes('delete')) {
console.log('🗑️ AWS CLI deletion detected:', line);
this.emit('awsOutput', {
direction: this.getCurrentDirection(),
output: `🗑️ Deletion: ${line.trim()}`,
timestamp: new Date().toISOString()
});
}
// Log important messages
if (line.includes('ERROR') || line.includes('Failed') || line.includes('error')) {
console.error('❌ AWS CLI error:', line);
this.emit('syncError', line);
} else if (line.includes('Completed') || line.includes('Transfer')) {
console.log('📊 AWS CLI status:', line);
}
}
}
/**
* Get the current sync direction based on the current phase
*/
private getCurrentDirection(): string {
switch (this.currentPhase) {
case 'downloading':
return 'download';
case 'uploading':
return 'upload';
case 'watching':
return 'watch';
case 'cleaning':
return 'cleanup';
case 'force-downloading':
return 'force-download';
case 'force-uploading':
return 'force-upload';
default:
return 'sync';
}
}
/**
* Get the last sync time
*/
private getLastSyncTime(): Date | null {
// Try to get from ConfigManager first (persistent), fall back to instance variable
if (this.config.configManager && this.config.configManager.getLastSyncTime) {
const persistentTime = this.config.configManager.getLastSyncTime();
if (persistentTime) {
// Update instance variable for consistency
this.lastSyncTime = persistentTime;
return persistentTime;
}
}
return this.lastSyncTime;
}
/**
* Emit status update
*/
private emitStatusUpdate(): void {
const status = this.getStatus();
this.emit('statusChanged', status);
}
/**
* Check if AWS CLI is available on the system
*/
static async checkAwsCliAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const process = spawn('aws', ['--version'], { stdio: 'pipe' });
process.on('close', (code) => {
resolve(code === 0);
});
process.on('error', () => {
resolve(false);
});
});
}
}

View File

@ -0,0 +1,450 @@
import Store from 'electron-store';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as dotenv from 'dotenv';
// Simplified sync configuration interface
export interface SyncConfig {
s3: {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
useSSL: boolean;
};
sync: {
localPath: string;
interval: number;
autoStart: boolean;
conflictResolution: 'local-wins' | 'remote-wins' | 'newer-wins';
};
}
export interface AppConfig {
s3: {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
useSSL: boolean;
};
sync: {
localPath: string;
syncInterval: number; // milliseconds
autoStart: boolean;
conflictResolution: 'local-wins' | 'remote-wins' | 'newer-wins';
lastSyncTime?: string; // ISO string of last successful sync
};
ui: {
theme: 'light' | 'dark' | 'system';
language: string;
notifications: boolean;
minimizeToTray: boolean;
};
}
export class ConfigManager {
private store: Store<AppConfig>;
private configPath: string;
constructor() {
// Load environment variables from .env file
this.loadEnvironmentVariables();
this.store = new Store<AppConfig>({
defaults: this.getDefaultConfig(),
schema: this.getConfigSchema(),
});
this.configPath = this.store.path;
}
/**
* Load environment variables from .env file
*/
private loadEnvironmentVariables(): void {
try {
const envPath = path.join(process.cwd(), '.env');
if (fsSync.existsSync(envPath)) {
dotenv.config({ path: envPath });
console.log('✅ Environment variables loaded from .env file');
} else {
console.log(' No .env file found, using default configuration');
}
} catch (error) {
console.warn('⚠️ Failed to load .env file:', error);
}
}
/**
* Force reload environment variables from .env file
*/
public reloadEnvironmentVariables(): void {
try {
const envPath = path.join(process.cwd(), '.env');
if (fsSync.existsSync(envPath)) {
// Clear any cached env vars
Object.keys(process.env).forEach(key => {
if (key.startsWith('S3_') || key.startsWith('SYNC_') || key.startsWith('UI_')) {
delete process.env[key];
}
});
// Reload from .env
dotenv.config({ path: envPath, override: true });
// Clear the electron-store cache to force reload
this.store.clear();
console.log('✅ Environment variables reloaded from .env file');
console.log('✅ Configuration cache cleared');
}
} catch (error) {
console.warn('⚠️ Failed to reload .env file:', error);
}
}
/**
* Get default configuration
*/
private getDefaultConfig(): AppConfig {
return {
s3: {
endpoint: process.env.S3_ENDPOINT || 'https://garage.geertrademakers.nl',
region: process.env.S3_REGION || 'garage',
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
bucketName: process.env.S3_BUCKET_NAME || 'music',
useSSL: process.env.S3_USE_SSL !== 'false',
},
sync: {
localPath: process.env.SYNC_LOCAL_PATH || this.getDefaultMusicPath(),
syncInterval: parseInt(process.env.SYNC_INTERVAL || '30000'),
autoStart: process.env.SYNC_AUTO_START === 'true',
conflictResolution: (process.env.SYNC_CONFLICT_RESOLUTION as any) || 'newer-wins',
},
ui: {
theme: (process.env.UI_THEME as any) || 'system',
language: process.env.UI_LANGUAGE || 'en',
notifications: process.env.UI_NOTIFICATIONS !== 'false',
minimizeToTray: process.env.UI_MINIMIZE_TO_TRAY !== 'false',
},
};
}
/**
* Get configuration schema for validation
*/
private getConfigSchema(): any {
return {
s3: {
type: 'object',
properties: {
endpoint: { type: 'string', format: 'uri' },
region: { type: 'string', minLength: 1 },
accessKeyId: { type: 'string', minLength: 1 },
secretAccessKey: { type: 'string', minLength: 1 },
bucketName: { type: 'string', minLength: 1 },
useSSL: { type: 'boolean' },
},
required: ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'],
},
sync: {
type: 'object',
properties: {
localPath: { type: 'string', minLength: 1 },
syncInterval: { type: 'number', minimum: 5000, maximum: 300000 },
autoStart: { type: 'boolean' },
conflictResolution: {
type: 'string',
enum: ['local-wins', 'remote-wins', 'newer-wins']
},
},
required: ['localPath', 'syncInterval', 'autoStart', 'conflictResolution'],
},
ui: {
type: 'object',
properties: {
theme: { type: 'string', enum: ['light', 'dark', 'system'] },
language: { type: 'string', minLength: 2 },
notifications: { type: 'boolean' },
minimizeToTray: { type: 'boolean' },
},
required: ['theme', 'language', 'notifications', 'minimizeToTray'],
},
};
}
/**
* Get default music path based on platform
*/
private getDefaultMusicPath(): string {
const os = process.platform;
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
switch (os) {
case 'darwin': // macOS
return path.join(homeDir, 'Music');
case 'win32': // Windows
return path.join(homeDir, 'Music');
case 'linux': // Linux
return path.join(homeDir, 'Music');
default:
return homeDir;
}
}
/**
* Get entire configuration
*/
getConfig(): AppConfig {
return this.store.store;
}
/**
* Get S3 configuration
*/
getS3Config() {
return this.store.get('s3');
}
/**
* Get sync configuration
*/
getSyncConfig() {
return this.store.get('sync');
}
/**
* Get UI configuration
*/
getUIConfig() {
return this.store.get('ui');
}
/**
* Update S3 configuration
*/
updateS3Config(s3Config: Partial<AppConfig['s3']>): void {
const current = this.store.get('s3');
this.store.set('s3', { ...current, ...s3Config });
}
/**
* Update sync configuration
*/
updateSyncConfig(syncConfig: Partial<AppConfig['sync']>): void {
const current = this.store.get('sync');
this.store.set('sync', { ...current, ...syncConfig });
}
/**
* Update UI configuration
*/
updateUIConfig(uiConfig: Partial<AppConfig['ui']>): void {
const current = this.store.get('ui');
this.store.set('ui', { ...current, ...uiConfig });
}
/**
* Get sync configuration for the sync manager
*/
getSyncManagerConfig(): SyncConfig {
const s3Config = this.getS3Config();
const syncConfig = this.getSyncConfig();
return {
s3: {
endpoint: s3Config.endpoint,
region: s3Config.region,
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
bucketName: s3Config.bucketName,
useSSL: s3Config.useSSL,
},
sync: {
localPath: syncConfig.localPath,
interval: syncConfig.syncInterval,
autoStart: syncConfig.autoStart,
conflictResolution: syncConfig.conflictResolution,
}
};
}
/**
* Validate local path exists and is accessible
*/
async validateLocalPath(localPath: string): Promise<{ valid: boolean; error?: string }> {
try {
const stats = await fs.stat(localPath);
if (!stats.isDirectory()) {
return { valid: false, error: 'Path is not a directory' };
}
// Test write access
const testFile = path.join(localPath, '.sync-test');
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
return { valid: true };
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Test S3 connection
*/
async testS3Connection(): Promise<{ success: boolean; error?: string }> {
try {
const { S3Client, ListBucketsCommand } = await import('@aws-sdk/client-s3');
const s3Config = this.getS3Config();
const client = new S3Client({
endpoint: s3Config.endpoint,
region: s3Config.region,
credentials: {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
},
forcePathStyle: true,
});
const command = new ListBucketsCommand({});
await client.send(command);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Get the last sync time
*/
getLastSyncTime(): Date | null {
const config = this.getConfig();
if (config.sync.lastSyncTime) {
try {
return new Date(config.sync.lastSyncTime);
} catch (error) {
console.warn('⚠️ Invalid last sync time format:', config.sync.lastSyncTime);
return null;
}
}
return null;
}
/**
* Set the last sync time
*/
setLastSyncTime(date: Date): void {
const config = this.getConfig();
config.sync.lastSyncTime = date.toISOString();
this.store.set('sync.lastSyncTime', config.sync.lastSyncTime);
console.log('💾 Last sync time saved:', date.toISOString());
}
/**
* Reset configuration to defaults
*/
resetToDefaults(): void {
this.store.clear();
this.store.store = this.getDefaultConfig();
}
/**
* Get configuration file path
*/
getConfigPath(): string {
return this.configPath;
}
/**
* Export configuration to file
*/
async exportConfig(exportPath: string): Promise<void> {
const config = this.getConfig();
await fs.writeFile(exportPath, JSON.stringify(config, null, 2));
}
/**
* Export configuration to .env file
*/
async exportToEnv(exportPath: string): Promise<void> {
const config = this.getConfig();
const envContent = [
'# Rekordbox Sync Desktop Application Configuration',
'# Generated on ' + new Date().toISOString(),
'',
'# S3 Configuration',
`S3_ENDPOINT=${config.s3.endpoint}`,
`S3_REGION=${config.s3.region}`,
`S3_ACCESS_KEY_ID=${config.s3.accessKeyId}`,
`S3_SECRET_ACCESS_KEY=${config.s3.secretAccessKey}`,
`S3_BUCKET_NAME=${config.s3.bucketName}`,
`S3_USE_SSL=${config.s3.useSSL}`,
'',
'# Sync Configuration',
`SYNC_LOCAL_PATH=${config.sync.localPath}`,
`SYNC_INTERVAL=${config.sync.syncInterval}`,
`SYNC_AUTO_START=${config.sync.autoStart}`,
`SYNC_CONFLICT_RESOLUTION=${config.sync.conflictResolution}`,
'',
'# UI Configuration',
`UI_THEME=${config.ui.theme}`,
`UI_LANGUAGE=${config.ui.language}`,
`UI_NOTIFICATIONS=${config.ui.notifications}`,
`UI_MINIMIZE_TO_TRAY=${config.ui.minimizeToTray}`,
].join('\n');
await fs.writeFile(exportPath, envContent);
}
/**
* Import configuration from file
*/
async importConfig(importPath: string): Promise<{ success: boolean; error?: string }> {
try {
const configData = await fs.readFile(importPath, 'utf-8');
const config = JSON.parse(configData);
// Validate imported config
if (this.validateConfig(config)) {
this.store.store = config;
return { success: true };
} else {
return { success: false, error: 'Invalid configuration format' };
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Validate configuration object
*/
private validateConfig(config: any): config is AppConfig {
// Basic validation - in production you might want more thorough validation
return (
config &&
typeof config === 'object' &&
config.s3 &&
config.sync &&
config.ui
);
}
}

View File

@ -0,0 +1,234 @@
import * as chokidar from 'chokidar';
import * as path from 'path';
import * as crypto from 'crypto';
import { EventEmitter } from 'events';
// Simple sync operation interface
export interface SyncOperation {
type: 'upload' | 'download' | 'delete';
localPath?: string;
s3Key?: string;
error?: string;
action?: string;
id?: string;
}
export interface FileChangeEvent {
type: 'add' | 'change' | 'unlink' | 'unlinkDir';
path: string;
relativePath: string;
isDirectory: boolean;
}
export class FileWatcher extends EventEmitter {
private watcher: chokidar.FSWatcher | null = null;
private localPath: string;
private isWatching: boolean = false;
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
private readonly DEBOUNCE_DELAY = 1000; // 1 second debounce
constructor(localPath: string) {
super();
this.localPath = localPath;
}
/**
* Start watching the local directory
*/
start(): Promise<void> {
if (this.isWatching) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
try {
this.watcher = chokidar.watch(this.localPath, {
persistent: true,
ignoreInitial: true,
ignored: [
/(^|[\/\\])\../, // Ignore hidden files
/node_modules/, // Ignore node_modules
/\.DS_Store$/, // Ignore macOS system files
/Thumbs\.db$/, // Ignore Windows system files
/\.tmp$/, // Ignore temporary files
/\.temp$/, // Ignore temporary files
/\.log$/, // Ignore log files
],
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
});
this.setupEventHandlers();
this.isWatching = true;
this.emit('started');
resolve();
} catch (error) {
reject(error);
}
});
}
/**
* Stop watching the local directory
*/
stop(): void {
if (!this.isWatching || !this.watcher) {
return;
}
this.watcher.close();
this.watcher = null;
this.isWatching = false;
// Clear all debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
this.emit('stopped');
}
/**
* Setup event handlers for file system changes
*/
private setupEventHandlers(): void {
if (!this.watcher) return;
// File added
this.watcher.on('add', (filePath: string) => {
this.handleFileChange('add', filePath);
});
// File changed
this.watcher.on('change', (filePath: string) => {
this.handleFileChange('change', filePath);
});
// File removed
this.watcher.on('unlink', (filePath: string) => {
this.handleFileChange('unlink', filePath);
});
// Directory removed
this.watcher.on('unlinkDir', (dirPath: string) => {
this.handleFileChange('unlinkDir', dirPath);
});
// Error handling
this.watcher.on('error', (error: Error) => {
this.emit('error', error);
});
// Ready event
this.watcher.on('ready', () => {
this.emit('ready');
});
}
/**
* Handle file change events with debouncing
*/
private handleFileChange(type: 'add' | 'change' | 'unlink' | 'unlinkDir', filePath: string): void {
const relativePath = path.relative(this.localPath, filePath);
const key = `${type}:${relativePath}`;
// Clear existing timer for this change
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key)!);
}
// Set new debounce timer
const timer = setTimeout(() => {
this.debounceTimers.delete(key);
this.processFileChange(type, filePath, relativePath);
}, this.DEBOUNCE_DELAY);
this.debounceTimers.set(key, timer);
}
/**
* Process the file change after debouncing
*/
private processFileChange(type: 'add' | 'change' | 'unlink' | 'unlinkDir', filePath: string, relativePath: string): void {
const isDirectory = type === 'unlinkDir';
const event: FileChangeEvent = {
type,
path: filePath,
relativePath,
isDirectory,
};
this.emit('fileChanged', event);
// Convert to sync operation
const syncOperation = this.createSyncOperation(event);
if (syncOperation) {
this.emit('syncOperation', syncOperation);
}
}
/**
* Create sync operation from file change event
*/
private createSyncOperation(event: FileChangeEvent): SyncOperation | null {
switch (event.type) {
case 'add':
case 'change':
if (!event.isDirectory) {
return {
type: 'upload',
localPath: event.path,
s3Key: event.relativePath,
action: 'pending',
id: crypto.randomUUID()
};
}
break;
case 'unlink':
case 'unlinkDir':
return {
type: 'delete',
s3Key: event.relativePath,
action: 'pending',
id: crypto.randomUUID()
};
}
return null;
}
/**
* Check if the watcher is currently active
*/
isActive(): boolean {
return this.isWatching && this.watcher !== null;
}
/**
* Get the current local path being watched
*/
getLocalPath(): string {
return this.localPath;
}
/**
* Update the local path being watched
*/
updateLocalPath(newPath: string): void {
if (this.isWatching) {
this.stop();
}
this.localPath = newPath;
if (this.isWatching) {
this.start();
}
}
}

View File

@ -0,0 +1,501 @@
import { EventEmitter } from 'events';
import { AwsS3Service, AwsS3Config } from './awsS3Service';
import { FileWatcher } from './fileWatcher';
export interface SyncState {
isRunning: boolean;
isPaused: boolean;
currentPhase: 'idle' | 'starting' | 'downloading' | 'uploading' | 'watching' | 'stopping' | 'error' | 'completed';
progress: {
percent: number;
message: string;
filesProcessed: number;
totalFiles: number;
transferSpeed: string;
eta: string;
};
lastSync: Date | null;
lastError: string | null;
stats: {
totalFilesSynced: number;
filesDownloaded: number;
filesUploaded: number;
bytesTransferred: number;
syncDuration: number;
};
}
export interface SyncConfig {
s3: {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
useSSL: boolean;
};
sync: {
localPath: string;
interval: number;
autoStart: boolean;
conflictResolution: string;
};
}
export class SyncManager extends EventEmitter {
private awsS3Service: AwsS3Service | null = null;
private fileWatcher: FileWatcher | null = null;
private config: SyncConfig;
private state: SyncState;
private configManager: any; // Reference to ConfigManager for persistent storage
constructor(config: SyncConfig, configManager?: any) {
super();
this.config = config;
this.configManager = configManager;
this.state = this.getInitialState();
}
private getInitialState(): SyncState {
return {
isRunning: false,
isPaused: false,
currentPhase: 'idle',
progress: {
percent: 0,
message: 'Ready to sync',
filesProcessed: 0,
totalFiles: 0,
transferSpeed: '',
eta: ''
},
lastSync: null,
lastError: null,
stats: {
totalFilesSynced: 0,
filesDownloaded: 0,
filesUploaded: 0,
bytesTransferred: 0,
syncDuration: 0
}
};
}
async startSync(): Promise<void> {
if (this.state.isRunning) {
throw new Error('Sync is already running');
}
try {
this.updateState({
isRunning: true,
currentPhase: 'starting',
progress: { ...this.state.progress, message: 'Initializing sync...', percent: 0 }
});
const awsS3Config: AwsS3Config = {
endpoint: this.config.s3.endpoint,
region: this.config.s3.region,
accessKeyId: this.config.s3.accessKeyId,
secretAccessKey: this.config.s3.secretAccessKey,
bucketName: this.config.s3.bucketName,
useSSL: this.config.s3.useSSL,
localPath: this.config.sync.localPath,
configManager: this.configManager
};
this.awsS3Service = new AwsS3Service(awsS3Config);
this.setupAwsS3EventHandlers();
this.fileWatcher = new FileWatcher(this.config.sync.localPath);
this.setupFileWatcherEventHandlers();
await this.awsS3Service.startSync();
this.fileWatcher.start();
// Check if AWS S3 service is actually running
if (this.awsS3Service && this.awsS3Service.getStatus().isRunning) {
this.updateState({
currentPhase: 'watching',
progress: { ...this.state.progress, message: 'Continuous sync active', percent: 100 }
});
this.emit('syncStarted');
} else {
throw new Error('MinIO service failed to start properly');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Clean up services on error
if (this.awsS3Service) {
try {
this.awsS3Service.stopSync();
} catch (cleanupError) {
console.warn('⚠️ Error cleaning up AWS S3 service:', cleanupError);
}
}
if (this.fileWatcher) {
try {
this.fileWatcher.stop();
} catch (cleanupError) {
console.warn('⚠️ Error cleaning up file watcher:', cleanupError);
}
}
this.updateState({
isRunning: false,
currentPhase: 'error',
lastError: errorMessage,
progress: { ...this.state.progress, message: `Failed to start: ${errorMessage}` }
});
this.emit('syncError', errorMessage);
throw error;
}
}
async stopSync(): Promise<void> {
if (!this.state.isRunning) return;
try {
this.updateState({
currentPhase: 'stopping',
progress: { ...this.state.progress, message: 'Stopping sync...' }
});
if (this.awsS3Service) {
this.awsS3Service.stopSync();
}
if (this.fileWatcher) {
this.fileWatcher.stop();
}
this.updateState({
isRunning: false,
currentPhase: 'idle',
progress: { ...this.state.progress, message: 'Sync stopped', percent: 0 }
});
this.emit('syncStopped');
} catch (error) {
console.error('❌ Error stopping sync:', error);
this.updateState({
currentPhase: 'error',
lastError: error instanceof Error ? error.message : 'Unknown error'
});
}
}
/**
* Trigger an immediate sync to propagate local changes (including deletions)
*/
async triggerImmediateSync(): Promise<void> {
if (!this.state.isRunning) {
return;
}
try {
if (this.awsS3Service) {
await this.awsS3Service.triggerImmediateSync();
}
} catch (error) {
console.error('❌ Immediate sync failed:', error);
this.emit('syncError', error);
}
}
async forceFullSync(): Promise<void> {
if (this.state.isRunning) {
await this.stopSync();
await new Promise(resolve => setTimeout(resolve, 2000));
}
try {
this.updateState({
currentPhase: 'starting',
progress: { ...this.state.progress, message: 'Preparing force sync...', percent: 0 }
});
const awsS3Config: AwsS3Config = {
endpoint: this.config.s3.endpoint,
region: this.config.s3.region,
accessKeyId: this.config.s3.accessKeyId,
secretAccessKey: this.config.s3.secretAccessKey,
bucketName: this.config.s3.bucketName,
useSSL: this.config.s3.useSSL,
localPath: this.config.sync.localPath,
configManager: this.configManager
};
const forceSyncService = new AwsS3Service(awsS3Config);
forceSyncService.on('statusChanged', (status: any) => {
this.handleAwsS3StatusChange(status, 'force-sync');
});
await forceSyncService.forceFullSync();
this.updateState({
currentPhase: 'completed',
progress: { ...this.state.progress, message: 'Force sync completed!', percent: 100 },
lastSync: new Date()
});
this.emit('forceSyncCompleted');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.updateState({
currentPhase: 'error',
lastError: errorMessage,
progress: { ...this.state.progress, message: `Force sync failed: ${errorMessage}` }
});
this.emit('syncError', errorMessage);
throw error;
}
}
getState(): SyncState {
return { ...this.state };
}
private updateState(updates: Partial<SyncState>): void {
this.state = { ...this.state, ...updates };
this.emit('stateChanged', this.state);
}
private setupAwsS3EventHandlers(): void {
if (!this.awsS3Service) return;
this.awsS3Service.on('statusChanged', (status: any) => {
this.handleAwsS3StatusChange(status, 'normal');
});
this.awsS3Service.on('syncError', (error: any) => {
this.handleAwsS3Error(error);
});
this.awsS3Service.on('awsOutput', (output: any) => {
this.emit('awsOutput', output);
});
}
private setupFileWatcherEventHandlers(): void {
if (!this.fileWatcher) return;
this.fileWatcher.on('fileChanged', (event: any) => {
// Check if this is a deletion event
if (event && event.type === 'unlink') {
console.log('🗑️ File deletion detected in fileChanged event:', event);
// Add a small delay to ensure file system has processed the deletion
setTimeout(() => {
console.log('🚀 Triggering immediate sync after file deletion...');
this.triggerImmediateSync();
}, 1000); // 1 second delay
}
this.emit('fileChanged', event);
});
this.fileWatcher.on('fileAdded', (filePath: string) => {
this.emit('fileAdded', filePath);
});
this.fileWatcher.on('fileRemoved', (filePath: string) => {
this.emit('fileRemoved', filePath);
});
}
private handleAwsS3StatusChange(status: any, syncType: 'normal' | 'force-sync'): void {
let phase: SyncState['currentPhase'] = 'idle';
let message = 'Ready';
let percent = 0;
switch (status.currentPhase) {
case 'starting':
phase = 'starting';
message = 'Initializing sync...';
percent = 10;
break;
case 'downloading':
phase = 'downloading';
const localCount = this.getActualLocalFileCount();
message = `Downloading files from S3 (Local: ${localCount} files)`;
percent = 30;
break;
case 'uploading':
phase = 'uploading';
const localCount2 = this.getActualLocalFileCount();
message = `Uploading files to S3 (Local: ${localCount2} files)`;
percent = 60;
break;
case 'watching':
phase = 'watching';
message = 'Continuous bidirectional sync active';
percent = 100;
break;
case 'completed':
phase = 'completed';
message = 'Sync completed successfully!';
percent = 100;
break;
case 'error':
phase = 'error';
message = `Sync error: ${status.progressMessage || 'Unknown error'}`;
break;
}
const progress = {
percent,
message,
filesProcessed: status.filesSynced || 0,
totalFiles: status.filesSynced || 0,
transferSpeed: status.transferSpeed || '',
eta: status.eta || ''
};
if (syncType === 'normal') {
// Get actual file count from local folder instead of inflating the counter
const actualFileCount = this.getActualLocalFileCount();
const stats = {
totalFilesSynced: actualFileCount,
filesDownloaded: actualFileCount, // For now, assume all files are downloaded
filesUploaded: 0,
bytesTransferred: this.state.stats.bytesTransferred,
syncDuration: this.state.stats.syncDuration
};
this.updateState({
currentPhase: phase,
progress,
stats,
lastError: phase === 'error' ? status.progressMessage : null
});
} else {
this.updateState({
currentPhase: phase,
progress,
lastError: phase === 'error' ? status.progressMessage : null
});
}
}
private handleAwsS3Error(error: any): void {
const errorMessage = typeof error === 'string' ? error : 'Unknown MinIO error';
this.updateState({
currentPhase: 'error',
lastError: errorMessage,
progress: { ...this.state.progress, message: `Error: ${errorMessage}` }
});
this.emit('syncError', errorMessage);
}
updateConfig(newConfig: SyncConfig): void {
this.config = newConfig;
if (this.state.isRunning) {
this.stopSync().then(() => {
setTimeout(() => this.startSync(), 1000);
});
}
}
/**
* Get actual file count from local sync folder
*/
private getActualLocalFileCount(): number {
try {
const fs = require('fs');
const path = require('path');
let fileCount = 0;
const countFiles = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
countFiles(fullPath);
} else if (stat.isFile()) {
// Only count music files
const ext = path.extname(item).toLowerCase();
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
fileCount++;
}
}
}
} catch (error) {
console.warn(`⚠️ Error counting files in ${dirPath}:`, error);
}
};
if (fs.existsSync(this.config.sync.localPath)) {
countFiles(this.config.sync.localPath);
}
return fileCount;
} catch (error) {
console.error('❌ Error getting actual file count:', error);
return 0;
}
}
/**
* Get S3 bucket file count for comparison
*/
private async getS3FileCount(): Promise<number> {
try {
const { spawn } = require('child_process');
return new Promise((resolve) => {
// Use AWS CLI to list objects in the bucket
const awsProcess = spawn('aws', [
's3', 'ls',
`s3://${this.config.s3.bucketName}/`,
'--recursive',
'--endpoint-url', this.config.s3.endpoint
], {
stdio: 'pipe',
env: {
...process.env,
AWS_ACCESS_KEY_ID: this.config.s3.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.config.s3.secretAccessKey,
AWS_DEFAULT_REGION: this.config.s3.region
}
});
let output = '';
let fileCount = 0;
awsProcess.stdout?.on('data', (data: Buffer) => {
output += data.toString();
});
awsProcess.on('close', () => {
// Count lines that represent files (not directories)
const lines = output.split('\n').filter(line => line.trim());
fileCount = lines.length;
resolve(fileCount);
});
awsProcess.on('error', () => {
resolve(0);
});
});
} catch (error) {
console.error('❌ Error getting S3 file count:', error);
return 0;
}
}
destroy(): void {
this.stopSync();
this.removeAllListeners();
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}