Compare commits

..

4 Commits

Author SHA1 Message Date
Geert Rademakes
11c714124b fix: Fix critical syntax error in renderer.js that was blocking UI updates - Remove extra closing brace in updateSyncStatus method - Fix 'Unexpected token this' error that prevented renderer execution - Restore real-time sync status display in UI - Sync functionality was working perfectly, only UI rendering was broken 2025-08-28 13:45:24 +02:00
Geert Rademakes
b6e961dc84 feat: Fix header button functionality and improve UI conciseness - Add working minimize/close/settings buttons in header - Implement window control IPC handlers - Simplify status display (Status, Files, Mode instead of verbose text) - Remove unused lastSync element references - Add window control channels to preload script 2025-08-28 11:42:27 +02:00
Geert Rademakes
39b7fb59aa feat: Improve UI spacing and layout - Increase window size from 1200x800 to 1400x900 - Add more padding and gaps between UI elements - Improve button sizing and spacing - Better status panel layout with flex distribution - Enhanced activity log readability - Remove unnecessary start/stop buttons (auto-sync only) - Remove progress bar (not needed for continuous sync) - Clean up unused sync methods 2025-08-28 11:38:14 +02:00
Geert Rademakes
73d9a41ca8 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 2025-08-28 11:26:18 +02:00
54 changed files with 5207 additions and 3071 deletions

View File

@ -9,8 +9,6 @@ A web application for reading, managing, and exporting Rekordbox XML files. This
- **Playlist Management**: Create, edit, and organize playlists and folders
- **Song Details**: View detailed information about tracks including BPM, key, rating, etc.
- **Export Functionality**: Export modified libraries back to XML format
- **Music File Storage**: Upload and stream music files with multiple storage providers
- **Storage Providers**: Support for S3-compatible storage (AWS S3, MinIO) and WebDAV (Nextcloud, ownCloud)
- **Responsive Design**: Works on desktop and mobile devices
- **Database Storage**: Persistent storage using MongoDB
@ -132,34 +130,6 @@ The frontend is configured to connect to the backend API. The API URL can be con
- Development: `packages/frontend/src/services/api.ts`
- Production: Environment variable `VITE_API_URL` in Docker
### Storage Configuration
The application supports multiple storage providers for music files:
#### S3-Compatible Storage (AWS S3, MinIO)
```env
STORAGE_PROVIDER=s3
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET_NAME=music-files
S3_REGION=us-east-1
S3_USE_SSL=false
```
#### WebDAV (Nextcloud, ownCloud)
```env
STORAGE_PROVIDER=webdav
WEBDAV_URL=https://your-nextcloud.com/remote.php/dav/files/username/
WEBDAV_USERNAME=your-username
WEBDAV_PASSWORD=your-password-or-app-password
WEBDAV_BASE_PATH=/music-files
```
You can also configure storage through the web interface at **Configuration → Storage Configuration**.
For detailed setup instructions, see [WEBDAV_INTEGRATION.md](./WEBDAV_INTEGRATION.md).
## 📊 API Endpoints
- `GET /api/health` - Health check

View File

@ -1,258 +0,0 @@
# WebDAV Integration for Rekordbox Reader
This document describes the WebDAV integration that allows Rekordbox Reader to work with self-hosted storage solutions like Nextcloud and ownCloud.
## 🎯 Overview
The application now supports two storage providers:
- **S3-Compatible Storage** (AWS S3, MinIO, etc.)
- **WebDAV** (Nextcloud, ownCloud, etc.)
Users can choose between these providers in the Storage Configuration page.
## 🏗️ Architecture
### Storage Provider Abstraction
The system uses a provider abstraction pattern:
```
StorageProvider Interface
├── S3Service (implements StorageProvider)
└── WebDAVService (implements StorageProvider)
```
### Key Components
1. **StorageProvider Interface** (`src/services/storageProvider.ts`)
- Defines common operations for all storage providers
- Factory pattern for creating providers
- Configuration loading and validation
2. **WebDAVService** (`src/services/webdavService.ts`)
- Implements WebDAV operations using the `webdav` npm package
- Supports Nextcloud, ownCloud, and other WebDAV-compatible servers
- Handles file upload, download, listing, and deletion
3. **Updated Configuration System**
- New `/api/config/storage` endpoints
- Support for both S3 and WebDAV configuration
- Backward compatibility with existing S3 configuration
4. **Frontend Storage Configuration**
- Provider selection (S3 vs WebDAV)
- Dynamic form fields based on selected provider
- Connection testing for both providers
## 🚀 Setup Instructions
### 1. Backend Configuration
The backend automatically detects the storage provider from the configuration file:
**File: `storage-config.json`**
```json
{
"provider": "webdav",
"url": "https://your-nextcloud.com/remote.php/dav/files/username/",
"username": "your-username",
"password": "your-password-or-app-password",
"basePath": "/music-files"
}
```
### 2. Environment Variables
You can also configure WebDAV using environment variables:
```bash
STORAGE_PROVIDER=webdav
WEBDAV_URL=https://your-nextcloud.com/remote.php/dav/files/username/
WEBDAV_USERNAME=your-username
WEBDAV_PASSWORD=your-password-or-app-password
WEBDAV_BASE_PATH=/music-files
```
### 3. Frontend Configuration
1. Navigate to **Configuration → Storage Configuration**
2. Select **WebDAV** as the storage provider
3. Fill in your WebDAV server details:
- **URL**: Your Nextcloud/ownCloud WebDAV URL
- **Username**: Your account username
- **Password**: Your password or app password
- **Base Path**: Optional subfolder for music files
4. Click **Test Connection** to verify
5. Click **Save Configuration** to apply
## 🔧 Nextcloud Setup
### 1. Enable WebDAV
WebDAV is enabled by default in Nextcloud. You can verify this in:
- **Settings → Administration → Basic settings**
### 2. Create App Password (Recommended)
For better security, create an app password:
1. Go to **Settings → Personal → Security**
2. Scroll down to **App passwords**
3. Create a new app password for "Rekordbox Reader"
4. Use this password instead of your main password
### 3. Get WebDAV URL
Your WebDAV URL follows this pattern:
```
https://your-nextcloud.com/remote.php/dav/files/username/
```
Replace:
- `your-nextcloud.com` with your Nextcloud domain
- `username` with your Nextcloud username
### 4. Create Music Folder (Optional)
You can create a dedicated folder for music files:
1. In Nextcloud, create a folder called `music-files`
2. Set this as the `basePath` in the configuration
## 🔧 ownCloud Setup
The setup is similar to Nextcloud:
1. Enable WebDAV in ownCloud settings
2. Create an app password for security
3. Use the WebDAV URL: `https://your-owncloud.com/remote.php/dav/files/username/`
4. Configure the same way as Nextcloud
## 🧪 Testing
### Backend Test
Run the WebDAV test script:
```bash
cd packages/backend
node test-webdav.js
```
Make sure to update the configuration in the test file first.
### Frontend Test
1. Go to **Configuration → Storage Configuration**
2. Select **WebDAV** provider
3. Enter your WebDAV details
4. Click **Test Connection**
5. Verify the connection is successful
## 📁 File Operations
The WebDAV service supports all standard file operations:
- **Upload**: Upload music files to WebDAV storage
- **Download**: Download files for playback
- **List**: List all files and folders
- **Delete**: Remove files from storage
- **Metadata**: Get file information
- **Streaming**: Generate streaming URLs
## 🔒 Security Considerations
1. **Use App Passwords**: Don't use your main Nextcloud/ownCloud password
2. **HTTPS Only**: Always use HTTPS URLs for WebDAV connections
3. **Base Path**: Use a dedicated folder for music files
4. **Permissions**: Ensure the WebDAV user has appropriate permissions
## 🐛 Troubleshooting
### Common Issues
1. **Connection Failed**
- Verify the WebDAV URL is correct
- Check username and password
- Ensure WebDAV is enabled on your server
2. **Permission Denied**
- Check if the user has write permissions
- Verify the base path exists and is accessible
3. **SSL/TLS Errors**
- Ensure you're using HTTPS
- Check if the SSL certificate is valid
4. **File Upload Fails**
- Check available storage space
- Verify file permissions
- Ensure the file format is supported
### Debug Mode
Enable debug logging by setting:
```bash
DEBUG=webdav:*
```
## 🔄 Migration from S3
If you're migrating from S3 to WebDAV:
1. Export your current configuration
2. Set up WebDAV storage
3. Update the configuration to use WebDAV
4. The application will automatically use the new storage provider
## 📊 Performance
WebDAV performance depends on:
- Network latency to your server
- Server performance and storage type
- File sizes and concurrent operations
For best performance:
- Use a local or fast Nextcloud/ownCloud instance
- Consider using SSD storage
- Optimize your network connection
## 🎵 Supported File Formats
The WebDAV integration supports all audio formats supported by the application:
- MP3
- WAV
- FLAC
- M4A
- AAC
- OGG
- OPUS
- WMA
## 📝 API Endpoints
### Storage Configuration
- `GET /api/config/storage` - Get current storage configuration
- `POST /api/config/storage` - Save storage configuration
- `POST /api/config/storage/test` - Test storage connection
### Legacy S3 Endpoints
The following endpoints are still available for backward compatibility:
- `GET /api/config/s3` - Get S3 configuration
- `POST /api/config/s3` - Save S3 configuration
- `POST /api/config/s3/test` - Test S3 connection
## 🤝 Contributing
When adding new storage providers:
1. Implement the `StorageProvider` interface
2. Add the provider to `StorageProviderFactory`
3. Update the frontend configuration UI
4. Add appropriate tests
5. Update this documentation
## 📄 License
This WebDAV integration follows the same license as the main Rekordbox Reader project.

270
package-lock.json generated
View File

@ -1180,14 +1180,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@buttercup/fetch": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz",
"integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==",
"optionalDependencies": {
"node-fetch": "^3.3.0"
}
},
"node_modules/@chakra-ui/accordion": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@chakra-ui/accordion/-/accordion-2.3.1.tgz",
@ -4269,13 +4261,9 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -4440,11 +4428,6 @@
"node": ">=10.16.0"
}
},
"node_modules/byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz",
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -4527,14 +4510,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"engines": {
"node": "*"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -4741,14 +4716,6 @@
"node": ">= 8"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"engines": {
"node": "*"
}
},
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
@ -4763,14 +4730,6 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"engines": {
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@ -4883,17 +4842,6 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -5336,28 +5284,6 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -5496,17 +5422,6 @@
"node": ">=10"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -5760,11 +5675,6 @@
"react-is": "^16.7.0"
}
},
"node_modules/hot-patcher": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz",
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@ -5865,11 +5775,6 @@
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -6023,11 +5928,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/layerr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz",
"integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -6111,16 +6011,6 @@
"node": ">= 0.4"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -6429,47 +6319,6 @@
"node": ">= 0.6"
}
},
"node_modules/nested-property": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz",
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -6631,11 +6480,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-posix": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@ -6780,11 +6624,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -7100,11 +6939,6 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -7841,23 +7675,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-join": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz",
"integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@ -8004,88 +7821,6 @@
}
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"engines": {
"node": ">= 8"
}
},
"node_modules/webdav": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.8.0.tgz",
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"dependencies": {
"@buttercup/fetch": "^0.2.1",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"entities": "^6.0.0",
"fast-xml-parser": "^4.5.1",
"hot-patcher": "^2.0.1",
"layerr": "^3.0.0",
"md5": "^2.3.0",
"minimatch": "^9.0.5",
"nested-property": "^4.0.0",
"node-fetch": "^3.3.2",
"path-posix": "^1.0.0",
"url-join": "^5.0.0",
"url-parse": "^1.5.10"
},
"engines": {
"node": ">=14"
}
},
"node_modules/webdav/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/webdav/node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/webdav/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/webdav/node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@ -8268,8 +8003,7 @@
"mongoose": "^8.2.1",
"multer": "^2.0.0-rc.3",
"music-metadata": "^8.1.0",
"uuid": "^11.1.0",
"webdav": "^5.3.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",

View File

@ -1,116 +0,0 @@
import { createClient } from 'webdav';
import fs from 'fs';
async function debugMP3Files() {
try {
// Load configuration
const configData = fs.readFileSync('storage-config.json', 'utf-8');
const config = JSON.parse(configData);
console.log('🔍 WebDAV Configuration:');
console.log('URL:', config.url);
console.log('Username:', config.username);
console.log('Base Path:', config.basePath);
console.log('');
// Create WebDAV client
const client = createClient(config.url, {
username: config.username,
password: config.password,
});
console.log('🔗 Testing connection...');
const basePath = config.basePath || '/Music';
try {
// Test deep listing
console.log('📁 Testing deep directory listing for MP3 files...');
const deepContents = await client.getDirectoryContents(basePath, { deep: true });
console.log('Deep listing response type:', typeof deepContents);
console.log('Is array:', Array.isArray(deepContents));
if (Array.isArray(deepContents)) {
console.log('Total items found:', deepContents.length);
// Filter for MP3 files specifically
const mp3Files = deepContents.filter(item => {
if (item && typeof item === 'object' && 'type' in item && item.type === 'file') {
const filename = item.basename || item.filename.split('/').pop() || '';
return filename.toLowerCase().endsWith('.mp3');
}
return false;
});
console.log('🎵 MP3 Files found:', mp3Files.length);
console.log('');
// Show first 20 MP3 files
console.log('🎵 First 20 MP3 files:');
mp3Files.slice(0, 20).forEach((file, index) => {
const relativePath = file.filename.replace(basePath + '/', '');
console.log(` ${index + 1}. ${relativePath} (${file.size} bytes)`);
});
if (mp3Files.length > 20) {
console.log(` ... and ${mp3Files.length - 20} more MP3 files`);
}
// Check if there are any patterns in the file paths
console.log('');
console.log('📁 Directory distribution of MP3 files:');
const dirCounts = new Map();
mp3Files.forEach(file => {
const relativePath = file.filename.replace(basePath + '/', '');
const dir = relativePath.split('/')[0];
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
});
const sortedDirs = Array.from(dirCounts.entries()).sort((a, b) => b[1] - a[1]);
sortedDirs.forEach(([dir, count]) => {
console.log(` 📁 ${dir}: ${count} MP3 files`);
});
// Test if there's a limit by checking specific directories
console.log('');
console.log('🔍 Testing specific directories for MP3 files...');
const testDirs = ['Gekocht', 'Merijn Music', 'Musica'];
for (const testDir of testDirs) {
try {
const dirPath = `${basePath}/${testDir}`;
const dirContents = await client.getDirectoryContents(dirPath, { deep: true });
const dirItems = Array.isArray(dirContents) ? dirContents : [dirContents];
const dirMp3Files = dirItems.filter(item => {
if (item && typeof item === 'object' && 'type' in item && item.type === 'file') {
const filename = item.basename || item.filename.split('/').pop() || '';
return filename.toLowerCase().endsWith('.mp3');
}
return false;
});
console.log(` 📁 ${testDir}: ${dirMp3Files.length} MP3 files`);
} catch (error) {
console.log(`${testDir}: Error - ${error.message}`);
}
}
} else {
console.log('❌ Deep listing returned non-array response:', deepContents);
}
} catch (error) {
console.error('❌ Error during WebDAV operations:', error);
console.error('Error details:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
}
} catch (error) {
console.error('❌ Failed to load configuration or create client:', error);
}
}
// Run the debug function
debugMP3Files().catch(console.error);

View File

@ -1,122 +0,0 @@
import { createClient } from 'webdav';
import fs from 'fs';
async function debugWebDAVFiles() {
try {
// Load configuration
const configData = fs.readFileSync('storage-config.json', 'utf-8');
const config = JSON.parse(configData);
console.log('🔍 WebDAV Configuration:');
console.log('URL:', config.url);
console.log('Username:', config.username);
console.log('Base Path:', config.basePath);
console.log('');
// Create WebDAV client
const client = createClient(config.url, {
username: config.username,
password: config.password,
});
console.log('🔗 Testing connection...');
const basePath = config.basePath || '/Music';
try {
const baseContents = await client.getDirectoryContents(basePath);
console.log('✅ Connection successful');
console.log('Base directory contents count:', Array.isArray(baseContents) ? baseContents.length : 1);
console.log('');
// Test deep listing
console.log('📁 Testing deep directory listing...');
const deepContents = await client.getDirectoryContents(basePath, { deep: true });
console.log('Deep listing response type:', typeof deepContents);
console.log('Is array:', Array.isArray(deepContents));
if (Array.isArray(deepContents)) {
console.log('Total items found:', deepContents.length);
// Count files vs directories
let fileCount = 0;
let dirCount = 0;
const fileExtensions = new Map();
const directories = new Set();
for (const item of deepContents) {
if (item && typeof item === 'object' && 'type' in item) {
if (item.type === 'file') {
fileCount++;
// Track file extensions
const ext = item.basename?.split('.').pop()?.toLowerCase() || 'unknown';
fileExtensions.set(ext, (fileExtensions.get(ext) || 0) + 1);
} else if (item.type === 'directory') {
dirCount++;
const relativePath = item.filename.replace(basePath + '/', '');
if (relativePath) {
directories.add(relativePath);
}
}
}
}
console.log('📊 File Statistics:');
console.log('Files:', fileCount);
console.log('Directories:', dirCount);
console.log('Total items:', fileCount + dirCount);
console.log('');
console.log('📁 Directory structure (first 20):');
const sortedDirs = Array.from(directories).sort();
sortedDirs.slice(0, 20).forEach(dir => {
console.log(' 📁', dir);
});
if (sortedDirs.length > 20) {
console.log(` ... and ${sortedDirs.length - 20} more directories`);
}
console.log('');
console.log('🎵 File extensions:');
const sortedExts = Array.from(fileExtensions.entries()).sort((a, b) => b[1] - a[1]);
sortedExts.forEach(([ext, count]) => {
console.log(` .${ext}: ${count} files`);
});
console.log('');
// Show some sample files
console.log('🎵 Sample files (first 10):');
const sampleFiles = deepContents
.filter(item => item && typeof item === 'object' && 'type' in item && item.type === 'file')
.slice(0, 10);
sampleFiles.forEach((file, index) => {
const relativePath = file.filename.replace(basePath + '/', '');
console.log(` ${index + 1}. ${relativePath} (${file.size} bytes)`);
});
if (fileCount > 10) {
console.log(` ... and ${fileCount - 10} more files`);
}
} else {
console.log('❌ Deep listing returned non-array response:', deepContents);
}
} catch (error) {
console.error('❌ Error during WebDAV operations:', error);
console.error('Error details:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
}
} catch (error) {
console.error('❌ Failed to load configuration or create client:', error);
}
}
// Run the debug function
debugWebDAVFiles().catch(console.error);

View File

@ -17,8 +17,7 @@
"mongoose": "^8.2.1",
"multer": "^2.0.0-rc.3",
"music-metadata": "^8.1.0",
"uuid": "^11.1.0",
"webdav": "^5.3.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",

View File

@ -10,8 +10,8 @@ router.post('/start', async (req, res) => {
try {
const { type, options } = req.body;
if (!type || !['storage-sync', 'song-matching'].includes(type)) {
return res.status(400).json({ error: 'Invalid job type. Must be "storage-sync" or "song-matching"' });
if (!type || !['s3-sync', 'song-matching'].includes(type)) {
return res.status(400).json({ error: 'Invalid job type. Must be "s3-sync" or "song-matching"' });
}
console.log(`🚀 Starting background job: ${type}`);

View File

@ -2,16 +2,14 @@ import express from 'express';
import { S3Client, ListBucketsCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
import fs from 'fs/promises';
import path from 'path';
import { reloadStorageService } from './music.js';
import { StorageProviderFactory, StorageConfig } from '../services/storageProvider.js';
import { reloadS3Service } from './music.js';
const router = express.Router();
// Path to the storage configuration file
const CONFIG_FILE_PATH = path.join(process.cwd(), 'storage-config.json');
// Path to the S3 configuration file
const CONFIG_FILE_PATH = path.join(process.cwd(), 's3-config.json');
interface S3Config extends StorageConfig {
provider: 's3';
interface S3Config {
endpoint: string;
region: string;
accessKeyId: string;
@ -20,56 +18,8 @@ interface S3Config extends StorageConfig {
useSSL: boolean;
}
interface WebDAVConfig extends StorageConfig {
provider: 'webdav';
url: string;
username: string;
password: string;
basePath?: string;
}
/**
* Get current storage configuration
*/
router.get('/storage', async (req, res) => {
try {
// Check if config file exists
try {
const configData = await fs.readFile(CONFIG_FILE_PATH, 'utf-8');
const config = JSON.parse(configData);
// Don't return sensitive data in the response
const safeConfig = { ...config };
if (config.provider === 's3') {
safeConfig.accessKeyId = config.accessKeyId ? '***' : '';
safeConfig.secretAccessKey = config.secretAccessKey ? '***' : '';
} else if (config.provider === 'webdav') {
safeConfig.password = config.password ? '***' : '';
}
res.json({ config: safeConfig });
} catch (error) {
// Config file doesn't exist, return default config
res.json({
config: {
provider: 's3',
endpoint: process.env.S3_ENDPOINT || '',
region: process.env.S3_REGION || 'us-east-1',
accessKeyId: process.env.S3_ACCESS_KEY_ID ? '***' : '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ? '***' : '',
bucketName: process.env.S3_BUCKET_NAME || '',
useSSL: process.env.S3_USE_SSL !== 'false',
}
});
}
} catch (error) {
console.error('Error loading storage config:', error);
res.status(500).json({ error: 'Failed to load storage configuration' });
}
});
/**
* Get current S3 configuration (legacy endpoint)
* Get current S3 configuration
*/
router.get('/s3', async (req, res) => {
try {
@ -106,88 +56,7 @@ router.get('/s3', async (req, res) => {
});
/**
* Save storage configuration
*/
router.post('/storage', async (req, res) => {
try {
const newConfig: StorageConfig = req.body;
// Load existing configuration to merge with
let existingConfig: StorageConfig | null = null;
try {
const existingData = await fs.readFile(CONFIG_FILE_PATH, 'utf-8');
existingConfig = JSON.parse(existingData);
} catch (error) {
// No existing config file, that's okay
}
// Merge with existing configuration to preserve passwords/credentials that weren't provided
const mergedConfig = { ...existingConfig };
// Only override fields that are actually provided (not empty strings)
Object.keys(newConfig).forEach(key => {
const value = newConfig[key];
if (value !== '' && value !== null && value !== undefined) {
mergedConfig[key] = value;
}
});
// Validate required fields based on provider
if (mergedConfig.provider === 's3') {
const s3Config = mergedConfig as S3Config;
if (!s3Config.endpoint || !s3Config.accessKeyId || !s3Config.secretAccessKey || !s3Config.bucketName) {
return res.status(400).json({
error: 'Missing required S3 fields: endpoint, accessKeyId, secretAccessKey, bucketName'
});
}
} else if (mergedConfig.provider === 'webdav') {
const webdavConfig = mergedConfig as WebDAVConfig;
if (!webdavConfig.url || !webdavConfig.username || !webdavConfig.password) {
return res.status(400).json({
error: 'Missing required WebDAV fields: url, username, password'
});
}
} else {
return res.status(400).json({
error: 'Invalid provider. Must be "s3" or "webdav"'
});
}
// Save configuration to file
await fs.writeFile(CONFIG_FILE_PATH, JSON.stringify(mergedConfig, null, 2));
// Update environment variables for current session
if (mergedConfig.provider === 's3') {
const s3Config = mergedConfig as S3Config;
process.env.S3_ENDPOINT = s3Config.endpoint;
process.env.S3_REGION = s3Config.region;
process.env.S3_ACCESS_KEY_ID = s3Config.accessKeyId;
process.env.S3_SECRET_ACCESS_KEY = s3Config.secretAccessKey;
process.env.S3_BUCKET_NAME = s3Config.bucketName;
process.env.S3_USE_SSL = s3Config.useSSL.toString();
} else if (mergedConfig.provider === 'webdav') {
const webdavConfig = mergedConfig as WebDAVConfig;
process.env.WEBDAV_URL = webdavConfig.url;
process.env.WEBDAV_USERNAME = webdavConfig.username;
process.env.WEBDAV_PASSWORD = webdavConfig.password;
process.env.WEBDAV_BASE_PATH = webdavConfig.basePath || '/music-files';
}
// Reload storage service with new configuration
const reloadSuccess = await reloadStorageService();
res.json({
message: `${mergedConfig.provider.toUpperCase()} configuration saved successfully`,
storageServiceReloaded: reloadSuccess
});
} catch (error) {
console.error('Error saving storage config:', error);
res.status(500).json({ error: 'Failed to save storage configuration' });
}
});
/**
* Save S3 configuration (legacy endpoint)
* Save S3 configuration
*/
router.post('/s3', async (req, res) => {
try {
@ -211,8 +80,8 @@ router.post('/s3', async (req, res) => {
process.env.S3_BUCKET_NAME = config.bucketName;
process.env.S3_USE_SSL = config.useSSL.toString();
// Reload storage service with new configuration
const reloadSuccess = await reloadStorageService();
// Reload S3 service with new configuration
const reloadSuccess = await reloadS3Service();
res.json({
message: 'S3 configuration saved successfully',
@ -225,83 +94,7 @@ router.post('/s3', async (req, res) => {
});
/**
* Test storage connection
*/
router.post('/storage/test', async (req, res) => {
try {
const newConfig: StorageConfig = req.body;
// Load existing configuration to merge with
let existingConfig: StorageConfig | null = null;
try {
const existingData = await fs.readFile(CONFIG_FILE_PATH, 'utf-8');
existingConfig = JSON.parse(existingData);
} catch (error) {
// No existing config file, that's okay
}
// Merge with existing configuration to preserve passwords/credentials that weren't provided
const config = { ...existingConfig };
// Only override fields that are actually provided (not empty strings)
Object.keys(newConfig).forEach(key => {
const value = newConfig[key];
if (value !== '' && value !== null && value !== undefined) {
config[key] = value;
}
});
// Validate required fields based on provider
if (config.provider === 's3') {
const s3Config = config as S3Config;
if (!s3Config.endpoint || !s3Config.accessKeyId || !s3Config.secretAccessKey || !s3Config.bucketName) {
return res.status(400).json({
error: 'Missing required S3 fields: endpoint, accessKeyId, secretAccessKey, bucketName'
});
}
} else if (config.provider === 'webdav') {
const webdavConfig = config as WebDAVConfig;
if (!webdavConfig.url || !webdavConfig.username || !webdavConfig.password) {
return res.status(400).json({
error: 'Missing required WebDAV fields: url, username, password'
});
}
} else {
return res.status(400).json({
error: 'Invalid provider. Must be "s3" or "webdav"'
});
}
// Create storage provider and test connection
const provider = await StorageProviderFactory.createProvider(config);
const connectionTest = await provider.testConnection();
if (connectionTest) {
res.json({
success: true,
message: `${config.provider.toUpperCase()} connection test successful`,
provider: config.provider
});
} else {
res.status(400).json({
success: false,
error: `${config.provider.toUpperCase()} connection test failed`,
provider: config.provider
});
}
} catch (error) {
console.error('Error testing storage connection:', error);
res.status(500).json({
error: 'Failed to test storage connection',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
/**
* Test S3 connection (legacy endpoint)
* Test S3 connection
*/
router.post('/s3/test', async (req, res) => {
try {
@ -377,6 +170,4 @@ router.post('/s3/test', async (req, res) => {
}
});
export { router as configRouter };

View File

@ -1,6 +1,6 @@
import express from 'express';
import multer from 'multer';
import { StorageProviderFactory, StorageProvider } from '../services/storageProvider.js';
import { S3Service } from '../services/s3Service.js';
import { AudioMetadataService } from '../services/audioMetadataService.js';
import { MusicFile } from '../models/MusicFile.js';
import { Song } from '../models/Song.js';
@ -24,43 +24,40 @@ const upload = multer({
});
// Initialize services
let storageService: StorageProvider;
let s3Service: S3Service;
// Initialize storage service with configuration from file
async function initializeStorageService() {
// Initialize S3 service with configuration from file
async function initializeS3Service() {
try {
const config = await StorageProviderFactory.loadConfig();
storageService = await StorageProviderFactory.createProvider(config);
console.log(`✅ Storage service initialized (${config.provider}) with configuration from storage-config.json`);
s3Service = await S3Service.createFromConfig();
console.log('✅ S3 service initialized with configuration from s3-config.json');
} catch (error) {
console.error('❌ Failed to initialize storage service:', error);
// Fallback to S3 with environment variables
storageService = await StorageProviderFactory.createProvider({
provider: 's3',
console.error('❌ Failed to initialize S3 service:', error);
// Fallback to environment variables
s3Service = new S3Service({
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
bucketName: process.env.S3_BUCKET_NAME || 'music-files',
region: process.env.S3_REGION || 'us-east-1',
});
console.log('⚠️ Storage service initialized with S3 environment variables as fallback');
console.log('⚠️ S3 service initialized with environment variables as fallback');
}
}
// Initialize storage service on startup
initializeStorageService();
// Initialize S3 service on startup
initializeS3Service();
/**
* Reload storage service with updated configuration
* Reload S3 service with updated configuration
*/
export async function reloadStorageService() {
export async function reloadS3Service() {
try {
const config = await StorageProviderFactory.loadConfig();
storageService = await StorageProviderFactory.createProvider(config);
console.log(`✅ Storage service reloaded (${config.provider}) with updated configuration`);
s3Service = await S3Service.createFromConfig();
console.log('✅ S3 service reloaded with updated configuration');
return true;
} catch (error) {
console.error('❌ Failed to reload storage service:', error);
console.error('❌ Failed to reload S3 service:', error);
return false;
}
}
@ -80,8 +77,8 @@ router.post('/upload', upload.single('file'), async (req, res) => {
const targetFolder = typeof req.body?.targetFolder === 'string' ? req.body.targetFolder : undefined;
const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true';
// Upload to storage
const uploadResult = await storageService.uploadFile(buffer, originalname, mimetype, targetFolder);
// Upload to S3
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
// Extract audio metadata
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
@ -180,8 +177,8 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
try {
const { buffer, originalname, mimetype } = file;
// Upload to storage
const uploadResult = await storageService.uploadFile(buffer, originalname, mimetype);
// Upload to S3
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype);
// Extract audio metadata
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
@ -301,7 +298,7 @@ router.get('/folders', async (req, res) => {
return res.json({ folders: folderCache.folders });
}
const folders = await storageService.listAllFolders('');
const folders = await s3Service.listAllFolders('');
const result = ['', ...folders];
// Cache the result
@ -342,12 +339,12 @@ router.post('/sync-s3', async (req, res) => {
// Start the background job
const jobId = await backgroundJobService.startJob({
type: 'storage-sync',
type: 's3-sync',
options: req.body
});
res.json({
message: 'Storage sync started as background job',
message: 'S3 sync started as background job',
jobId,
status: 'started'
});
@ -371,75 +368,20 @@ router.get('/:id/stream', async (req, res) => {
return res.status(404).json({ error: 'Music file not found' });
}
// For WebDAV, use a proxy endpoint to handle authentication
// For S3, use presigned URL for direct access
const config = await StorageProviderFactory.loadConfig();
if (config.provider === 'webdav') {
// Use proxy endpoint for WebDAV to handle authentication
const proxyUrl = `${req.protocol}://${req.get('host')}/api/music/${musicFile._id}/proxy`;
res.json({
streamingUrl: proxyUrl,
musicFile,
contentType: musicFile.contentType || undefined,
});
} else {
// Use presigned URL for S3
const presignedUrl = await storageService.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry
// Use presigned URL for secure access instead of direct URL
const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry
res.json({
streamingUrl: presignedUrl,
musicFile,
contentType: musicFile.contentType || undefined,
});
}
} catch (error) {
console.error('Streaming error:', error);
res.status(500).json({ error: 'Failed to get streaming URL' });
}
});
/**
* Proxy endpoint for WebDAV streaming with authentication
*/
router.get('/:id/proxy', async (req, res) => {
try {
const musicFile = await MusicFile.findById(req.params.id);
if (!musicFile) {
return res.status(404).json({ error: 'Music file not found' });
}
// Set appropriate headers for audio streaming
res.setHeader('Content-Type', musicFile.contentType || 'audio/mpeg');
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=3600');
// Get the file content from WebDAV
const fileBuffer = await storageService.getFileContent(musicFile.s3Key);
// Handle range requests for seeking
const range = req.headers.range;
if (range) {
const fileSize = fileBuffer.length;
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const chunk = fileBuffer.slice(start, end + 1);
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.setHeader('Content-Length', chunksize.toString());
res.end(chunk);
} else {
// No range request, send entire file
res.setHeader('Content-Length', fileBuffer.length.toString());
res.end(fileBuffer);
}
} catch (error) {
console.error('Proxy streaming error:', error);
res.status(500).json({ error: 'Failed to stream music file' });
}
});
/**
* Get presigned URL for secure access
*/
@ -451,7 +393,7 @@ router.get('/:id/presigned', async (req, res) => {
}
const expiresIn = parseInt(req.query.expiresIn as string) || 3600;
const presignedUrl = await storageService.getPresignedUrl(musicFile.s3Key, expiresIn);
const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, expiresIn);
res.json({
presignedUrl,
@ -534,8 +476,31 @@ router.get('/', async (req, res) => {
}
});
// DELETE endpoint removed to keep WebDAV integration read-only
// Music files cannot be deleted to prevent accidental data loss from WebDAV
/**
* Delete a music file
*/
router.delete('/:id', async (req, res) => {
try {
const musicFile = await MusicFile.findById(req.params.id);
if (!musicFile) {
return res.status(404).json({ error: 'Music file not found' });
}
// Delete from S3
await s3Service.deleteFile(musicFile.s3Key);
// Delete from database
await MusicFile.findByIdAndDelete(req.params.id);
// Invalidate folder cache since we removed a file
invalidateFolderCache();
res.json({ message: 'Music file deleted successfully' });
} catch (error) {
console.error('Delete error:', error);
res.status(500).json({ error: 'Failed to delete music file' });
}
});
/**
* Link music file to existing song

View File

@ -37,9 +37,6 @@ export class AudioMetadataService {
'wma': 'WMA',
'OPUS': 'OPUS',
'opus': 'OPUS',
'AIFF': 'AIFF',
'aiff': 'AIFF',
'aif': 'AIFF',
};
// Try to map the container format
@ -207,7 +204,7 @@ export class AudioMetadataService {
*/
isAudioFile(fileName: string): boolean {
const supportedFormats = [
'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'aif', 'aiff'
'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus'
];
const extension = fileName.split('.').pop()?.toLowerCase();

View File

@ -1,6 +1,6 @@
export interface JobProgress {
jobId: string;
type: 'storage-sync' | 'song-matching';
type: 's3-sync' | 'song-matching';
status: 'running' | 'completed' | 'failed';
progress: number; // 0-100
current: number;
@ -13,7 +13,7 @@ export interface JobProgress {
}
export interface JobOptions {
type: 'storage-sync' | 'song-matching';
type: 's3-sync' | 'song-matching';
options?: any;
}
@ -140,8 +140,8 @@ class BackgroundJobService {
private async runJob(jobId: string, jobOptions: JobOptions): Promise<void> {
try {
switch (jobOptions.type) {
case 'storage-sync':
await this.runStorageSyncJob(jobId, jobOptions.options);
case 's3-sync':
await this.runS3SyncJob(jobId, jobOptions.options);
break;
case 'song-matching':
await this.runSongMatchingJob(jobId, jobOptions.options);
@ -156,20 +156,18 @@ class BackgroundJobService {
}
/**
* Run storage sync job (works with any storage provider)
* Run S3 sync job
*/
private async runStorageSyncJob(jobId: string, options?: any): Promise<void> {
private async runS3SyncJob(jobId: string, options?: any): Promise<void> {
try {
// Import here to avoid circular dependencies
const { StorageProviderFactory } = await import('./storageProvider.js');
const { S3Service } = await import('./s3Service.js');
const { AudioMetadataService } = await import('./audioMetadataService.js');
const { SongMatchingService } = await import('./songMatchingService.js');
const { MusicFile } = await import('../models/MusicFile.js');
const { Song } = await import('../models/Song.js');
// Get the configured storage provider
const config = await StorageProviderFactory.loadConfig();
const storageService = await StorageProviderFactory.createProvider(config);
const s3Service = await S3Service.createFromConfig();
const audioMetadataService = new AudioMetadataService();
const songMatchingService = new SongMatchingService();
@ -185,8 +183,6 @@ class BackgroundJobService {
case 'ogg': return 'audio/ogg';
case 'opus': return 'audio/opus';
case 'wma': return 'audio/x-ms-wma';
case 'aif': return 'audio/aiff';
case 'aiff': return 'audio/aiff';
default: return 'application/octet-stream';
}
};
@ -217,14 +213,14 @@ class BackgroundJobService {
// Phase 1: Quick filename matching
this.updateProgress(jobId, {
message: `Phase 1: Fetching files from ${config.provider.toUpperCase()}...`,
message: 'Phase 1: Fetching files from S3...',
current: 0,
total: 0
});
const storageFiles = await storageService.listAllFiles();
const audioFiles = storageFiles.filter(storageFile => {
const filename = storageFile.key.split('/').pop() || storageFile.key;
const s3Files = await s3Service.listAllFiles();
const audioFiles = s3Files.filter(s3File => {
const filename = s3File.key.split('/').pop() || s3File.key;
return audioMetadataService.isAudioFile(filename);
});
@ -236,8 +232,8 @@ class BackgroundJobService {
// Get existing files
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
const existingStorageKeys = new Set(existingFiles.map(f => f.s3Key));
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(storageFile => !existingStorageKeys.has(storageFile.key));
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key));
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
this.updateProgress(jobId, {
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
@ -253,11 +249,11 @@ class BackgroundJobService {
let processedCount = 0;
let phase1Errors = 0;
for (const storageFile of newAudioFiles) {
for (const s3File of newAudioFiles) {
processedCount++;
try {
const filename = storageFile.key.split('/').pop() || storageFile.key;
const filename = s3File.key.split('/').pop() || s3File.key;
this.updateProgress(jobId, {
message: `Phase 1: Quick filename matching`,
@ -269,7 +265,7 @@ class BackgroundJobService {
// Decode URL-encoded sequences so %20, %27 etc. are compared correctly
const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } };
const stripDiacritics = (s: string) => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
const normalizedStorageFilename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase();
const normalizedS3Filename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase();
let matchedSong = null;
for (const song of allSongs) {
@ -277,7 +273,7 @@ class BackgroundJobService {
const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location;
const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase();
if (normalizedStorageFilename === normalizedRekordboxFilename) {
if (normalizedS3Filename === normalizedRekordboxFilename) {
matchedSong = song;
break;
}
@ -287,31 +283,30 @@ class BackgroundJobService {
if (matchedSong) {
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
// Reuse existing MusicFile if present (force mode), otherwise create new
let musicFile = await MusicFile.findOne({ s3Key: storageFile.key });
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
if (!musicFile) {
musicFile = new MusicFile({ s3Key: storageFile.key });
musicFile = new MusicFile({ s3Key: s3File.key });
}
musicFile.originalName = filename;
musicFile.s3Key = storageFile.key;
musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
musicFile.s3Key = s3File.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
musicFile.contentType = guessContentType(filename);
musicFile.size = storageFile.size;
musicFile.size = s3File.size;
Object.assign(musicFile, basicMetadata);
musicFile.songId = matchedSong._id;
await musicFile.save();
quickMatches.push(musicFile);
// Update the Song document to indicate it has a storage file
const storageUrl = await storageService.getPresignedUrl(storageFile.key);
// Update the Song document to indicate it has an S3 file
await Song.updateOne(
{ _id: matchedSong._id },
{
$set: {
's3File.musicFileId': musicFile._id,
's3File.s3Key': storageFile.key,
's3File.s3Url': storageUrl,
's3File.streamingUrl': storageUrl,
's3File.s3Key': s3File.key,
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
's3File.hasS3File': true
}
}
@ -319,14 +314,14 @@ class BackgroundJobService {
console.log(`✅ Quick match saved immediately: ${filename}`);
} else {
unmatchedFiles.push(storageFile);
unmatchedFiles.push(s3File);
}
} catch (error) {
console.error(`Error in quick matching ${storageFile.key}:`, error);
unmatchedFiles.push(storageFile);
console.error(`Error in quick matching ${s3File.key}:`, error);
unmatchedFiles.push(s3File);
phase1Errors++;
}
}
@ -351,10 +346,10 @@ class BackgroundJobService {
const processedFiles: any[] = [];
for (let i = 0; i < unmatchedFiles.length; i++) {
const storageFile = unmatchedFiles[i];
const s3File = unmatchedFiles[i];
try {
const filename = storageFile.key.split('/').pop() || storageFile.key;
const filename = s3File.key.split('/').pop() || s3File.key;
this.updateProgress(jobId, {
message: `Phase 2: Complex matching`,
@ -363,19 +358,19 @@ class BackgroundJobService {
});
// Download file and extract metadata
const fileBuffer = await storageService.getFileContent(storageFile.key);
const fileBuffer = await s3Service.getFileContent(s3File.key);
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
// Reuse existing MusicFile document if present to avoid duplicate key errors
let musicFile = await MusicFile.findOne({ s3Key: storageFile.key });
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
if (!musicFile) {
musicFile = new MusicFile({ s3Key: storageFile.key });
musicFile = new MusicFile({ s3Key: s3File.key });
}
musicFile.originalName = filename;
musicFile.s3Key = storageFile.key;
musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
musicFile.s3Key = s3File.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
musicFile.contentType = guessContentType(filename);
musicFile.size = storageFile.size;
musicFile.size = s3File.size;
Object.assign(musicFile, metadata);
// Try complex matching
@ -391,16 +386,15 @@ class BackgroundJobService {
musicFile.songId = bestMatch.song._id;
complexMatches++;
// Update the Song document to indicate it has a storage file
const storageUrl = await storageService.getPresignedUrl(storageFile.key);
// Update the Song document to indicate it has an S3 file
await Song.updateOne(
{ _id: bestMatch.song._id },
{
$set: {
's3File.musicFileId': musicFile._id,
's3File.s3Key': storageFile.key,
's3File.s3Url': storageUrl,
's3File.streamingUrl': storageUrl,
's3File.s3Key': s3File.key,
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
's3File.hasS3File': true
}
}
@ -418,7 +412,7 @@ class BackgroundJobService {
} catch (error) {
console.error(`Error processing ${storageFile.key}:`, error);
console.error(`Error processing ${s3File.key}:`, error);
stillUnmatched++;
phase2Errors++;
}

View File

@ -3,10 +3,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs/promises';
import path from 'path';
import { StorageProvider, StorageConfig, UploadResult, FileInfo } from './storageProvider.js';
export interface S3Config extends StorageConfig {
provider: 's3';
export interface S3Config {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
@ -15,7 +13,21 @@ export interface S3Config extends StorageConfig {
useSSL?: boolean;
}
export class S3Service implements StorageProvider {
export interface UploadResult {
key: string;
url: string;
size: number;
contentType: string;
}
export interface S3FileInfo {
key: string;
size: number;
lastModified: Date;
contentType?: string;
}
export class S3Service {
private client: S3Client;
private bucketName: string;
@ -43,7 +55,6 @@ export class S3Service implements StorageProvider {
} catch (error) {
console.warn('Failed to load s3-config.json, using environment variables as fallback');
return {
provider: 's3' as const,
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
@ -71,22 +82,16 @@ export class S3Service implements StorageProvider {
contentType: string,
targetFolder?: string
): Promise<UploadResult> {
// Sanitize filename to be safe for S3
const sanitizedFilename = this.sanitizeFilename(originalName);
const fileExtension = originalName.split('.').pop();
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
const safeFolder = cleaned;
// Use original filename instead of UUID
const key = safeFolder
? `${safeFolder}/${sanitizedFilename}`
: sanitizedFilename;
// Check if file already exists and handle conflicts
const finalKey = await this.handleFilenameConflict(key);
? `${safeFolder}/${uuidv4()}.${fileExtension}`
: `${uuidv4()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: finalKey,
Key: key,
Body: file,
ContentType: contentType,
Metadata: {
@ -98,8 +103,8 @@ export class S3Service implements StorageProvider {
await this.client.send(command);
return {
key: finalKey,
url: `${this.bucketName}/${finalKey}`,
key,
url: `${this.bucketName}/${key}`,
size: file.length,
contentType,
};
@ -108,8 +113,8 @@ export class S3Service implements StorageProvider {
/**
* Recursively list all files in the S3 bucket
*/
async listAllFiles(prefix: string = ''): Promise<FileInfo[]> {
const files: FileInfo[] = [];
async listAllFiles(prefix: string = ''): Promise<S3FileInfo[]> {
const files: S3FileInfo[] = [];
let continuationToken: string | undefined;
do {
@ -191,7 +196,12 @@ export class S3Service implements StorageProvider {
* Delete a file from S3
*/
async deleteFile(key: string): Promise<void> {
throw new Error('File deletion is disabled to prevent accidental data loss');
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
});
await this.client.send(command);
}
/**
@ -253,74 +263,4 @@ export class S3Service implements StorageProvider {
async getStreamingUrl(key: string): Promise<string> {
return `${process.env.S3_ENDPOINT}/${this.bucketName}/${key}`;
}
/**
* Test the connection to S3
*/
async testConnection(): Promise<boolean> {
try {
// Try to list objects with a limit of 1 to test the connection
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
MaxKeys: 1,
});
await this.client.send(command);
return true;
} catch (error) {
console.error('S3 connection test failed:', error);
return false;
}
}
/**
* Sanitize filename to be safe for S3
*/
private sanitizeFilename(filename: string): string {
// Remove or replace characters that might cause issues in S3
return filename
.replace(/[<>:"|?*]/g, '_') // Replace problematic characters
.replace(/[\x00-\x1f\x80-\x9f]/g, '') // Remove control characters
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Handle filename conflicts by adding a number suffix
*/
private async handleFilenameConflict(key: string): Promise<string> {
try {
// Check if file exists
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});
await this.client.send(command);
// File exists, generate a new name with number suffix
const pathParts = key.split('/');
const filename = pathParts.pop() || '';
const dir = pathParts.join('/');
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.'));
const extension = filename.substring(filename.lastIndexOf('.'));
let counter = 1;
let newKey: string;
do {
const newFilename = `${nameWithoutExt} (${counter})${extension}`;
newKey = dir ? `${dir}/${newFilename}` : newFilename;
counter++;
// Prevent infinite loop
if (counter > 1000) {
throw new Error('Too many filename conflicts');
}
} while (await this.fileExists(newKey));
return newKey;
} catch (error) {
// File doesn't exist, use original key
return key;
}
}
}

View File

@ -1,143 +0,0 @@
/**
* Storage Provider Interface
*
* This interface defines the contract that all storage providers (S3, WebDAV, etc.)
* must implement to ensure consistent behavior across different storage backends.
*/
export interface StorageConfig {
provider: 's3' | 'webdav';
[key: string]: any; // Allow additional provider-specific config
}
export interface UploadResult {
key: string;
url: string;
size: number;
contentType: string;
}
export interface FileInfo {
key: string;
size: number;
lastModified: Date;
contentType?: string;
}
export interface StorageProvider {
/**
* Upload a file to storage
*/
uploadFile(
file: Buffer,
originalName: string,
contentType: string,
targetFolder?: string
): Promise<UploadResult>;
/**
* List all files in storage
*/
listAllFiles(prefix?: string): Promise<FileInfo[]>;
/**
* List all folders in storage
*/
listAllFolders(prefix?: string): Promise<string[]>;
/**
* Generate a presigned/secure URL for file access
*/
getPresignedUrl(key: string, expiresIn?: number): Promise<string>;
/**
* Delete a file from storage
*/
deleteFile(key: string): Promise<void>;
/**
* Check if a file exists
*/
fileExists(key: string): Promise<boolean>;
/**
* Get file metadata
*/
getFileMetadata(key: string): Promise<any>;
/**
* Get file content as buffer
*/
getFileContent(key: string): Promise<Buffer>;
/**
* Get streaming URL for a file
*/
getStreamingUrl(key: string): Promise<string>;
/**
* Test the connection to the storage provider
*/
testConnection(): Promise<boolean>;
}
/**
* Storage Provider Factory
* Creates the appropriate storage provider based on configuration
*/
export class StorageProviderFactory {
static async createProvider(config: StorageConfig): Promise<StorageProvider> {
switch (config.provider) {
case 's3':
const { S3Service } = await import('./s3Service.js');
return new S3Service(config as any);
case 'webdav':
const { WebDAVService } = await import('./webdavService.js');
return new WebDAVService(config as any);
default:
throw new Error(`Unsupported storage provider: ${config.provider}`);
}
}
/**
* Load storage configuration from file or environment
*/
static async loadConfig(): Promise<StorageConfig> {
try {
const configPath = path.join(process.cwd(), 'storage-config.json');
const configData = await fs.readFile(configPath, 'utf-8');
return JSON.parse(configData);
} catch (error) {
console.warn('Failed to load storage-config.json, using environment variables as fallback');
// Determine provider from environment
const provider = process.env.STORAGE_PROVIDER || 's3';
if (provider === 'webdav') {
return {
provider: 'webdav',
url: process.env.WEBDAV_URL || '',
username: process.env.WEBDAV_USERNAME || '',
password: process.env.WEBDAV_PASSWORD || '',
basePath: process.env.WEBDAV_BASE_PATH || '/music-files',
};
} else {
return {
provider: 's3',
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
bucketName: process.env.S3_BUCKET_NAME || 'music-files',
region: process.env.S3_REGION || 'us-east-1',
useSSL: process.env.S3_USE_SSL !== 'false',
};
}
}
}
}
// Import required modules
import fs from 'fs/promises';
import path from 'path';

View File

@ -1,337 +0,0 @@
import { createClient, WebDAVClient, FileStat } from 'webdav';
import { v4 as uuidv4 } from 'uuid';
import { StorageProvider, StorageConfig, UploadResult, FileInfo } from './storageProvider.js';
export interface WebDAVConfig extends StorageConfig {
provider: 'webdav';
url: string;
username: string;
password: string;
basePath?: string;
}
export class WebDAVService implements StorageProvider {
private client: WebDAVClient;
private basePath: string;
private config: WebDAVConfig;
constructor(config: WebDAVConfig) {
this.config = config;
this.client = createClient(config.url, {
username: config.username,
password: config.password,
});
this.basePath = config.basePath || '/music-files';
}
/**
* Load WebDAV configuration from file or environment
*/
static async loadConfig(): Promise<WebDAVConfig> {
try {
const configPath = path.join(process.cwd(), 'storage-config.json');
const configData = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configData);
if (config.provider !== 'webdav') {
throw new Error('Configuration is not for WebDAV provider');
}
return config;
} catch (error) {
console.warn('Failed to load storage-config.json, using environment variables as fallback');
return {
provider: 'webdav',
url: process.env.WEBDAV_URL || '',
username: process.env.WEBDAV_USERNAME || '',
password: process.env.WEBDAV_PASSWORD || '',
basePath: process.env.WEBDAV_BASE_PATH || '/music-files',
};
}
}
/**
* Create WebDAVService instance with configuration from file
*/
static async createFromConfig(): Promise<WebDAVService> {
const config = await this.loadConfig();
return new WebDAVService(config);
}
/**
* Upload a file to WebDAV
*/
async uploadFile(
file: Buffer,
originalName: string,
contentType: string,
targetFolder?: string
): Promise<UploadResult> {
// Sanitize filename to be safe for WebDAV
const sanitizedFilename = this.sanitizeFilename(originalName);
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
const safeFolder = cleaned;
// Use original filename instead of UUID
const key = safeFolder
? `${safeFolder}/${sanitizedFilename}`
: sanitizedFilename;
const remotePath = `${this.basePath}/${key}`;
// Ensure the directory exists
const dirPath = remotePath.substring(0, remotePath.lastIndexOf('/'));
await this.ensureDirectoryExists(dirPath);
// Check if file already exists and handle conflicts
const finalKey = await this.handleFilenameConflict(key, remotePath);
// Upload the file
await this.client.putFileContents(`${this.basePath}/${finalKey}`, file, {
overwrite: true,
headers: {
'Content-Type': contentType,
},
});
return {
key: finalKey,
url: `${this.config.url}${this.basePath}/${finalKey}`,
size: file.length,
contentType,
};
}
/**
* Recursively list all files in the WebDAV directory
*/
async listAllFiles(prefix: string = ''): Promise<FileInfo[]> {
const files: FileInfo[] = [];
const searchPath = `${this.basePath}/${prefix}`.replace(/\/+/g, '/');
try {
const response = await this.client.getDirectoryContents(searchPath, {
deep: true,
maxDepth: -1, // No depth limit
});
// Handle both single item and array responses
const items = Array.isArray(response) ? response : [response];
for (const item of items) {
if (item && typeof item === 'object' && 'type' in item && item.type === 'file') {
const fileItem = item as any;
const relativePath = fileItem.filename.replace(this.basePath + '/', '');
files.push({
key: relativePath,
size: fileItem.size || 0,
lastModified: new Date(fileItem.lastmod || Date.now()),
contentType: this.getContentTypeFromFilename(fileItem.basename),
});
}
}
} catch (error) {
console.error('Error listing WebDAV files:', error);
}
return files;
}
/**
* List all folders in the WebDAV directory
*/
async listAllFolders(prefix: string = ''): Promise<string[]> {
const folders = new Set<string>();
const searchPath = `${this.basePath}/${prefix}`.replace(/\/+/g, '/');
try {
const response = await this.client.getDirectoryContents(searchPath, {
deep: true,
maxDepth: -1, // No depth limit
});
// Handle both single item and array responses
const items = Array.isArray(response) ? response : [response];
for (const item of items) {
if (item && typeof item === 'object' && 'type' in item && item.type === 'directory') {
const dirItem = item as any;
const relativePath = dirItem.filename.replace(this.basePath + '/', '');
if (relativePath) {
folders.add(relativePath);
}
}
}
} catch (error) {
console.error('Error listing WebDAV folders:', error);
}
return Array.from(folders).sort();
}
/**
* Generate a direct URL for file access (WebDAV doesn't support presigned URLs)
*/
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
// WebDAV doesn't support presigned URLs, so we return a direct URL
// Use the config URL directly since WebDAV client doesn't expose getURL()
const baseUrl = this.config.url;
return `${baseUrl}${this.basePath}/${key}`;
}
/**
* Delete a file from WebDAV - DISABLED for read-only safety
*/
async deleteFile(key: string): Promise<void> {
throw new Error('File deletion is disabled for WebDAV integration to prevent accidental data loss');
}
/**
* Check if a file exists
*/
async fileExists(key: string): Promise<boolean> {
try {
const remotePath = `${this.basePath}/${key}`;
const response = await this.client.stat(remotePath);
const stat = Array.isArray(response) ? response[0] : response;
return stat && typeof stat === 'object' && 'type' in stat && stat.type === 'file';
} catch (error) {
return false;
}
}
/**
* Get file metadata
*/
async getFileMetadata(key: string): Promise<any> {
const remotePath = `${this.basePath}/${key}`;
const response = await this.client.stat(remotePath);
return Array.isArray(response) ? response[0] : response;
}
/**
* Get file content as buffer
*/
async getFileContent(key: string): Promise<Buffer> {
const remotePath = `${this.basePath}/${key}`;
const arrayBuffer = await this.client.getFileContents(remotePath, {
format: 'binary',
});
return Buffer.from(arrayBuffer as ArrayBuffer);
}
/**
* Get streaming URL for a file
*/
async getStreamingUrl(key: string): Promise<string> {
// Use the config URL directly since WebDAV client doesn't expose getURL()
const baseUrl = this.config.url;
return `${baseUrl}${this.basePath}/${key}`;
}
/**
* Test the connection to WebDAV
*/
async testConnection(): Promise<boolean> {
try {
// Try to list the base directory to test the connection
await this.client.getDirectoryContents(this.basePath);
return true;
} catch (error) {
console.error('WebDAV connection test failed:', error);
return false;
}
}
/**
* Ensure a directory exists, creating it if necessary
*/
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
// Check if directory exists
await this.client.stat(dirPath);
} catch (error) {
// Directory doesn't exist, create it
try {
await this.client.createDirectory(dirPath, { recursive: true });
} catch (createError) {
console.error('Failed to create directory:', dirPath, createError);
throw createError;
}
}
}
/**
* Get content type from filename
*/
private getContentTypeFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'mp3': return 'audio/mpeg';
case 'wav': return 'audio/wav';
case 'flac': return 'audio/flac';
case 'm4a': return 'audio/mp4';
case 'aac': return 'audio/aac';
case 'ogg': return 'audio/ogg';
case 'opus': return 'audio/opus';
case 'wma': return 'audio/x-ms-wma';
case 'aif': return 'audio/aiff';
case 'aiff': return 'audio/aiff';
default: return 'application/octet-stream';
}
}
/**
* Sanitize filename to be safe for WebDAV
*/
private sanitizeFilename(filename: string): string {
// Remove or replace characters that might cause issues in WebDAV
return filename
.replace(/[<>:"|?*]/g, '_') // Replace problematic characters
.replace(/[\x00-\x1f\x80-\x9f]/g, '') // Remove control characters
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Handle filename conflicts by adding a number suffix
*/
private async handleFilenameConflict(key: string, remotePath: string): Promise<string> {
try {
// Check if file exists
await this.client.stat(remotePath);
// File exists, generate a new name with number suffix
const pathParts = key.split('/');
const filename = pathParts.pop() || '';
const dir = pathParts.join('/');
const nameWithoutExt = filename.substring(0, filename.lastIndexOf('.'));
const extension = filename.substring(filename.lastIndexOf('.'));
let counter = 1;
let newKey: string;
let newRemotePath: string;
do {
const newFilename = `${nameWithoutExt} (${counter})${extension}`;
newKey = dir ? `${dir}/${newFilename}` : newFilename;
newRemotePath = `${this.basePath}/${newKey}`;
counter++;
// Prevent infinite loop
if (counter > 1000) {
throw new Error('Too many filename conflicts');
}
} while (await this.fileExists(newKey));
return newKey;
} catch (error) {
// File doesn't exist, use original key
return key;
}
}
}
// Import required modules
import fs from 'fs/promises';
import path from 'path';

View File

@ -1,7 +0,0 @@
{
"provider": "webdav",
"url": "https://cloud.geertrademakers.nl/remote.php/dav/files/admin",
"username": "admin",
"password": "XPZK2-MGQ5W-7Yetf-nr8gf-s5g5Z",
"basePath": "/Music"
}

View File

@ -1,45 +0,0 @@
import { AudioMetadataService } from './dist/services/audioMetadataService.js';
const audioService = new AudioMetadataService();
// Test files from the debug output
const testFiles = [
'01 Gas Op Die Lollie.mp3',
'ACRAZE - Do It To It (Extended Mix).mp3',
'test.flac',
'sample.wav',
'music.m4a',
'song.aac',
'track.ogg',
'audio.opus',
'file.wma',
'sound.aif',
'music.aiff',
'image.jpg',
'archive.zip',
'script.py',
'info.nfo',
'video.mp4',
'installer.dmg',
'playlist.m3u',
'readme.md',
'script.sh'
];
console.log('🎵 Testing audio file detection:');
console.log('');
testFiles.forEach(filename => {
const isAudio = audioService.isAudioFile(filename);
const status = isAudio ? '✅' : '❌';
console.log(`${status} ${filename} -> ${isAudio ? 'AUDIO' : 'NOT AUDIO'}`);
});
console.log('');
console.log('📊 Summary:');
const audioFiles = testFiles.filter(f => audioService.isAudioFile(f));
const nonAudioFiles = testFiles.filter(f => !audioService.isAudioFile(f));
console.log(`Audio files: ${audioFiles.length}`);
console.log(`Non-audio files: ${nonAudioFiles.length}`);
console.log(`Total files: ${testFiles.length}`);

View File

@ -1,113 +0,0 @@
import { StorageProviderFactory } from './dist/services/storageProvider.js';
import { AudioMetadataService } from './dist/services/audioMetadataService.js';
async function testBackgroundJobFlow() {
try {
console.log('🔍 Testing Background Job Flow:');
console.log('');
// Step 1: Load config and create provider (same as background job)
console.log('1⃣ Loading storage configuration...');
const config = await StorageProviderFactory.loadConfig();
console.log('Config provider:', config.provider);
console.log('Config basePath:', config.basePath);
console.log('');
// Step 2: Create storage service (same as background job)
console.log('2⃣ Creating storage service...');
const storageService = await StorageProviderFactory.createProvider(config);
console.log('Storage service created:', storageService.constructor.name);
console.log('');
// Step 3: List all files (same as background job)
console.log('3⃣ Listing all files from storage...');
const startTime = Date.now();
const storageFiles = await storageService.listAllFiles();
const endTime = Date.now();
console.log(`✅ listAllFiles completed in ${endTime - startTime}ms`);
console.log('Total storage files found:', storageFiles.length);
console.log('');
// Step 4: Filter for audio files (same as background job)
console.log('4⃣ Filtering for audio files...');
const audioMetadataService = new AudioMetadataService();
const audioFiles = storageFiles.filter(storageFile => {
const filename = storageFile.key.split('/').pop() || storageFile.key;
const isAudio = audioMetadataService.isAudioFile(filename);
if (!isAudio) {
console.log(` ❌ Not audio: ${filename}`);
}
return isAudio;
});
console.log('Audio files found:', audioFiles.length);
console.log('');
// Step 5: Show breakdown by file type
console.log('5⃣ File type breakdown:');
const fileTypes = new Map();
storageFiles.forEach(file => {
const filename = file.key.split('/').pop() || file.key;
const ext = filename.split('.').pop()?.toLowerCase() || 'no-extension';
fileTypes.set(ext, (fileTypes.get(ext) || 0) + 1);
});
const sortedTypes = Array.from(fileTypes.entries()).sort((a, b) => b[1] - a[1]);
sortedTypes.forEach(([ext, count]) => {
const isAudio = audioMetadataService.isAudioFile(`test.${ext}`);
const status = isAudio ? '🎵' : '📄';
console.log(` ${status} .${ext}: ${count} files`);
});
console.log('');
// Step 6: Show MP3 breakdown
console.log('6⃣ MP3 files breakdown:');
const mp3Files = storageFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log('MP3 files found:', mp3Files.length);
// Show directory distribution of MP3 files
const mp3DirCounts = new Map();
mp3Files.forEach(file => {
const dir = file.key.split('/')[0];
mp3DirCounts.set(dir, (mp3DirCounts.get(dir) || 0) + 1);
});
const sortedMp3Dirs = Array.from(mp3DirCounts.entries()).sort((a, b) => b[1] - a[1]);
console.log('MP3 files by directory:');
sortedMp3Dirs.forEach(([dir, count]) => {
console.log(` 📁 ${dir}: ${count} MP3 files`);
});
console.log('');
// Step 7: Test with different prefixes (if WebDAV)
if (config.provider === 'webdav') {
console.log('7⃣ Testing with different prefixes...');
const testPrefixes = ['', 'Gekocht', 'Merijn Music', 'Musica'];
for (const prefix of testPrefixes) {
try {
const prefixFiles = await storageService.listAllFiles(prefix);
const prefixMp3Files = prefixFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log(` 📁 Prefix "${prefix}": ${prefixFiles.length} total, ${prefixMp3Files.length} MP3`);
} catch (error) {
console.log(` ❌ Prefix "${prefix}": Error - ${error.message}`);
}
}
}
} catch (error) {
console.error('❌ Failed to test background job flow:', error);
console.error('Error details:', error.message);
console.error('Stack trace:', error.stack);
}
}
// Run the test
testBackgroundJobFlow().catch(console.error);

View File

@ -1,94 +0,0 @@
import { StorageProviderFactory } from './dist/services/storageProvider.js';
import { AudioMetadataService } from './dist/services/audioMetadataService.js';
async function testBackgroundJobSimulation() {
try {
console.log('🔍 Testing Background Job Simulation:');
console.log('');
// Step 1: Load config and create provider (exactly like background job)
console.log('1⃣ Loading storage configuration...');
const config = await StorageProviderFactory.loadConfig();
console.log('Config provider:', config.provider);
console.log('Config basePath:', config.basePath);
console.log('');
// Step 2: Create storage service (exactly like background job)
console.log('2⃣ Creating storage service...');
const storageService = await StorageProviderFactory.createProvider(config);
console.log('Storage service created:', storageService.constructor.name);
console.log('');
// Step 3: Create audio metadata service (exactly like background job)
console.log('3⃣ Creating audio metadata service...');
const audioMetadataService = new AudioMetadataService();
console.log('Audio metadata service created');
console.log('');
// Step 4: List all files (exactly like background job)
console.log('4⃣ Listing all files from storage...');
const startTime = Date.now();
const storageFiles = await storageService.listAllFiles();
const endTime = Date.now();
console.log(`✅ listAllFiles completed in ${endTime - startTime}ms`);
console.log('Total storage files found:', storageFiles.length);
console.log('');
// Step 5: Filter for audio files (exactly like background job)
console.log('5⃣ Filtering for audio files...');
const audioFiles = storageFiles.filter(storageFile => {
const filename = storageFile.key.split('/').pop() || storageFile.key;
const isAudio = audioMetadataService.isAudioFile(filename);
return isAudio;
});
console.log('Audio files found:', audioFiles.length);
console.log('');
// Step 6: Show MP3 breakdown
console.log('6⃣ MP3 files breakdown:');
const mp3Files = audioFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log('MP3 files found:', mp3Files.length);
console.log('');
// Step 7: Show file type breakdown
console.log('7⃣ File type breakdown:');
const fileTypes = new Map();
audioFiles.forEach(file => {
const filename = file.key.split('/').pop() || file.key;
const ext = filename.split('.').pop()?.toLowerCase() || 'no-extension';
fileTypes.set(ext, (fileTypes.get(ext) || 0) + 1);
});
const sortedTypes = Array.from(fileTypes.entries()).sort((a, b) => b[1] - a[1]);
sortedTypes.forEach(([ext, count]) => {
console.log(` .${ext}: ${count} files`);
});
console.log('');
// Step 8: Show directory breakdown
console.log('8⃣ Directory breakdown:');
const dirCounts = new Map();
audioFiles.forEach(file => {
const dir = file.key.split('/')[0];
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
});
const sortedDirs = Array.from(dirCounts.entries()).sort((a, b) => b[1] - a[1]);
sortedDirs.forEach(([dir, count]) => {
console.log(` 📁 ${dir}: ${count} audio files`);
});
} catch (error) {
console.error('❌ Failed to test background job simulation:', error);
console.error('Error details:', error.message);
console.error('Stack trace:', error.stack);
}
}
// Run the test
testBackgroundJobSimulation().catch(console.error);

View File

@ -1,51 +0,0 @@
import { StorageProviderFactory } from './dist/services/storageProvider.js';
async function testCurrentWebDAV() {
try {
console.log('🔍 Testing Current WebDAV Service:');
console.log('');
// Load config and create provider (same as background job)
const config = await StorageProviderFactory.loadConfig();
console.log('Config provider:', config.provider);
console.log('Config basePath:', config.basePath);
console.log('');
// Create storage service
const storageService = await StorageProviderFactory.createProvider(config);
console.log('Storage service created:', storageService.constructor.name);
console.log('');
// List all files
console.log('📁 Listing all files...');
const startTime = Date.now();
const storageFiles = await storageService.listAllFiles();
const endTime = Date.now();
console.log(`✅ listAllFiles completed in ${endTime - startTime}ms`);
console.log('Total storage files found:', storageFiles.length);
console.log('');
// Filter for MP3 files
const mp3Files = storageFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log('🎵 MP3 files found:', mp3Files.length);
console.log('');
// Show first 10 MP3 files
console.log('🎵 First 10 MP3 files:');
mp3Files.slice(0, 10).forEach((file, index) => {
console.log(` ${index + 1}. ${file.key} (${file.size} bytes)`);
});
} catch (error) {
console.error('❌ Failed to test current WebDAV service:', error);
console.error('Error details:', error.message);
console.error('Stack trace:', error.stack);
}
}
// Run the test
testCurrentWebDAV().catch(console.error);

View File

@ -1 +0,0 @@
test content

View File

@ -1,54 +0,0 @@
import fetch from 'node-fetch';
async function testServerWebDAV() {
try {
console.log('🔍 Testing Server WebDAV via API:');
console.log('');
// Test the storage sync endpoint
console.log('1⃣ Testing storage sync endpoint...');
const response = await fetch('http://localhost:3000/api/music/sync-s3', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ force: true })
});
if (response.ok) {
const result = await response.json();
console.log('✅ Storage sync started:', result);
console.log('');
// Wait a bit and check job progress
console.log('2⃣ Waiting for job to start...');
await new Promise(resolve => setTimeout(resolve, 5000));
const jobsResponse = await fetch('http://localhost:3000/api/background-jobs/jobs');
if (jobsResponse.ok) {
const jobs = await jobsResponse.json();
const latestJob = jobs.jobs[jobs.jobs.length - 1];
console.log('📊 Latest job status:', {
jobId: latestJob.jobId,
status: latestJob.status,
progress: latestJob.progress,
message: latestJob.message,
current: latestJob.current,
total: latestJob.total
});
if (latestJob.result) {
console.log('📊 Job result:', latestJob.result);
}
}
} else {
console.error('❌ Failed to start storage sync:', response.status, response.statusText);
}
} catch (error) {
console.error('❌ Failed to test server WebDAV:', error);
}
}
// Run the test
testServerWebDAV().catch(console.error);

View File

@ -1,92 +0,0 @@
import { WebDAVService } from './src/services/webdavService.js';
import fs from 'fs';
async function testWebDAVService() {
try {
// Load configuration
const configData = fs.readFileSync('storage-config.json', 'utf-8');
const config = JSON.parse(configData);
console.log('🔍 Testing WebDAV Service:');
console.log('URL:', config.url);
console.log('Username:', config.username);
console.log('Base Path:', config.basePath);
console.log('');
// Create WebDAV service
const webdavService = new WebDAVService(config);
console.log('🔗 Testing connection...');
const connectionTest = await webdavService.testConnection();
console.log('Connection test:', connectionTest ? '✅ Success' : '❌ Failed');
console.log('');
if (connectionTest) {
console.log('📁 Testing listAllFiles...');
const startTime = Date.now();
const allFiles = await webdavService.listAllFiles();
const endTime = Date.now();
console.log(`✅ listAllFiles completed in ${endTime - startTime}ms`);
console.log('Total files found:', allFiles.length);
console.log('');
// Filter for MP3 files
const mp3Files = allFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log('🎵 MP3 Files found by service:', mp3Files.length);
console.log('');
// Show first 20 MP3 files
console.log('🎵 First 20 MP3 files from service:');
mp3Files.slice(0, 20).forEach((file, index) => {
console.log(` ${index + 1}. ${file.key} (${file.size} bytes)`);
});
if (mp3Files.length > 20) {
console.log(` ... and ${mp3Files.length - 20} more MP3 files`);
}
// Check directory distribution
console.log('');
console.log('📁 Directory distribution of MP3 files:');
const dirCounts = new Map();
mp3Files.forEach(file => {
const dir = file.key.split('/')[0];
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
});
const sortedDirs = Array.from(dirCounts.entries()).sort((a, b) => b[1] - a[1]);
sortedDirs.forEach(([dir, count]) => {
console.log(` 📁 ${dir}: ${count} MP3 files`);
});
// Test with different prefixes
console.log('');
console.log('🔍 Testing with different prefixes...');
const testPrefixes = ['', 'Gekocht', 'Merijn Music', 'Musica'];
for (const prefix of testPrefixes) {
try {
const prefixFiles = await webdavService.listAllFiles(prefix);
const prefixMp3Files = prefixFiles.filter(file => {
const filename = file.key.split('/').pop() || file.key;
return filename.toLowerCase().endsWith('.mp3');
});
console.log(` 📁 Prefix "${prefix}": ${prefixMp3Files.length} MP3 files`);
} catch (error) {
console.log(` ❌ Prefix "${prefix}": Error - ${error.message}`);
}
}
}
} catch (error) {
console.error('❌ Failed to test WebDAV service:', error);
}
}
// Run the test
testWebDAVService().catch(console.error);

View File

@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* WebDAV Integration Test
*
* This script tests the WebDAV service integration with a sample Nextcloud instance.
* Update the configuration below to match your Nextcloud setup.
*/
import { WebDAVService } from './dist/services/webdavService.js';
// Test configuration - update these values for your Nextcloud instance
const testConfig = {
provider: 'webdav',
url: 'https://your-nextcloud.com/remote.php/dav/files/username/',
username: 'your-username',
password: 'your-password-or-app-password',
basePath: '/music-files'
};
async function testWebDAVConnection() {
console.log('🧪 Testing WebDAV connection...');
console.log('📋 Configuration:', {
...testConfig,
password: '***' // Hide password in logs
});
try {
// Create WebDAV service
const webdavService = new WebDAVService(testConfig);
// Test connection
console.log('🔗 Testing connection...');
const isConnected = await webdavService.testConnection();
if (isConnected) {
console.log('✅ WebDAV connection successful!');
// Test listing files
console.log('📁 Testing file listing...');
const files = await webdavService.listAllFiles();
console.log(`📊 Found ${files.length} files`);
// Test listing folders
console.log('📂 Testing folder listing...');
const folders = await webdavService.listAllFolders();
console.log(`📊 Found ${folders.length} folders:`, folders);
console.log('🎉 All WebDAV tests passed!');
} else {
console.log('❌ WebDAV connection failed');
}
} catch (error) {
console.error('❌ WebDAV test failed:', error.message);
console.error('💡 Make sure to update the configuration in this file with your Nextcloud details');
}
}
// Run the test
testWebDAVConnection().catch(console.error);

View File

@ -0,0 +1,22 @@
# Rekordbox Sync Desktop Application Configuration
# Generated on Sat Aug 16 15:03:29 CEST 2025
# S3 Configuration
S3_ENDPOINT=https://garage.geertrademakers.nl
S3_REGION=garage
S3_ACCESS_KEY_ID=GK1c1a4a30946eb1e7f8d60847
S3_SECRET_ACCESS_KEY=2ed6673f0e3c42d347adeb54ba6b95a1ebc6414750f2a95e1d3d89758f1add63
S3_BUCKET_NAME=music
S3_USE_SSL=true
# Sync Configuration
SYNC_LOCAL_PATH=/Users/geertrademakers/Music/s3-sync-test
SYNC_INTERVAL=30000
SYNC_AUTO_START=true
SYNC_CONFLICT_RESOLUTION=newer-wins
# UI Configuration
UI_THEME=system
UI_LANGUAGE=en
UI_NOTIFICATIONS=true
UI_MINIMIZE_TO_TRAY=true

View File

@ -0,0 +1,22 @@
# Rekordbox Sync Desktop Application Configuration
# Generated on Sat Aug 16 15:03:29 CEST 2025
# S3 Configuration
S3_ENDPOINT=https://garage.geertrademakers.nl
S3_REGION=garage
S3_ACCESS_KEY_ID=GK1c1a4a30946eb1e7f8d60847
S3_SECRET_ACCESS_KEY=2ed6673f0e3c42d347adeb54ba6b95a1ebc6414750f2a95e1d3d89758f1add63
S3_BUCKET_NAME=music
S3_USE_SSL=true
# Sync Configuration
SYNC_LOCAL_PATH=/Users/geertrademakers/Desktop/s3-music-sync-dir
SYNC_INTERVAL=30000
SYNC_AUTO_START=true
SYNC_CONFLICT_RESOLUTION=newer-wins
# UI Configuration
UI_THEME=system
UI_LANGUAGE=en
UI_NOTIFICATIONS=true
UI_MINIMIZE_TO_TRAY=true

View File

@ -0,0 +1,255 @@
# Rekordbox Sync - Desktop Companion
A desktop application for bidirectional synchronization between a Garage-hosted S3 instance and your local computer, specifically designed for Rekordbox music libraries.
## 🚀 Features
- **Bidirectional S3 Sync**: Seamlessly sync files between your local machine and S3 storage
- **Incremental Sync**: Only sync files that have changed since the last sync
- **Automatic Cleanup**: Removes temporary files before syncing
- **Real-time Monitoring**: Continuous sync with configurable intervals
- **Error Handling**: Robust error handling with automatic retries
- **Progress Tracking**: Real-time progress updates and file counting
- **Cross-platform**: Built with Electron for macOS, Windows, and Linux
## 🔧 Prerequisites
### AWS CLI v2
This tool requires AWS CLI v2 to be installed on your system. The AWS CLI provides the `aws s3 sync` command which offers superior performance and reliability compared to other S3 sync tools.
#### Installation Options:
**Option 1: Automatic Installation (macOS)**
```bash
npm run install-aws-cli
```
**Option 2: Manual Installation**
- Download from: https://awscli.amazonaws.com/
- Follow the installation guide: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
**Option 3: Homebrew (macOS)**
```bash
brew install awscli
```
#### Verify Installation
```bash
aws --version
```
## 📦 Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd rekordbox-reader/packages/desktop-sync
```
2. **Install dependencies**
```bash
npm install
```
3. **Configure environment**
```bash
cp .env.example .env
# Edit .env with your S3 configuration
```
4. **Build the application**
```bash
npm run build
```
5. **Start the application**
```bash
npm run dev
```
## ⚙️ Configuration
Create a `.env` file in the project root with the following variables:
```env
# S3 Configuration (Garage)
S3_ENDPOINT=http://your-garage-instance:3900
S3_REGION=garage
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET_NAME=your-bucket-name
S3_USE_SSL=false
# Sync Configuration
SYNC_LOCAL_PATH=/path/to/your/local/music/folder
SYNC_INTERVAL=30000
SYNC_AUTO_START=false
SYNC_CONFLICT_RESOLUTION=newer-wins
# UI Configuration
UI_THEME=dark
UI_LANGUAGE=en
```
### Garage S3 Configuration
For Garage S3 compatibility, ensure your configuration includes:
- **Endpoint**: Your Garage instance URL (e.g., `http://localhost:3900`)
- **Region**: Usually `garage` or `us-east-1`
- **SSL**: Set to `false` for local Garage instances
## 🎯 Usage
### Starting Sync
1. Launch the application
2. Click "Start Sync" to begin bidirectional synchronization
3. The app will:
- Download all files from S3 to local (first time)
- Upload new/changed local files to S3
- Start continuous bidirectional sync
### Sync Modes
#### **Initial Sync**
- Downloads all files from S3 to local
- Ensures local folder matches S3 bucket contents
- Excludes temporary files (`.tmp`, `.temp`, `.part`, `.DS_Store`)
#### **Continuous Sync**
- Monitors both local and S3 for changes
- Automatically syncs new, modified, or deleted files
- Runs every 30 seconds by default
- Maintains bidirectional consistency
#### **Force Full Sync**
- Completely resynchronizes all files
- Useful for resolving sync conflicts
- Deletes and re-downloads all files
### File Handling
- **Temporary Files**: Automatically excluded and cleaned up
- **Conflict Resolution**: Newer timestamp wins by default
- **Delete Propagation**: Files deleted locally are removed from S3 and vice versa
- **Incremental Updates**: Only changed files are transferred
## 🔍 Monitoring
### Real-time Status
- Current sync phase (downloading, uploading, watching)
- Progress percentage and file counts
- Transfer speed and ETA
- Error messages and retry attempts
### Activity Log
- Detailed AWS CLI output
- File operations and sync events
- Error tracking and resolution
### File Counts
- Accurate local file counting
- S3 bucket file statistics
- Sync progress tracking
## 🛠️ Development
### Project Structure
```
src/
├── main.ts # Main Electron process
├── preload.ts # Preload script for IPC
├── services/
│ ├── awsS3Service.ts # AWS S3 sync service
│ ├── configManager.ts # Configuration management
│ ├── fileWatcher.ts # Local file system monitoring
│ └── syncManager.ts # Sync orchestration
└── renderer/ # UI components
├── index.html
├── renderer.js
└── styles.css
```
### Available Scripts
- `npm run dev` - Development mode with hot reload
- `npm run build` - Build TypeScript to JavaScript
- `npm run start` - Start the built application
- `npm run package` - Package for distribution
- `npm run install-aws-cli` - Install AWS CLI (macOS)
### Building
```bash
npm run build
npm start
```
## 🚨 Troubleshooting
### Common Issues
**AWS CLI Not Found**
```bash
# Check if AWS CLI is installed
aws --version
# Install if missing
npm run install-aws-cli
```
**Sync Fails to Start**
- Verify S3 credentials in `.env`
- Check network connectivity to Garage instance
- Ensure local sync path exists and is writable
**Files Not Syncing**
- Check file permissions
- Verify S3 bucket access
- Review activity log for error messages
**Performance Issues**
- AWS CLI v2 provides optimal performance
- Consider adjusting sync interval
- Monitor network bandwidth usage
### Debug Mode
Enable detailed logging by setting environment variables:
```bash
DEBUG=* npm run dev
```
## 📊 Performance
- **AWS CLI v2**: Optimized for S3 operations
- **Incremental Sync**: Only transfers changed files
- **Parallel Operations**: Efficient file transfer
- **Memory Management**: Minimal memory footprint
- **Network Optimization**: Intelligent retry and backoff
## 🔒 Security
- **Credential Management**: Secure storage of S3 credentials
- **Local Storage**: Credentials stored locally, never transmitted
- **SSL Support**: Configurable SSL/TLS for S3 endpoints
- **Access Control**: Follows S3 bucket policies and IAM permissions
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📄 License
MIT License - see LICENSE file for details
## 🙏 Acknowledgments
- **AWS CLI**: Powerful S3 sync capabilities
- **Electron**: Cross-platform desktop framework
- **Garage**: Self-hosted S3-compatible storage
- **Rekordbox**: Professional DJ software
---
**Note**: This tool is designed for personal and professional use with Garage S3 storage. Ensure compliance with your organization's data policies and S3 usage guidelines.

View File

@ -0,0 +1,5 @@
# This is a placeholder for the PNG icon
# You can convert the SVG to PNG using:
# - Online tools like convertio.co
# - Command line: convert icon.svg icon.png (if ImageMagick is installed)
# - Or use any image editor that supports SVG import

View File

@ -0,0 +1,36 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3498db;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2980b9;stop-opacity:1" />
</linearGradient>
<linearGradient id="sync" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#27ae60;stop-opacity:1" />
<stop offset="100%" style="stop-color:#2ecc71;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="240" fill="url(#bg)" stroke="#2c3e50" stroke-width="16"/>
<!-- Sync arrows -->
<g fill="url(#sync)">
<!-- Left arrow -->
<path d="M 120 200 L 160 200 L 160 160 L 200 200 L 160 240 L 160 200 Z"/>
<!-- Right arrow -->
<path d="M 392 312 L 352 312 L 352 352 L 312 312 L 352 272 L 352 312 Z"/>
</g>
<!-- Music note -->
<g fill="white">
<ellipse cx="200" cy="280" rx="12" ry="16"/>
<rect x="188" y="240" width="8" height="40" rx="4"/>
<ellipse cx="312" cy="232" rx="12" ry="16"/>
<rect x="300" y="192" width="8" height="40" rx="4"/>
</g>
<!-- Center sync symbol -->
<circle cx="256" cy="256" r="40" fill="none" stroke="white" stroke-width="8" stroke-dasharray="20,10"/>
<circle cx="256" cy="256" r="20" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,29 @@
# Rekordbox Sync Desktop Application Configuration
# Copy this file to .env and fill in your values
# S3 Configuration
S3_ENDPOINT=https://garage.geertrademakers.nl
S3_REGION=garage
S3_ACCESS_KEY_ID=your_access_key_here
S3_SECRET_ACCESS_KEY=your_secret_key_here
S3_BUCKET_NAME=music
S3_USE_SSL=true
# Sync Configuration
SYNC_LOCAL_PATH=/path/to/your/music/folder
SYNC_INTERVAL=30000
SYNC_AUTO_START=false
SYNC_CONFLICT_RESOLUTION=newer-wins
# UI Configuration
UI_THEME=system
UI_LANGUAGE=en
UI_NOTIFICATIONS=true
UI_MINIMIZE_TO_TRAY=true
# Notes:
# - SYNC_INTERVAL is in milliseconds (30000 = 30 seconds)
# - SYNC_CONFLICT_RESOLUTION options: newer-wins, local-wins, remote-wins
# - UI_THEME options: system, light, dark
# - Boolean values: true/false (as strings)
# - Paths should use forward slashes (/) even on Windows

View File

@ -0,0 +1,53 @@
#!/bin/bash
# AWS CLI v2 Installer for macOS
# This script downloads and installs AWS CLI v2 on macOS
set -e
echo "🚀 Installing AWS CLI v2 for macOS..."
# Check if AWS CLI is already installed
if command -v aws &> /dev/null; then
echo "✅ AWS CLI is already installed:"
aws --version
exit 0
fi
# Check if we're on macOS
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "❌ This script is for macOS only. Please install AWS CLI manually for your platform."
exit 1
fi
# Create temporary directory
TEMP_DIR=$(mktemp -d)
cd "$TEMP_DIR"
echo "📥 Downloading AWS CLI v2..."
# Download AWS CLI v2 for macOS
curl -O https://awscli.amazonaws.com/AWSCLIV2.pkg
echo "🔧 Installing AWS CLI v2..."
# Install the package
sudo installer -pkg AWSCLIV2.pkg -target /
# Clean up
cd - > /dev/null
rm -rf "$TEMP_DIR"
echo "✅ AWS CLI v2 installed successfully!"
# Verify installation
if command -v aws &> /dev/null; then
echo "🔍 AWS CLI version:"
aws --version
echo ""
echo "🎉 Installation completed! You can now use the desktop sync tool."
else
echo "❌ Installation failed. Please try installing manually:"
echo " https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
exit 1
fi

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,213 @@
<!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">Status</span>
<span id="syncStatus" class="status-value">Initializing...</span>
</div>
<div class="status-item">
<span class="status-label">Files</span>
<span id="filesSynced" class="status-value">0</span>
</div>
<div class="status-item">
<span class="status-label">Mode</span>
<span id="syncMode" class="status-value">Auto</span>
</div>
<div id="syncDetails" class="sync-details"></div>
</div>
<!-- Control Panel -->
<div class="control-panel">
<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,535 @@
class RekordboxSyncRenderer {
constructor() {
this.initializeElements();
this.setupEventListeners();
this.setupElectronListeners();
this.loadInitialState();
}
/**
* Initialize DOM elements
*/
initializeElements() {
// Buttons
this.exportEnvBtn = document.getElementById('exportEnvBtn');
// Status elements
this.syncStatusElement = document.getElementById('syncStatus');
this.filesSyncedElement = document.getElementById('filesSynced');
this.syncDetailsElement = document.getElementById('syncDetails');
this.syncModeElement = document.getElementById('syncMode');
// 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() {
if (!window.electronAPI) {
console.error('❌ Electron API not available');
return;
}
// Sync status updates
window.electronAPI.on('sync-status-changed', (status) => {
this.updateSyncStatus(status);
});
// 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;
});
// 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'}`);
}
}
/**
* 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 concise information
if (this.syncStatusElement) {
if (status.isRunning) {
let statusText = 'Running';
// Add current phase information (shortened)
if (status.currentPhase) {
const phase = status.currentPhase === 'watching' ? 'Watching' :
status.currentPhase === 'downloading' ? 'Downloading' :
status.currentPhase === 'uploading' ? 'Uploading' :
status.currentPhase === 'completed' ? 'Complete' :
status.currentPhase;
statusText = phase;
}
this.syncStatusElement.textContent = statusText;
this.syncStatusElement.className = 'status-value running';
} else {
this.syncStatusElement.textContent = 'Stopped';
this.syncStatusElement.className = 'status-value stopped';
}
}
// 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 detailed status
this.updateDetailedStatus(status);
// Update phase and progress
if (status.currentPhase && status.progressMessage) {
this.addActivityLog('info', `${status.currentPhase}: ${status.progressMessage}`);
}
// Log progress changes to reduce spam
if (status.progress && status.progress.percent > 0) {
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
}
/**
* Open settings modal
*/
openSettings() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.add('show');
}
}
/**
* Minimize window
*/
minimizeWindow() {
if (window.electronAPI && window.electronAPI.invoke) {
window.electronAPI.invoke('window:minimize');
}
}
/**
* Close window
*/
closeWindow() {
if (window.electronAPI && window.electronAPI.invoke) {
window.electronAPI.invoke('window:close');
}
}
}
// 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,598 @@
/* 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: 1rem 2rem;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
min-width: 140px;
justify-content: center;
}
.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: 2rem;
overflow-y: auto;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto 1fr;
gap: 2rem;
grid-template-areas:
"status control"
"activity activity"
"activity activity";
}
/* Status Panel */
.status-panel {
grid-area: status;
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-around;
align-items: center;
gap: 1.5rem;
}
.status-item {
text-align: center;
flex: 1;
min-width: 0;
}
.status-label {
display: block;
font-size: 0.85rem;
color: #7f8c8d;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.status-value {
display: block;
font-size: 1.3rem;
font-weight: 600;
color: #2c3e50;
line-height: 1.2;
}
#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;
}
/* Control Panel */
.control-panel {
grid-area: control;
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
gap: 1.5rem;
justify-content: center;
align-items: center;
}
/* Activity Panel */
.activity-panel {
grid-area: activity;
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.activity-panel h3 {
margin-bottom: 1.5rem;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.2rem;
}
.activity-log {
flex: 1;
overflow-y: auto;
}
.empty-activity {
text-align: center;
color: #95a5a6;
font-style: italic;
padding: 3rem 2rem;
font-size: 1.1rem;
}
.activity-item {
padding: 1rem;
margin-bottom: 0.75rem;
border-radius: 8px;
background: #f8f9fa;
border-left: 4px solid #95a5a6;
font-size: 0.95rem;
line-height: 1.4;
}
.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,61 @@
#!/usr/bin/env node
/**
* Check if AWS CLI is available on the system
* This script is run during postinstall to ensure AWS CLI is available
*/
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('🔍 Checking AWS CLI availability...');
// Check if AWS CLI is available
function checkAwsCli() {
return new Promise((resolve) => {
const process = spawn('aws', ['--version'], { stdio: 'pipe' });
process.on('close', (code) => {
resolve(code === 0);
});
process.on('error', () => {
resolve(false);
});
});
}
async function main() {
const isAvailable = await checkAwsCli();
if (isAvailable) {
console.log('✅ AWS CLI is available');
// Get version
const versionProcess = spawn('aws', ['--version'], { stdio: 'pipe' });
versionProcess.stdout.on('data', (data) => {
console.log(`📋 Version: ${data.toString().trim()}`);
});
console.log('🎉 You can now use the desktop sync tool with AWS S3!');
} else {
console.log('❌ AWS CLI is not available');
console.log('');
console.log('📋 To install AWS CLI v2:');
console.log('');
console.log(' Option 1: Run the installer script:');
console.log(' npm run install-aws-cli');
console.log('');
console.log(' Option 2: Install manually:');
console.log(' https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html');
console.log('');
console.log(' Option 3: Use Homebrew (macOS):');
console.log(' brew install awscli');
console.log('');
console.log('⚠️ The desktop sync tool requires AWS CLI to function properly.');
console.log(' Please install AWS CLI before using the sync functionality.');
}
}
main().catch(console.error);

View File

@ -0,0 +1,87 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('🔍 Checking MinIO Client installation...');
function checkMinio() {
return new Promise((resolve) => {
const minio = spawn('mc', ['--version'], { stdio: 'pipe' });
let output = '';
let error = '';
minio.stdout.on('data', (data) => {
output += data.toString();
});
minio.stderr.on('data', (data) => {
error += data.toString();
});
minio.on('close', (code) => {
if (code === 0) {
const versionMatch = output.match(/mc version (RELEASE\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)/);
if (versionMatch) {
console.log(`✅ MinIO Client is installed: ${versionMatch[1]}`);
resolve(true);
} else {
console.log('✅ MinIO Client is installed (version unknown)');
resolve(true);
}
} else {
console.log('❌ MinIO Client is not installed or not in PATH');
resolve(false);
}
});
minio.on('error', () => {
console.log('❌ MinIO Client is not installed or not in PATH');
resolve(false);
});
});
}
function showInstallInstructions() {
console.log('\n📥 MinIO Client Installation Instructions:');
console.log('==========================================');
if (process.platform === 'darwin') {
console.log('\n🍎 macOS:');
console.log(' brew install minio/stable/mc');
console.log(' # Or download from: https://min.io/download');
} else if (process.platform === 'win32') {
console.log('\n🪟 Windows:');
console.log(' # Download from: https://min.io/download');
console.log(' # Extract and add to PATH');
} else if (process.platform === 'linux') {
console.log('\n🐧 Linux:');
console.log(' # Ubuntu/Debian:');
console.log(' wget https://dl.min.io/client/mc/release/linux-amd64/mc');
console.log(' chmod +x mc');
console.log(' sudo mv mc /usr/local/bin/');
console.log(' # Or: curl https://dl.min.io/client/mc/release/linux-amd64/mc -o mc && chmod +x mc && sudo mv mc /usr/local/bin/');
}
console.log('\n📚 After installation:');
console.log(' 1. Run: mc alias set garage https://your-garage-endpoint access-key secret-key');
console.log(' 2. Test with: mc ls garage/bucket-name');
console.log('\n🔗 Documentation: https://min.io/docs/minio/linux/reference/minio-mc.html');
}
async function main() {
const isInstalled = await checkMinio();
if (!isInstalled) {
showInstallInstructions();
process.exit(1);
} else {
console.log('\n🎉 MinIO Client is ready to use!');
console.log('💡 You can now run: npm start');
}
}
main().catch(console.error);

View File

@ -0,0 +1,89 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
console.log('🔍 Checking rclone installation...');
function checkRclone() {
return new Promise((resolve) => {
const rclone = spawn('rclone', ['version'], { stdio: 'pipe' });
let output = '';
let error = '';
rclone.stdout.on('data', (data) => {
output += data.toString();
});
rclone.stderr.on('data', (data) => {
error += data.toString();
});
rclone.on('close', (code) => {
if (code === 0) {
const versionMatch = output.match(/rclone v(\d+\.\d+\.\d+)/);
if (versionMatch) {
console.log(`✅ Rclone is installed: ${versionMatch[1]}`);
resolve(true);
} else {
console.log('✅ Rclone is installed (version unknown)');
resolve(true);
}
} else {
console.log('❌ Rclone is not installed or not in PATH');
resolve(false);
}
});
rclone.on('error', () => {
console.log('❌ Rclone is not installed or not in PATH');
resolve(false);
});
});
}
function showInstallInstructions() {
console.log('\n📥 Rclone Installation Instructions:');
console.log('=====================================');
if (process.platform === 'darwin') {
console.log('\n🍎 macOS:');
console.log(' brew install rclone');
console.log(' # Or download from: https://rclone.org/downloads/');
} else if (process.platform === 'win32') {
console.log('\n🪟 Windows:');
console.log(' # Download from: https://rclone.org/downloads/');
console.log(' # Extract and add to PATH');
} else if (process.platform === 'linux') {
console.log('\n🐧 Linux:');
console.log(' # Ubuntu/Debian:');
console.log(' curl https://rclone.org/install.sh | sudo bash');
console.log(' # Or: sudo apt install rclone');
console.log(' # CentOS/RHEL:');
console.log(' sudo yum install rclone');
}
console.log('\n📚 After installation:');
console.log(' 1. Run: rclone config');
console.log(' 2. Create a new remote named "music"');
console.log(' 3. Choose S3 provider');
console.log(' 4. Enter your Garage S3 credentials');
console.log('\n🔗 Documentation: https://rclone.org/s3/');
}
async function main() {
const isInstalled = await checkRclone();
if (!isInstalled) {
showInstallInstructions();
process.exit(1);
} else {
console.log('\n🎉 Rclone is ready to use!');
console.log('💡 You can now run: npm start');
}
}
main().catch(console.error);

View File

@ -0,0 +1,152 @@
#!/bin/bash
# Rekordbox Sync .env Setup Script
echo "🔧 Setting up Rekordbox Sync .env configuration file..."
# Check if .env already exists
if [ -f ".env" ]; then
echo "⚠️ .env file already exists!"
read -p "Do you want to overwrite it? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "❌ Setup cancelled."
exit 1
fi
fi
# Get S3 configuration
echo ""
echo "🌐 S3 Configuration:"
read -p "S3 Endpoint (default: https://garage.geertrademakers.nl): " s3_endpoint
s3_endpoint=${s3_endpoint:-https://garage.geertrademakers.nl}
read -p "S3 Region (default: garage): " s3_region
s3_region=${s3_region:-garage}
read -p "S3 Access Key ID: " s3_access_key
if [ -z "$s3_access_key" ]; then
echo "❌ S3 Access Key ID is required!"
exit 1
fi
read -s -p "S3 Secret Access Key: " s3_secret_key
echo
if [ -z "$s3_secret_key" ]; then
echo "❌ S3 Secret Access Key is required!"
exit 1
fi
read -p "S3 Bucket Name (default: music): " s3_bucket
s3_bucket=${s3_bucket:-music}
read -p "Use SSL? (Y/n): " -n 1 -r
echo
s3_use_ssl="true"
if [[ $REPLY =~ ^[Nn]$ ]]; then
s3_use_ssl="false"
fi
# Get sync configuration
echo ""
echo "🔄 Sync Configuration:"
read -p "Local Music Folder Path: " sync_local_path
if [ -z "$sync_local_path" ]; then
echo "❌ Local music folder path is required!"
exit 1
fi
read -p "Sync Interval in seconds (default: 30): " sync_interval
sync_interval=${sync_interval:-30}
sync_interval=$((sync_interval * 1000)) # Convert to milliseconds
read -p "Auto-start sync on app launch? (y/N): " -n 1 -r
echo
sync_auto_start="false"
if [[ $REPLY =~ ^[Yy]$ ]]; then
sync_auto_start="true"
fi
echo ""
echo "Conflict Resolution Strategy:"
echo "1) newer-wins (recommended)"
echo "2) local-wins"
echo "3) remote-wins"
read -p "Choose strategy (1-3, default: 1): " conflict_resolution
case $conflict_resolution in
2) conflict_resolution="local-wins" ;;
3) conflict_resolution="remote-wins" ;;
*) conflict_resolution="newer-wins" ;;
esac
# Get UI configuration
echo ""
echo "🎨 UI Configuration:"
echo "Theme options:"
echo "1) system (follows OS theme)"
echo "2) light"
echo "3) dark"
read -p "Choose theme (1-3, default: 1): " ui_theme
case $ui_theme in
2) ui_theme="light" ;;
3) ui_theme="dark" ;;
*) ui_theme="system" ;;
esac
read -p "Show notifications? (Y/n): " -n 1 -r
echo
ui_notifications="true"
if [[ $REPLY =~ ^[Nn]$ ]]; then
ui_notifications="false"
fi
read -p "Minimize to system tray? (Y/n): " -n 1 -r
echo
ui_minimize_to_tray="true"
if [[ $REPLY =~ ^[Nn]$ ]]; then
ui_minimize_to_tray="false"
fi
# Create .env file
echo ""
echo "📝 Creating .env file..."
cat > .env << EOF
# Rekordbox Sync Desktop Application Configuration
# Generated on $(date)
# S3 Configuration
S3_ENDPOINT=$s3_endpoint
S3_REGION=$s3_region
S3_ACCESS_KEY_ID=$s3_access_key
S3_SECRET_ACCESS_KEY=$s3_secret_key
S3_BUCKET_NAME=$s3_bucket
S3_USE_SSL=$s3_use_ssl
# Sync Configuration
SYNC_LOCAL_PATH=$sync_local_path
SYNC_INTERVAL=$sync_interval
SYNC_AUTO_START=$sync_auto_start
SYNC_CONFLICT_RESOLUTION=$conflict_resolution
# UI Configuration
UI_THEME=$ui_theme
UI_LANGUAGE=en
UI_NOTIFICATIONS=$ui_notifications
UI_MINIMIZE_TO_TRAY=$ui_minimize_to_tray
EOF
echo "✅ .env file created successfully!"
echo ""
echo "🔍 Configuration summary:"
echo " S3 Endpoint: $s3_endpoint"
echo " S3 Region: $s3_region"
echo " S3 Bucket: $s3_bucket"
echo " Local Path: $sync_local_path"
echo " Sync Interval: $((sync_interval / 1000)) seconds"
echo " Auto-start: $sync_auto_start"
echo " Conflict Resolution: $conflict_resolution"
echo " Theme: $ui_theme"
echo ""
echo "🚀 You can now start the application with: npm run dev"
echo "📖 The .env file will be automatically loaded on startup."

View File

@ -0,0 +1,703 @@
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: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
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' };
}
});
// Window controls
ipcMain.handle('window:minimize', () => {
if (this.mainWindow) {
this.mainWindow.minimize();
}
});
ipcMain.handle('window:close', () => {
if (this.mainWindow) {
this.mainWindow.close();
}
});
// 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()
};
console.log('📤 Sending to renderer:', uiState);
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,82 @@
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',
'window:minimize',
'window:close',
'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,717 @@
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', '**/.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();
console.log('🔍 AWS S3 Service emitting status:', status);
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"
]
}

View File

@ -27,7 +27,7 @@ import { api } from '../services/api';
interface JobProgress {
jobId: string;
type: 'storage-sync' | 'song-matching';
type: 's3-sync' | 'song-matching';
status: 'running' | 'completed' | 'failed';
progress: number;
current: number;
@ -56,42 +56,98 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
const { isOpen, onClose } = useDisclosure();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Simple polling function
const pollJobs = async () => {
// Load all jobs
const loadJobs = async () => {
try {
setLoading(true);
const jobsData = await api.getAllJobs();
setJobs(jobsData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load jobs');
} finally {
setLoading(false);
}
};
// Update specific job progress
const updateJobProgress = async (jobId: string) => {
try {
const progress = await api.getJobProgress(jobId);
setJobs(prev => prev.map(job =>
job.jobId === jobId ? progress : job
));
// Handle job completion
if (progress.status === 'completed' && onJobComplete) {
onJobComplete(progress.result);
} else if (progress.status === 'failed' && onJobError) {
onJobError(progress.error || 'Job failed');
}
} catch (err) {
console.error('Error updating job progress:', err);
}
};
// Start polling for jobs and update progress
const startPolling = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
const tick = async () => {
try {
// Always reload job list to detect newly started jobs
const jobsData = await api.getAllJobs();
setJobs(jobsData);
// Handle job completion for the specific job if provided
// Update progress for active jobs
const activeJobIds = jobsData.filter((j: JobProgress) => j.status === 'running').map((j: JobProgress) => j.jobId);
for (const id of activeJobIds) {
await updateJobProgress(id);
}
if (jobId) {
const specificJob = jobsData.find((j: JobProgress) => j.jobId === jobId);
if (specificJob) {
if (specificJob.status === 'completed' && onJobComplete) {
onJobComplete(specificJob.result);
} else if (specificJob.status === 'failed' && onJobError) {
onJobError(specificJob.error || 'Job failed');
}
}
await updateJobProgress(jobId);
}
} catch (err) {
// ignore transient polling errors
}
};
// Adaptive interval: 2s if active jobs, else 10s
const schedule = async () => {
await tick();
const hasActive = (jobs || []).some(j => j.status === 'running');
const delay = hasActive ? 2000 : 10000;
intervalRef.current = setTimeout(schedule, delay) as any;
};
schedule();
};
// Stop polling
const stopPolling = () => {
if (intervalRef.current) {
clearTimeout(intervalRef.current as any);
intervalRef.current = null;
}
};
// Start polling on mount and stop on unmount
useEffect(() => {
// Initial poll
pollJobs();
// Set up interval polling
const interval = setInterval(() => {
pollJobs();
}, 10000); // Simple 10-second interval
loadJobs();
startPolling();
return () => stopPolling();
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
clearInterval(interval);
stopPolling();
};
}, [jobId, onJobComplete, onJobError]);
}, []);
const getStatusColor = (status: string) => {
switch (status) {
@ -152,7 +208,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
<Box key={job.jobId} p={3} bg="gray.700" borderRadius="md">
<Flex justify="space-between" align="center" mb={2}>
<Text fontSize="sm" fontWeight="medium" color="gray.100">
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
{job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'}
</Text>
<Badge colorScheme={getStatusColor(job.status)} size="sm">
{job.status}
@ -190,7 +246,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
<VStack spacing={4} align="stretch">
<HStack justify="space-between">
<Text fontWeight="bold" color="gray.100">All Jobs</Text>
<Button size="sm" onClick={() => pollJobs()} isLoading={loading}>
<Button size="sm" onClick={loadJobs} isLoading={loading}>
Refresh
</Button>
</HStack>
@ -216,7 +272,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
<Tr key={job.jobId}>
<Td>
<Text fontSize="sm" color="gray.100">
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
{job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'}
</Text>
</Td>
<Td>

View File

@ -162,12 +162,12 @@ export const DuplicatesViewer: React.FC = () => {
}}
/>
</Tooltip>
<Tooltip label="Merge duplicates (keeps target, removes others from playlists only)">
<Tooltip label="Delete other duplicates (optionally remove music files)">
<IconButton
aria-label="Merge duplicates"
icon={<FiCheck />}
aria-label="Delete duplicates"
icon={<FiTrash2 />}
size="sm"
colorScheme="blue"
colorScheme="red"
variant="outline"
isLoading={processingGroupKey === group.key}
onClick={async () => {
@ -175,7 +175,7 @@ export const DuplicatesViewer: React.FC = () => {
try {
const targetId = it.songId;
const others = group.items.map(x => x.songId).filter(id => id !== targetId);
// Merge playlists (safe), but don't delete songs or music files
// First merge playlists (safe), then delete redundant songs and optionally their music files
const allPlaylists = await api.getPlaylists();
const updated = allPlaylists.map(p => {
if (p.type === 'playlist') {
@ -196,7 +196,7 @@ export const DuplicatesViewer: React.FC = () => {
return p;
});
await api.savePlaylists(updated as any);
// Note: We don't call deleteDuplicateSongs anymore to keep it read-only
await api.deleteDuplicateSongs(targetId, others, true);
await loadDuplicates(minGroupSize);
} finally {
setProcessingGroupKey(null);

View File

@ -249,9 +249,6 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
<Text fontSize="sm" color="gray.400">
Supports MP3, WAV, FLAC, AAC, OGG, WMA, Opus (max 100MB per file)
</Text>
<Text fontSize="xs" color="gray.500">
Original filenames and metadata will be preserved
</Text>
</VStack>
</Box>

View File

@ -12,13 +12,8 @@ import {
Input,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
} from '@chakra-ui/react';
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
import { Search2Icon } from '@chakra-ui/icons';
import { FiPlay } from 'react-icons/fi';
import type { Song, PlaylistNode } from '../types/interfaces';
import { api } from '../services/api';
@ -136,7 +131,7 @@ const SongItem = memo<{
</Box>
<Box textAlign="right" ml={2}>
<Text fontSize="xs" color="gray.500">
{formattedDuration}{song.tonality ? ` - ${song.tonality}` : ''}
{formattedDuration}
</Text>
<Text fontSize="xs" color="gray.600">
{song.averageBpm} BPM
@ -208,13 +203,6 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
onLoadMoreRef.current = onLoadMore;
}, [hasMore, loading, onLoadMore]);
// Clear selection when switching playlists
useEffect(() => {
if (isSwitchingPlaylist) {
setSelectedSongs(new Set());
}
}, [isSwitchingPlaylist]);
// Debounce search to prevent excessive API calls
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
@ -534,32 +522,27 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="blue"
rightIcon={<ChevronDownIcon />}
onClick={onPlaylistModalOpen}
>
Actions
</MenuButton>
<MenuList>
<MenuItem onClick={onPlaylistModalOpen}>
Add to Playlist...
</MenuItem>
</Button>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={handleBulkRemoveFromPlaylist}
<Button
size="sm"
variant="outline"
colorScheme="red"
onClick={() => {
handleBulkRemoveFromPlaylist();
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
</Button>
)}
</MenuList>
</Menu>
</HStack>
)}
</Flex>
</Box>

View File

@ -19,7 +19,7 @@ import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
import { FiPlay, FiMusic } from 'react-icons/fi';
import type { Song, PlaylistNode } from "../types/interfaces";
import { useState, useCallback, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo } from "react";
import { formatDuration, formatTotalDuration } from '../utils/formatters';
@ -33,7 +33,6 @@ interface SongListProps {
currentPlaylist: string | null;
depth?: number;
onPlaySong?: (song: Song) => void;
isSwitchingPlaylist?: boolean;
}
export const SongList: React.FC<SongListProps> = ({
@ -45,19 +44,11 @@ export const SongList: React.FC<SongListProps> = ({
selectedSongId,
currentPlaylist,
depth = 0,
onPlaySong,
isSwitchingPlaylist = false
onPlaySong
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
// Clear selection when switching playlists
useEffect(() => {
if (isSwitchingPlaylist) {
setSelectedSongs(new Set());
}
}, [isSwitchingPlaylist]);
// Helper function to get all playlists (excluding folders) from the playlist tree
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
let result: PlaylistNode[] = [];
@ -279,7 +270,7 @@ export const SongList: React.FC<SongListProps> = ({
fontSize={depth > 0 ? "xs" : "sm"}
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
>
{song.artist} {formatDuration(song.totalTime)}{song.tonality ? ` - ${song.tonality}` : ''}
{song.artist} {formatDuration(song.totalTime)}
</Text>
{song.location && (
<Text

View File

@ -261,7 +261,7 @@ export const SongMatching: React.FC = () => {
<CardBody>
<VStack spacing={4}>
<Text color="gray.300" textAlign="center">
Try linking any remaining unmatched files. The main storage sync already performs matching; use this for leftovers.
Try linking any remaining unmatched files. The main S3 sync already performs matching; use this for leftovers.
</Text>
<Button
leftIcon={<FiZap />}

View File

@ -29,12 +29,12 @@ import { FiDatabase, FiSettings, FiUpload, FiLink, FiRefreshCw, FiTrash2 } from
import { useNavigate } from "react-router-dom";
import { useXmlParser } from "../hooks/useXmlParser";
import { StyledFileInput } from "../components/StyledFileInput";
import { StorageConfiguration } from "./StorageConfiguration";
import { S3Configuration } from "./S3Configuration";
import { MusicUpload } from "../components/MusicUpload";
import { SongMatching } from "../components/SongMatching";
import { api } from "../services/api";
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo } from "react";
interface MusicFile {
_id: string;
@ -63,27 +63,10 @@ export function Configuration() {
return stored ? parseInt(stored, 10) : 0;
}, []);
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
const [storageProvider, setStorageProvider] = useState<string>('Storage');
// No explicit tab index enum needed
// Load current storage provider for dynamic button labels
useEffect(() => {
const loadStorageProvider = async () => {
try {
const response = await fetch('/api/config/storage');
if (response.ok) {
const data = await response.json();
setStorageProvider(data.config.provider.toUpperCase());
}
} catch (error) {
console.error('Failed to load storage provider:', error);
}
};
loadStorageProvider();
}, []);
// Storage config fetch removed; Sync buttons remain available in the panel
// S3 config fetch removed; Sync buttons remain available in the panel
// Removed Music Library sync handlers from Config (moved to Sync & Matching panel)
@ -196,7 +179,7 @@ export function Configuration() {
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiSettings} />
<Text>Storage Configuration</Text>
<Text>S3 Configuration</Text>
</HStack>
</Tab>
</TabList>
@ -253,8 +236,8 @@ export function Configuration() {
Upload Music Files
</Heading>
<Text color="gray.400" mb={4}>
Drag and drop your music files here or click to select. Files will be uploaded to your configured storage
(S3 or WebDAV) and metadata will be automatically extracted.
Drag and drop your music files here or click to select. Files will be uploaded to S3 storage
and metadata will be automatically extracted.
</Text>
</Box>
<MusicUpload onUploadComplete={handleUploadComplete} />
@ -266,21 +249,21 @@ export function Configuration() {
{/* Song Matching Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={6} align="stretch">
<Heading size="md" color="white">Sync and Matching ({storageProvider})</Heading>
<Heading size="md" color="white">Sync and Matching</Heading>
<HStack spacing={3}>
<Button
leftIcon={<FiRefreshCw />}
colorScheme="blue"
variant="solid"
onClick={() => api.startStorageSync()}
onClick={() => api.startS3Sync()}
>
Sync (incremental)
Sync S3 (incremental)
</Button>
<Button
leftIcon={<FiRefreshCw />}
colorScheme="orange"
variant="outline"
onClick={() => api.startStorageSync({ force: true })}
onClick={() => api.startS3Sync({ force: true })}
>
Force Sync (rescan all)
</Button>
@ -288,7 +271,7 @@ export function Configuration() {
leftIcon={<FiTrash2 />}
colorScheme="red"
variant="outline"
onClick={() => api.startStorageSync({ clearLinks: true, force: true })}
onClick={() => api.startS3Sync({ clearLinks: true, force: true })}
>
Clear Links + Force Sync
</Button>
@ -302,10 +285,10 @@ export function Configuration() {
<DuplicatesViewer />
</TabPanel>
{/* Storage Configuration Tab */}
{/* S3 Configuration Tab */}
<TabPanel bg="gray.800" p={0}>
<Box p={6}>
<StorageConfiguration />
<S3Configuration />
</Box>
</TabPanel>
</TabPanels>

View File

@ -1,702 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
FormControl,
FormLabel,
Input,
Button,
useToast,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Card,
CardBody,
CardHeader,
Spinner,
Badge,
Icon,
Switch,
FormHelperText,
Select,
Divider,
} from '@chakra-ui/react';
import { FiSettings, FiZap, FiSave, FiCloud, FiServer } from 'react-icons/fi';
interface S3Config {
provider: 's3';
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
useSSL: boolean;
}
interface WebDAVConfig {
provider: 'webdav';
url: string;
username: string;
password: string;
basePath?: string;
}
type StorageConfig = S3Config | WebDAVConfig;
interface TestResult {
success: boolean;
message: string;
details?: any;
provider?: string;
}
export const StorageConfiguration: React.FC = () => {
const [config, setConfig] = useState<StorageConfig>({
provider: 's3',
endpoint: '',
region: 'us-east-1',
accessKeyId: '',
secretAccessKey: '',
bucketName: '',
useSSL: true,
});
const [isLoading, setIsLoading] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [currentConfig, setCurrentConfig] = useState<StorageConfig | null>(null);
const toast = useToast();
// Load current configuration on component mount
useEffect(() => {
loadCurrentConfig();
}, []);
const loadCurrentConfig = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/config/storage');
if (response.ok) {
const data = await response.json();
setCurrentConfig(data.config);
// Handle masked passwords - don't set masked values as initial state
const configWithEmptyPasswords = { ...data.config };
if (configWithEmptyPasswords.provider === 'webdav' && configWithEmptyPasswords.password === '***') {
configWithEmptyPasswords.password = '';
}
if (configWithEmptyPasswords.provider === 's3') {
if (configWithEmptyPasswords.accessKeyId === '***') {
configWithEmptyPasswords.accessKeyId = '';
}
if (configWithEmptyPasswords.secretAccessKey === '***') {
configWithEmptyPasswords.secretAccessKey = '';
}
}
setConfig(configWithEmptyPasswords);
}
} catch (error) {
console.error('Error loading storage config:', error);
} finally {
setIsLoading(false);
}
};
const handleProviderChange = (provider: 's3' | 'webdav') => {
if (provider === 's3') {
setConfig({
provider: 's3',
endpoint: '',
region: 'us-east-1',
accessKeyId: '',
secretAccessKey: '',
bucketName: '',
useSSL: true,
});
} else {
setConfig({
provider: 'webdav',
url: '',
username: '',
password: '',
basePath: '/music-files',
});
}
};
const handleInputChange = (field: string, value: string | boolean) => {
setConfig(prev => ({
...prev,
[field]: value,
}));
};
const testConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
// For testing, merge current form state with existing config to preserve passwords
const testConfig = { ...currentConfig, ...config };
const response = await fetch('/api/config/storage/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(testConfig),
});
const result = await response.json();
if (response.ok) {
setTestResult({
success: true,
message: 'Connection successful!',
details: result,
provider: testConfig.provider,
});
toast({
title: 'Connection Test Successful',
description: `${testConfig.provider.toUpperCase()} connection is working properly`,
status: 'success',
duration: 5000,
isClosable: true,
});
} else {
setTestResult({
success: false,
message: result.error || 'Connection failed',
details: result,
provider: testConfig.provider,
});
toast({
title: 'Connection Test Failed',
description: result.error || `Failed to connect to ${testConfig.provider.toUpperCase()}`,
status: 'error',
duration: 5000,
isClosable: true,
});
}
} catch (error) {
console.error('Error testing storage connection:', error);
setTestResult({
success: false,
message: 'Network error or server unavailable',
provider: testConfig.provider,
});
toast({
title: 'Connection Test Failed',
description: 'Network error or server unavailable',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsTesting(false);
}
};
const saveConfiguration = async () => {
setIsSaving(true);
try {
// Always send the complete configuration
const configToSave = { ...config };
const response = await fetch('/api/config/storage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(configToSave),
});
if (response.ok) {
setCurrentConfig(config);
toast({
title: 'Configuration Saved',
description: `${config.provider.toUpperCase()} configuration has been saved successfully`,
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to save configuration');
}
} catch (error) {
console.error('Error saving storage config:', error);
toast({
title: 'Save Failed',
description: error instanceof Error ? error.message : 'Failed to save configuration',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsSaving(false);
}
};
const hasChanges = () => {
if (!currentConfig) return true;
// Create a copy of currentConfig with masked values replaced with empty strings for comparison
const normalizedCurrentConfig = { ...currentConfig };
if (normalizedCurrentConfig.provider === 'webdav' && normalizedCurrentConfig.password === '***') {
normalizedCurrentConfig.password = '';
}
if (normalizedCurrentConfig.provider === 's3') {
if (normalizedCurrentConfig.accessKeyId === '***') {
normalizedCurrentConfig.accessKeyId = '';
}
if (normalizedCurrentConfig.secretAccessKey === '***') {
normalizedCurrentConfig.secretAccessKey = '';
}
}
return JSON.stringify(config) !== JSON.stringify(normalizedCurrentConfig);
};
const renderS3Config = () => (
<VStack spacing={6} align="stretch">
{/* Endpoint */}
<FormControl>
<FormLabel color="white">S3 Endpoint</FormLabel>
<Input
value={config.provider === 's3' ? config.endpoint : ''}
onChange={(e) => handleInputChange('endpoint', e.target.value)}
placeholder="https://s3.amazonaws.com or http://localhost:9000 for MinIO"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
For AWS S3, use: https://s3.amazonaws.com. For MinIO: http://localhost:9000
</FormHelperText>
</FormControl>
{/* Region */}
<FormControl>
<FormLabel color="white">Region</FormLabel>
<Input
value={config.provider === 's3' ? config.region : ''}
onChange={(e) => handleInputChange('region', e.target.value)}
placeholder="us-east-1"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
AWS region (e.g., us-east-1, eu-west-1) or 'us-east-1' for MinIO
</FormHelperText>
</FormControl>
{/* Access Key */}
<FormControl>
<FormLabel color="white">Access Key ID</FormLabel>
<Input
value={config.provider === 's3' ? config.accessKeyId : ''}
onChange={(e) => handleInputChange('accessKeyId', e.target.value)}
placeholder="Your S3 access key"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
</FormControl>
{/* Secret Key */}
<FormControl>
<FormLabel color="white">Secret Access Key</FormLabel>
<Input
type="password"
value={config.provider === 's3' ? config.secretAccessKey : ''}
onChange={(e) => handleInputChange('secretAccessKey', e.target.value)}
placeholder="Your S3 secret key"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
</FormControl>
{/* Bucket Name */}
<FormControl>
<FormLabel color="white">Bucket Name</FormLabel>
<Input
value={config.provider === 's3' ? config.bucketName : ''}
onChange={(e) => handleInputChange('bucketName', e.target.value)}
placeholder="music-files"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
The S3 bucket where music files will be stored
</FormHelperText>
</FormControl>
{/* Use SSL */}
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="use-ssl" mb="0" color="white">
Use SSL/TLS
</FormLabel>
<Switch
id="use-ssl"
isChecked={config.provider === 's3' ? config.useSSL : false}
onChange={(e) => handleInputChange('useSSL', e.target.checked)}
colorScheme="blue"
/>
<FormHelperText color="gray.400" ml={3}>
Enable for HTTPS connections (recommended for production)
</FormHelperText>
</FormControl>
</VStack>
);
const renderWebDAVConfig = () => (
<VStack spacing={6} align="stretch">
{/* URL */}
<FormControl>
<FormLabel color="white">WebDAV URL</FormLabel>
<Input
value={config.provider === 'webdav' ? config.url : ''}
onChange={(e) => handleInputChange('url', e.target.value)}
placeholder="https://your-nextcloud.com/remote.php/dav/files/username/"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
Your Nextcloud WebDAV URL (usually ends with /remote.php/dav/files/username/)
</FormHelperText>
</FormControl>
{/* Username */}
<FormControl>
<FormLabel color="white">Username</FormLabel>
<Input
value={config.provider === 'webdav' ? config.username : ''}
onChange={(e) => handleInputChange('username', e.target.value)}
placeholder="Your Nextcloud username"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
</FormControl>
{/* Password */}
<FormControl>
<FormLabel color="white">Password</FormLabel>
<Input
type="password"
value={config.provider === 'webdav' ? config.password : ''}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder="Your Nextcloud password or app password"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
Use your Nextcloud password or create an app password for better security
</FormHelperText>
</FormControl>
{/* Base Path */}
<FormControl>
<FormLabel color="white">Base Path (Optional)</FormLabel>
<Input
value={config.provider === 'webdav' ? config.basePath || '' : ''}
onChange={(e) => handleInputChange('basePath', e.target.value)}
placeholder="/music-files"
bg="gray.700"
borderColor="gray.600"
color="white"
_placeholder={{ color: 'gray.400' }}
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
/>
<FormHelperText color="gray.400">
Subfolder within your WebDAV storage where music files will be stored
</FormHelperText>
</FormControl>
</VStack>
);
if (isLoading) {
return (
<Box p={8}>
<VStack spacing={4} align="center">
<Spinner size="xl" />
<Text>Loading storage configuration...</Text>
</VStack>
</Box>
);
}
return (
<Box p={8} maxW="800px" mx="auto">
<VStack spacing={8} align="stretch">
{/* Header */}
<VStack spacing={2} align="start">
<HStack spacing={3}>
<Icon as={FiSettings} w={6} h={6} color="blue.400" />
<Heading size="lg" color="white">Storage Configuration</Heading>
</HStack>
<Text color="gray.400">
Configure your storage provider for music file storage and playback. Choose between S3-compatible storage or WebDAV (Nextcloud).
</Text>
</VStack>
{/* Provider Selection */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<Heading size="md" color="white">Storage Provider</Heading>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<FormControl>
<FormLabel color="white">Select Storage Provider</FormLabel>
<Select
value={config.provider}
onChange={(e) => handleProviderChange(e.target.value as 's3' | 'webdav')}
bg="gray.700"
borderColor="gray.600"
color="white"
_focus={{ borderColor: 'blue.400', boxShadow: 'none' }}
>
<option value="s3">S3-Compatible Storage (AWS S3, MinIO, etc.)</option>
<option value="webdav">WebDAV (Nextcloud, ownCloud, etc.)</option>
</Select>
</FormControl>
<HStack spacing={4} align="center">
<Icon
as={config.provider === 's3' ? FiCloud : FiServer}
w={5} h={5}
color={config.provider === 's3' ? 'blue.400' : 'green.400'}
/>
<Text color="gray.400">
{config.provider === 's3'
? 'S3-compatible storage for scalable cloud storage'
: 'WebDAV for self-hosted solutions like Nextcloud'
}
</Text>
</HStack>
</VStack>
</CardBody>
</Card>
{/* Configuration Form */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<Heading size="md" color="white">
{config.provider === 's3' ? 'S3 Configuration' : 'WebDAV Configuration'}
</Heading>
</CardHeader>
<CardBody>
{config.provider === 's3' ? renderS3Config() : renderWebDAVConfig()}
</CardBody>
</Card>
{/* Test Connection */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiZap} w={5} h={5} color="blue.400" />
<Heading size="md" color="white">Test Connection</Heading>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Text color="gray.400">
Test your {config.provider.toUpperCase()} configuration to ensure it's working properly before saving.
</Text>
<Button
leftIcon={isTesting ? <Spinner size="sm" /> : <FiZap />}
onClick={testConnection}
isLoading={isTesting}
loadingText="Testing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Test Connection
</Button>
{testResult && (
<Alert
status={testResult.success ? 'success' : 'error'}
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
height="auto"
py={4}
>
<AlertIcon boxSize="24px" />
<AlertTitle mt={2} mb={1} fontSize="lg">
{testResult.success ? 'Connection Successful' : 'Connection Failed'}
</AlertTitle>
<AlertDescription maxWidth="sm">
{testResult.message}
</AlertDescription>
{testResult.details && (
<Box mt={2} p={3} bg="gray.700" borderRadius="md" fontSize="sm">
<Text color="gray.300" fontWeight="bold">Details:</Text>
<Text color="gray.400" fontFamily="mono">
{JSON.stringify(testResult.details, null, 2)}
</Text>
</Box>
)}
</Alert>
)}
</VStack>
</CardBody>
</Card>
{/* Save Configuration */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiSave} w={5} h={5} color="green.400" />
<Heading size="md" color="white">Save Configuration</Heading>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Text color="gray.400">
Save your {config.provider.toUpperCase()} configuration to use it for music file storage and playback.
</Text>
<HStack spacing={3}>
<Button
leftIcon={isSaving ? <Spinner size="sm" /> : <FiSave />}
onClick={saveConfiguration}
isLoading={isSaving}
loadingText="Saving..."
colorScheme="green"
isDisabled={!hasChanges()}
_hover={{ bg: "green.700" }}
>
Save Configuration
</Button>
{currentConfig && (
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
Configuration Loaded
</Badge>
)}
</HStack>
{!hasChanges() && currentConfig && (
<Alert status="info" variant="subtle">
<AlertIcon />
<Text color="gray.300">No changes to save</Text>
</Alert>
)}
</VStack>
</CardBody>
</Card>
{/* Help Section */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<Heading size="md" color="white">Configuration Help</Heading>
</CardHeader>
<CardBody>
<VStack spacing={6} align="stretch">
{/* S3 Help */}
<Box>
<Text color="gray.400" fontWeight="bold" mb={2}>
<Icon as={FiCloud} w={4} h={4} mr={2} />
S3-Compatible Storage
</Text>
<Box pl={4} borderLeft="2px" borderColor="gray.600">
<Text color="gray.400" fontSize="sm" mb={2}>
<strong>For AWS S3:</strong>
</Text>
<Text color="gray.400" fontSize="sm" pl={2}>
Endpoint: https://s3.amazonaws.com<br/>
Region: Your AWS region (e.g., us-east-1)<br/>
Access Key: Your AWS access key<br/>
Secret Key: Your AWS secret key<br/>
Bucket: Your S3 bucket name
</Text>
<Text color="gray.400" fontSize="sm" mt={3} mb={2}>
<strong>For MinIO (Local Development):</strong>
</Text>
<Text color="gray.400" fontSize="sm" pl={2}>
Endpoint: http://localhost:9000<br/>
Region: us-east-1<br/>
Access Key: minioadmin<br/>
Secret Key: minioadmin<br/>
Bucket: Create a bucket named 'music-files'
</Text>
</Box>
</Box>
<Divider borderColor="gray.600" />
{/* WebDAV Help */}
<Box>
<Text color="gray.400" fontWeight="bold" mb={2}>
<Icon as={FiServer} w={4} h={4} mr={2} />
WebDAV (Nextcloud/ownCloud)
</Text>
<Box pl={4} borderLeft="2px" borderColor="gray.600">
<Text color="gray.400" fontSize="sm" mb={2}>
<strong>For Nextcloud:</strong>
</Text>
<Text color="gray.400" fontSize="sm" pl={2}>
URL: https://your-nextcloud.com/remote.php/dav/files/username/<br/>
Username: Your Nextcloud username<br/>
Password: Your Nextcloud password or app password<br/>
Base Path: /music-files (optional subfolder)
</Text>
<Text color="gray.400" fontSize="sm" mt={3} mb={2}>
<strong>For ownCloud:</strong>
</Text>
<Text color="gray.400" fontSize="sm" pl={2}>
URL: https://your-owncloud.com/remote.php/dav/files/username/<br/>
Username: Your ownCloud username<br/>
Password: Your ownCloud password or app password<br/>
Base Path: /music-files (optional subfolder)
</Text>
<Text color="gray.400" fontSize="sm" mt={3}>
<strong>Note:</strong> For better security, create an app password in your Nextcloud/ownCloud settings instead of using your main password.
</Text>
</Box>
</Box>
</VStack>
</CardBody>
</Card>
</VStack>
</Box>
);
};

View File

@ -142,7 +142,7 @@ class Api {
}
// Background job methods
async startBackgroundJob(type: 'storage-sync' | 'song-matching', options?: any): Promise<{ jobId: string; type: string }> {
async startBackgroundJob(type: 's3-sync' | 'song-matching', options?: any): Promise<{ jobId: string; type: string }> {
const response = await fetch(`${API_BASE_URL}/background-jobs/start`, {
method: 'POST',
headers: {
@ -155,13 +155,13 @@ class Api {
return response.json();
}
async startStorageSync(options?: { force?: boolean; clearLinks?: boolean }): Promise<{ jobId: string; type: string }> {
async startS3Sync(options?: { force?: boolean; clearLinks?: boolean }): Promise<{ jobId: string; type: string }> {
const response = await fetch(`${API_BASE_URL}/music/sync-s3`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options || {})
});
if (!response.ok) throw new Error('Failed to start storage sync');
if (!response.ok) throw new Error('Failed to start S3 sync');
return response.json();
}
@ -205,7 +205,15 @@ class Api {
return response.json();
}
// deleteDuplicateSongs method removed to keep WebDAV integration read-only
async deleteDuplicateSongs(targetSongId: string, redundantSongIds: string[], deleteMusicFiles: boolean): Promise<{ deletedSongs: number; unlinkedOrDeletedMusicFiles: number; }>{
const response = await fetch(`${API_BASE_URL}/songs/delete-duplicates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetSongId, redundantSongIds, deleteMusicFiles })
});
if (!response.ok) throw new Error('Failed to delete duplicates');
return response.json();
}
}
export const api = new Api();