Compare commits
4 Commits
main
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c714124b | ||
|
|
b6e961dc84 | ||
|
|
39b7fb59aa | ||
|
|
73d9a41ca8 |
30
README.md
30
README.md
@ -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
|
- **Playlist Management**: Create, edit, and organize playlists and folders
|
||||||
- **Song Details**: View detailed information about tracks including BPM, key, rating, etc.
|
- **Song Details**: View detailed information about tracks including BPM, key, rating, etc.
|
||||||
- **Export Functionality**: Export modified libraries back to XML format
|
- **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
|
- **Responsive Design**: Works on desktop and mobile devices
|
||||||
- **Database Storage**: Persistent storage using MongoDB
|
- **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`
|
- Development: `packages/frontend/src/services/api.ts`
|
||||||
- Production: Environment variable `VITE_API_URL` in Docker
|
- 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
|
## 📊 API Endpoints
|
||||||
|
|
||||||
- `GET /api/health` - Health check
|
- `GET /api/health` - Health check
|
||||||
|
|||||||
@ -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
270
package-lock.json
generated
@ -1180,14 +1180,6 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@chakra-ui/accordion": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@chakra-ui/accordion/-/accordion-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@chakra-ui/accordion/-/accordion-2.3.1.tgz",
|
||||||
@ -4269,13 +4261,9 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@ -4440,11 +4428,6 @@
|
|||||||
"node": ">=10.16.0"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@ -4527,14 +4510,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@ -4741,14 +4716,6 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/css-box-model": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||||
@ -4763,14 +4730,6 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/date-fns": {
|
||||||
"version": "2.30.0",
|
"version": "2.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||||
@ -4883,17 +4842,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||||
@ -5336,28 +5284,6 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@ -5496,17 +5422,6 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@ -5760,11 +5675,6 @@
|
|||||||
"react-is": "^16.7.0"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@ -5865,11 +5775,6 @@
|
|||||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
@ -6023,11 +5928,6 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@ -6111,16 +6011,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@ -6429,47 +6319,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@ -6631,11 +6480,6 @@
|
|||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"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"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -7100,11 +6939,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@ -7841,23 +7675,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-callback-ref": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
@ -8268,8 +8003,7 @@
|
|||||||
"mongoose": "^8.2.1",
|
"mongoose": "^8.2.1",
|
||||||
"multer": "^2.0.0-rc.3",
|
"multer": "^2.0.0-rc.3",
|
||||||
"music-metadata": "^8.1.0",
|
"music-metadata": "^8.1.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0"
|
||||||
"webdav": "^5.3.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -17,8 +17,7 @@
|
|||||||
"mongoose": "^8.2.1",
|
"mongoose": "^8.2.1",
|
||||||
"multer": "^2.0.0-rc.3",
|
"multer": "^2.0.0-rc.3",
|
||||||
"music-metadata": "^8.1.0",
|
"music-metadata": "^8.1.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0"
|
||||||
"webdav": "^5.3.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
@ -10,8 +10,8 @@ router.post('/start', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { type, options } = req.body;
|
const { type, options } = req.body;
|
||||||
|
|
||||||
if (!type || !['storage-sync', 'song-matching'].includes(type)) {
|
if (!type || !['s3-sync', 'song-matching'].includes(type)) {
|
||||||
return res.status(400).json({ error: 'Invalid job type. Must be "storage-sync" or "song-matching"' });
|
return res.status(400).json({ error: 'Invalid job type. Must be "s3-sync" or "song-matching"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🚀 Starting background job: ${type}`);
|
console.log(`🚀 Starting background job: ${type}`);
|
||||||
|
|||||||
@ -2,16 +2,14 @@ import express from 'express';
|
|||||||
import { S3Client, ListBucketsCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
|
import { S3Client, ListBucketsCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { reloadStorageService } from './music.js';
|
import { reloadS3Service } from './music.js';
|
||||||
import { StorageProviderFactory, StorageConfig } from '../services/storageProvider.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Path to the storage configuration file
|
// Path to the S3 configuration file
|
||||||
const CONFIG_FILE_PATH = path.join(process.cwd(), 'storage-config.json');
|
const CONFIG_FILE_PATH = path.join(process.cwd(), 's3-config.json');
|
||||||
|
|
||||||
interface S3Config extends StorageConfig {
|
interface S3Config {
|
||||||
provider: 's3';
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
region: string;
|
region: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
@ -20,56 +18,8 @@ interface S3Config extends StorageConfig {
|
|||||||
useSSL: boolean;
|
useSSL: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebDAVConfig extends StorageConfig {
|
|
||||||
provider: 'webdav';
|
|
||||||
url: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
basePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current storage configuration
|
* Get current S3 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)
|
|
||||||
*/
|
*/
|
||||||
router.get('/s3', async (req, res) => {
|
router.get('/s3', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -106,88 +56,7 @@ router.get('/s3', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save storage configuration
|
* Save S3 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)
|
|
||||||
*/
|
*/
|
||||||
router.post('/s3', async (req, res) => {
|
router.post('/s3', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -211,8 +80,8 @@ router.post('/s3', async (req, res) => {
|
|||||||
process.env.S3_BUCKET_NAME = config.bucketName;
|
process.env.S3_BUCKET_NAME = config.bucketName;
|
||||||
process.env.S3_USE_SSL = config.useSSL.toString();
|
process.env.S3_USE_SSL = config.useSSL.toString();
|
||||||
|
|
||||||
// Reload storage service with new configuration
|
// Reload S3 service with new configuration
|
||||||
const reloadSuccess = await reloadStorageService();
|
const reloadSuccess = await reloadS3Service();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'S3 configuration saved successfully',
|
message: 'S3 configuration saved successfully',
|
||||||
@ -225,83 +94,7 @@ router.post('/s3', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test storage connection
|
* Test S3 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)
|
|
||||||
*/
|
*/
|
||||||
router.post('/s3/test', async (req, res) => {
|
router.post('/s3/test', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -377,6 +170,4 @@ router.post('/s3/test', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export { router as configRouter };
|
export { router as configRouter };
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { StorageProviderFactory, StorageProvider } from '../services/storageProvider.js';
|
import { S3Service } from '../services/s3Service.js';
|
||||||
import { AudioMetadataService } from '../services/audioMetadataService.js';
|
import { AudioMetadataService } from '../services/audioMetadataService.js';
|
||||||
import { MusicFile } from '../models/MusicFile.js';
|
import { MusicFile } from '../models/MusicFile.js';
|
||||||
import { Song } from '../models/Song.js';
|
import { Song } from '../models/Song.js';
|
||||||
@ -24,43 +24,40 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
let storageService: StorageProvider;
|
let s3Service: S3Service;
|
||||||
|
|
||||||
// Initialize storage service with configuration from file
|
// Initialize S3 service with configuration from file
|
||||||
async function initializeStorageService() {
|
async function initializeS3Service() {
|
||||||
try {
|
try {
|
||||||
const config = await StorageProviderFactory.loadConfig();
|
s3Service = await S3Service.createFromConfig();
|
||||||
storageService = await StorageProviderFactory.createProvider(config);
|
console.log('✅ S3 service initialized with configuration from s3-config.json');
|
||||||
console.log(`✅ Storage service initialized (${config.provider}) with configuration from storage-config.json`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to initialize storage service:', error);
|
console.error('❌ Failed to initialize S3 service:', error);
|
||||||
// Fallback to S3 with environment variables
|
// Fallback to environment variables
|
||||||
storageService = await StorageProviderFactory.createProvider({
|
s3Service = new S3Service({
|
||||||
provider: 's3',
|
|
||||||
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
bucketName: process.env.S3_BUCKET_NAME || 'music-files',
|
bucketName: process.env.S3_BUCKET_NAME || 'music-files',
|
||||||
region: process.env.S3_REGION || 'us-east-1',
|
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
|
// Initialize S3 service on startup
|
||||||
initializeStorageService();
|
initializeS3Service();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload storage service with updated configuration
|
* Reload S3 service with updated configuration
|
||||||
*/
|
*/
|
||||||
export async function reloadStorageService() {
|
export async function reloadS3Service() {
|
||||||
try {
|
try {
|
||||||
const config = await StorageProviderFactory.loadConfig();
|
s3Service = await S3Service.createFromConfig();
|
||||||
storageService = await StorageProviderFactory.createProvider(config);
|
console.log('✅ S3 service reloaded with updated configuration');
|
||||||
console.log(`✅ Storage service reloaded (${config.provider}) with updated configuration`);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to reload storage service:', error);
|
console.error('❌ Failed to reload S3 service:', error);
|
||||||
return false;
|
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 targetFolder = typeof req.body?.targetFolder === 'string' ? req.body.targetFolder : undefined;
|
||||||
const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true';
|
const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true';
|
||||||
|
|
||||||
// Upload to storage
|
// Upload to S3
|
||||||
const uploadResult = await storageService.uploadFile(buffer, originalname, mimetype, targetFolder);
|
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
|
||||||
|
|
||||||
// Extract audio metadata
|
// Extract audio metadata
|
||||||
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
||||||
@ -180,8 +177,8 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { buffer, originalname, mimetype } = file;
|
const { buffer, originalname, mimetype } = file;
|
||||||
|
|
||||||
// Upload to storage
|
// Upload to S3
|
||||||
const uploadResult = await storageService.uploadFile(buffer, originalname, mimetype);
|
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype);
|
||||||
|
|
||||||
// Extract audio metadata
|
// Extract audio metadata
|
||||||
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
||||||
@ -301,7 +298,7 @@ router.get('/folders', async (req, res) => {
|
|||||||
return res.json({ folders: folderCache.folders });
|
return res.json({ folders: folderCache.folders });
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = await storageService.listAllFolders('');
|
const folders = await s3Service.listAllFolders('');
|
||||||
const result = ['', ...folders];
|
const result = ['', ...folders];
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
@ -342,12 +339,12 @@ router.post('/sync-s3', async (req, res) => {
|
|||||||
|
|
||||||
// Start the background job
|
// Start the background job
|
||||||
const jobId = await backgroundJobService.startJob({
|
const jobId = await backgroundJobService.startJob({
|
||||||
type: 'storage-sync',
|
type: 's3-sync',
|
||||||
options: req.body
|
options: req.body
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Storage sync started as background job',
|
message: 'S3 sync started as background job',
|
||||||
jobId,
|
jobId,
|
||||||
status: 'started'
|
status: 'started'
|
||||||
});
|
});
|
||||||
@ -371,75 +368,20 @@ router.get('/:id/stream', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Music file not found' });
|
return res.status(404).json({ error: 'Music file not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// For WebDAV, use a proxy endpoint to handle authentication
|
// Use presigned URL for secure access instead of direct URL
|
||||||
// For S3, use presigned URL for direct access
|
const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry
|
||||||
const config = await StorageProviderFactory.loadConfig();
|
|
||||||
if (config.provider === 'webdav') {
|
res.json({
|
||||||
// Use proxy endpoint for WebDAV to handle authentication
|
streamingUrl: presignedUrl,
|
||||||
const proxyUrl = `${req.protocol}://${req.get('host')}/api/music/${musicFile._id}/proxy`;
|
musicFile,
|
||||||
res.json({
|
contentType: musicFile.contentType || undefined,
|
||||||
streamingUrl: proxyUrl,
|
});
|
||||||
musicFile,
|
|
||||||
contentType: musicFile.contentType || undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Use presigned URL for S3
|
|
||||||
const presignedUrl = await storageService.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry
|
|
||||||
res.json({
|
|
||||||
streamingUrl: presignedUrl,
|
|
||||||
musicFile,
|
|
||||||
contentType: musicFile.contentType || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Streaming error:', error);
|
console.error('Streaming error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get streaming URL' });
|
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
|
* 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 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({
|
res.json({
|
||||||
presignedUrl,
|
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
|
* Link music file to existing song
|
||||||
|
|||||||
@ -37,9 +37,6 @@ export class AudioMetadataService {
|
|||||||
'wma': 'WMA',
|
'wma': 'WMA',
|
||||||
'OPUS': 'OPUS',
|
'OPUS': 'OPUS',
|
||||||
'opus': 'OPUS',
|
'opus': 'OPUS',
|
||||||
'AIFF': 'AIFF',
|
|
||||||
'aiff': 'AIFF',
|
|
||||||
'aif': 'AIFF',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to map the container format
|
// Try to map the container format
|
||||||
@ -207,7 +204,7 @@ export class AudioMetadataService {
|
|||||||
*/
|
*/
|
||||||
isAudioFile(fileName: string): boolean {
|
isAudioFile(fileName: string): boolean {
|
||||||
const supportedFormats = [
|
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();
|
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export interface JobProgress {
|
export interface JobProgress {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
type: 'storage-sync' | 'song-matching';
|
type: 's3-sync' | 'song-matching';
|
||||||
status: 'running' | 'completed' | 'failed';
|
status: 'running' | 'completed' | 'failed';
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
current: number;
|
current: number;
|
||||||
@ -13,7 +13,7 @@ export interface JobProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface JobOptions {
|
export interface JobOptions {
|
||||||
type: 'storage-sync' | 'song-matching';
|
type: 's3-sync' | 'song-matching';
|
||||||
options?: any;
|
options?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,8 +140,8 @@ class BackgroundJobService {
|
|||||||
private async runJob(jobId: string, jobOptions: JobOptions): Promise<void> {
|
private async runJob(jobId: string, jobOptions: JobOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
switch (jobOptions.type) {
|
switch (jobOptions.type) {
|
||||||
case 'storage-sync':
|
case 's3-sync':
|
||||||
await this.runStorageSyncJob(jobId, jobOptions.options);
|
await this.runS3SyncJob(jobId, jobOptions.options);
|
||||||
break;
|
break;
|
||||||
case 'song-matching':
|
case 'song-matching':
|
||||||
await this.runSongMatchingJob(jobId, jobOptions.options);
|
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 {
|
try {
|
||||||
// Import here to avoid circular dependencies
|
// 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 { AudioMetadataService } = await import('./audioMetadataService.js');
|
||||||
const { SongMatchingService } = await import('./songMatchingService.js');
|
const { SongMatchingService } = await import('./songMatchingService.js');
|
||||||
const { MusicFile } = await import('../models/MusicFile.js');
|
const { MusicFile } = await import('../models/MusicFile.js');
|
||||||
const { Song } = await import('../models/Song.js');
|
const { Song } = await import('../models/Song.js');
|
||||||
|
|
||||||
// Get the configured storage provider
|
const s3Service = await S3Service.createFromConfig();
|
||||||
const config = await StorageProviderFactory.loadConfig();
|
|
||||||
const storageService = await StorageProviderFactory.createProvider(config);
|
|
||||||
const audioMetadataService = new AudioMetadataService();
|
const audioMetadataService = new AudioMetadataService();
|
||||||
const songMatchingService = new SongMatchingService();
|
const songMatchingService = new SongMatchingService();
|
||||||
|
|
||||||
@ -185,8 +183,6 @@ class BackgroundJobService {
|
|||||||
case 'ogg': return 'audio/ogg';
|
case 'ogg': return 'audio/ogg';
|
||||||
case 'opus': return 'audio/opus';
|
case 'opus': return 'audio/opus';
|
||||||
case 'wma': return 'audio/x-ms-wma';
|
case 'wma': return 'audio/x-ms-wma';
|
||||||
case 'aif': return 'audio/aiff';
|
|
||||||
case 'aiff': return 'audio/aiff';
|
|
||||||
default: return 'application/octet-stream';
|
default: return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -217,14 +213,14 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
// Phase 1: Quick filename matching
|
// Phase 1: Quick filename matching
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
message: `Phase 1: Fetching files from ${config.provider.toUpperCase()}...`,
|
message: 'Phase 1: Fetching files from S3...',
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0
|
total: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const storageFiles = await storageService.listAllFiles();
|
const s3Files = await s3Service.listAllFiles();
|
||||||
const audioFiles = storageFiles.filter(storageFile => {
|
const audioFiles = s3Files.filter(s3File => {
|
||||||
const filename = storageFile.key.split('/').pop() || storageFile.key;
|
const filename = s3File.key.split('/').pop() || s3File.key;
|
||||||
return audioMetadataService.isAudioFile(filename);
|
return audioMetadataService.isAudioFile(filename);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -236,8 +232,8 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
// Get existing files
|
// Get existing files
|
||||||
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
|
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
|
||||||
const existingStorageKeys = new Set(existingFiles.map(f => f.s3Key));
|
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key));
|
||||||
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(storageFile => !existingStorageKeys.has(storageFile.key));
|
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
|
||||||
|
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
|
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
|
||||||
@ -253,11 +249,11 @@ class BackgroundJobService {
|
|||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let phase1Errors = 0;
|
let phase1Errors = 0;
|
||||||
|
|
||||||
for (const storageFile of newAudioFiles) {
|
for (const s3File of newAudioFiles) {
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filename = storageFile.key.split('/').pop() || storageFile.key;
|
const filename = s3File.key.split('/').pop() || s3File.key;
|
||||||
|
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
message: `Phase 1: Quick filename matching`,
|
message: `Phase 1: Quick filename matching`,
|
||||||
@ -269,7 +265,7 @@ class BackgroundJobService {
|
|||||||
// Decode URL-encoded sequences so %20, %27 etc. are compared correctly
|
// Decode URL-encoded sequences so %20, %27 etc. are compared correctly
|
||||||
const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } };
|
const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } };
|
||||||
const stripDiacritics = (s: string) => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
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;
|
let matchedSong = null;
|
||||||
|
|
||||||
for (const song of allSongs) {
|
for (const song of allSongs) {
|
||||||
@ -277,7 +273,7 @@ class BackgroundJobService {
|
|||||||
const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location;
|
const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location;
|
||||||
const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase();
|
const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase();
|
||||||
|
|
||||||
if (normalizedStorageFilename === normalizedRekordboxFilename) {
|
if (normalizedS3Filename === normalizedRekordboxFilename) {
|
||||||
matchedSong = song;
|
matchedSong = song;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -287,31 +283,30 @@ class BackgroundJobService {
|
|||||||
if (matchedSong) {
|
if (matchedSong) {
|
||||||
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
|
||||||
// Reuse existing MusicFile if present (force mode), otherwise create new
|
// 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) {
|
if (!musicFile) {
|
||||||
musicFile = new MusicFile({ s3Key: storageFile.key });
|
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||||
}
|
}
|
||||||
musicFile.originalName = filename;
|
musicFile.originalName = filename;
|
||||||
musicFile.s3Key = storageFile.key;
|
musicFile.s3Key = s3File.key;
|
||||||
musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
|
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
||||||
musicFile.contentType = guessContentType(filename);
|
musicFile.contentType = guessContentType(filename);
|
||||||
musicFile.size = storageFile.size;
|
musicFile.size = s3File.size;
|
||||||
Object.assign(musicFile, basicMetadata);
|
Object.assign(musicFile, basicMetadata);
|
||||||
musicFile.songId = matchedSong._id;
|
musicFile.songId = matchedSong._id;
|
||||||
|
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
quickMatches.push(musicFile);
|
quickMatches.push(musicFile);
|
||||||
|
|
||||||
// Update the Song document to indicate it has a storage file
|
// Update the Song document to indicate it has an S3 file
|
||||||
const storageUrl = await storageService.getPresignedUrl(storageFile.key);
|
|
||||||
await Song.updateOne(
|
await Song.updateOne(
|
||||||
{ _id: matchedSong._id },
|
{ _id: matchedSong._id },
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
's3File.musicFileId': musicFile._id,
|
's3File.musicFileId': musicFile._id,
|
||||||
's3File.s3Key': storageFile.key,
|
's3File.s3Key': s3File.key,
|
||||||
's3File.s3Url': storageUrl,
|
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
's3File.streamingUrl': storageUrl,
|
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
's3File.hasS3File': true
|
's3File.hasS3File': true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -319,14 +314,14 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
console.log(`✅ Quick match saved immediately: ${filename}`);
|
console.log(`✅ Quick match saved immediately: ${filename}`);
|
||||||
} else {
|
} else {
|
||||||
unmatchedFiles.push(storageFile);
|
unmatchedFiles.push(s3File);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in quick matching ${storageFile.key}:`, error);
|
console.error(`Error in quick matching ${s3File.key}:`, error);
|
||||||
unmatchedFiles.push(storageFile);
|
unmatchedFiles.push(s3File);
|
||||||
phase1Errors++;
|
phase1Errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -351,10 +346,10 @@ class BackgroundJobService {
|
|||||||
const processedFiles: any[] = [];
|
const processedFiles: any[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < unmatchedFiles.length; i++) {
|
for (let i = 0; i < unmatchedFiles.length; i++) {
|
||||||
const storageFile = unmatchedFiles[i];
|
const s3File = unmatchedFiles[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filename = storageFile.key.split('/').pop() || storageFile.key;
|
const filename = s3File.key.split('/').pop() || s3File.key;
|
||||||
|
|
||||||
this.updateProgress(jobId, {
|
this.updateProgress(jobId, {
|
||||||
message: `Phase 2: Complex matching`,
|
message: `Phase 2: Complex matching`,
|
||||||
@ -363,19 +358,19 @@ class BackgroundJobService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download file and extract metadata
|
// 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);
|
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
|
||||||
|
|
||||||
// Reuse existing MusicFile document if present to avoid duplicate key errors
|
// 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) {
|
if (!musicFile) {
|
||||||
musicFile = new MusicFile({ s3Key: storageFile.key });
|
musicFile = new MusicFile({ s3Key: s3File.key });
|
||||||
}
|
}
|
||||||
musicFile.originalName = filename;
|
musicFile.originalName = filename;
|
||||||
musicFile.s3Key = storageFile.key;
|
musicFile.s3Key = s3File.key;
|
||||||
musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
|
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
|
||||||
musicFile.contentType = guessContentType(filename);
|
musicFile.contentType = guessContentType(filename);
|
||||||
musicFile.size = storageFile.size;
|
musicFile.size = s3File.size;
|
||||||
Object.assign(musicFile, metadata);
|
Object.assign(musicFile, metadata);
|
||||||
|
|
||||||
// Try complex matching
|
// Try complex matching
|
||||||
@ -391,16 +386,15 @@ class BackgroundJobService {
|
|||||||
musicFile.songId = bestMatch.song._id;
|
musicFile.songId = bestMatch.song._id;
|
||||||
complexMatches++;
|
complexMatches++;
|
||||||
|
|
||||||
// Update the Song document to indicate it has a storage file
|
// Update the Song document to indicate it has an S3 file
|
||||||
const storageUrl = await storageService.getPresignedUrl(storageFile.key);
|
|
||||||
await Song.updateOne(
|
await Song.updateOne(
|
||||||
{ _id: bestMatch.song._id },
|
{ _id: bestMatch.song._id },
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
's3File.musicFileId': musicFile._id,
|
's3File.musicFileId': musicFile._id,
|
||||||
's3File.s3Key': storageFile.key,
|
's3File.s3Key': s3File.key,
|
||||||
's3File.s3Url': storageUrl,
|
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
's3File.streamingUrl': storageUrl,
|
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
|
||||||
's3File.hasS3File': true
|
's3File.hasS3File': true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -418,7 +412,7 @@ class BackgroundJobService {
|
|||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing ${storageFile.key}:`, error);
|
console.error(`Error processing ${s3File.key}:`, error);
|
||||||
stillUnmatched++;
|
stillUnmatched++;
|
||||||
phase2Errors++;
|
phase2Errors++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { StorageProvider, StorageConfig, UploadResult, FileInfo } from './storageProvider.js';
|
|
||||||
|
|
||||||
export interface S3Config extends StorageConfig {
|
export interface S3Config {
|
||||||
provider: 's3';
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
@ -15,7 +13,21 @@ export interface S3Config extends StorageConfig {
|
|||||||
useSSL?: boolean;
|
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 client: S3Client;
|
||||||
private bucketName: string;
|
private bucketName: string;
|
||||||
|
|
||||||
@ -43,7 +55,6 @@ export class S3Service implements StorageProvider {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load s3-config.json, using environment variables as fallback');
|
console.warn('Failed to load s3-config.json, using environment variables as fallback');
|
||||||
return {
|
return {
|
||||||
provider: 's3' as const,
|
|
||||||
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
|
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
|
||||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
|
||||||
@ -71,22 +82,16 @@ export class S3Service implements StorageProvider {
|
|||||||
contentType: string,
|
contentType: string,
|
||||||
targetFolder?: string
|
targetFolder?: string
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
// Sanitize filename to be safe for S3
|
const fileExtension = originalName.split('.').pop();
|
||||||
const sanitizedFilename = this.sanitizeFilename(originalName);
|
|
||||||
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
|
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
|
||||||
const safeFolder = cleaned;
|
const safeFolder = cleaned;
|
||||||
|
|
||||||
// Use original filename instead of UUID
|
|
||||||
const key = safeFolder
|
const key = safeFolder
|
||||||
? `${safeFolder}/${sanitizedFilename}`
|
? `${safeFolder}/${uuidv4()}.${fileExtension}`
|
||||||
: sanitizedFilename;
|
: `${uuidv4()}.${fileExtension}`;
|
||||||
|
|
||||||
// Check if file already exists and handle conflicts
|
|
||||||
const finalKey = await this.handleFilenameConflict(key);
|
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
Key: finalKey,
|
Key: key,
|
||||||
Body: file,
|
Body: file,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Metadata: {
|
Metadata: {
|
||||||
@ -98,8 +103,8 @@ export class S3Service implements StorageProvider {
|
|||||||
await this.client.send(command);
|
await this.client.send(command);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: finalKey,
|
key,
|
||||||
url: `${this.bucketName}/${finalKey}`,
|
url: `${this.bucketName}/${key}`,
|
||||||
size: file.length,
|
size: file.length,
|
||||||
contentType,
|
contentType,
|
||||||
};
|
};
|
||||||
@ -108,8 +113,8 @@ export class S3Service implements StorageProvider {
|
|||||||
/**
|
/**
|
||||||
* Recursively list all files in the S3 bucket
|
* Recursively list all files in the S3 bucket
|
||||||
*/
|
*/
|
||||||
async listAllFiles(prefix: string = ''): Promise<FileInfo[]> {
|
async listAllFiles(prefix: string = ''): Promise<S3FileInfo[]> {
|
||||||
const files: FileInfo[] = [];
|
const files: S3FileInfo[] = [];
|
||||||
let continuationToken: string | undefined;
|
let continuationToken: string | undefined;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -191,7 +196,12 @@ export class S3Service implements StorageProvider {
|
|||||||
* Delete a file from S3
|
* Delete a file from S3
|
||||||
*/
|
*/
|
||||||
async deleteFile(key: string): Promise<void> {
|
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> {
|
async getStreamingUrl(key: string): Promise<string> {
|
||||||
return `${process.env.S3_ENDPOINT}/${this.bucketName}/${key}`;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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';
|
|
||||||
@ -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';
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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}`);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -1 +0,0 @@
|
|||||||
test content
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
22
packages/desktop-sync/.env
Normal file
22
packages/desktop-sync/.env
Normal 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
|
||||||
22
packages/desktop-sync/.env.backup
Normal file
22
packages/desktop-sync/.env.backup
Normal 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
|
||||||
255
packages/desktop-sync/README.md
Normal file
255
packages/desktop-sync/README.md
Normal 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.
|
||||||
5
packages/desktop-sync/assets/icon.png
Normal file
5
packages/desktop-sync/assets/icon.png
Normal 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
|
||||||
36
packages/desktop-sync/assets/icon.svg
Normal file
36
packages/desktop-sync/assets/icon.svg
Normal 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 |
29
packages/desktop-sync/env.example
Normal file
29
packages/desktop-sync/env.example
Normal 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
|
||||||
53
packages/desktop-sync/install-aws-cli.sh
Executable file
53
packages/desktop-sync/install-aws-cli.sh
Executable 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
|
||||||
56
packages/desktop-sync/package.json
Normal file
56
packages/desktop-sync/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
packages/desktop-sync/renderer/index.html
Normal file
213
packages/desktop-sync/renderer/index.html
Normal 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">×</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>
|
||||||
535
packages/desktop-sync/renderer/renderer.js
Normal file
535
packages/desktop-sync/renderer/renderer.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
598
packages/desktop-sync/renderer/styles.css
Normal file
598
packages/desktop-sync/renderer/styles.css
Normal 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;
|
||||||
|
}
|
||||||
61
packages/desktop-sync/scripts/check-aws-cli.js
Normal file
61
packages/desktop-sync/scripts/check-aws-cli.js
Normal 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);
|
||||||
87
packages/desktop-sync/scripts/check-minio.js
Normal file
87
packages/desktop-sync/scripts/check-minio.js
Normal 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);
|
||||||
|
|
||||||
89
packages/desktop-sync/scripts/check-rclone.js
Normal file
89
packages/desktop-sync/scripts/check-rclone.js
Normal 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);
|
||||||
|
|
||||||
152
packages/desktop-sync/setup-env.sh
Executable file
152
packages/desktop-sync/setup-env.sh
Executable 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."
|
||||||
703
packages/desktop-sync/src/main.ts
Normal file
703
packages/desktop-sync/src/main.ts
Normal 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();
|
||||||
82
packages/desktop-sync/src/preload.ts
Normal file
82
packages/desktop-sync/src/preload.ts
Normal 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
|
||||||
717
packages/desktop-sync/src/services/awsS3Service.ts
Normal file
717
packages/desktop-sync/src/services/awsS3Service.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
450
packages/desktop-sync/src/services/configManager.ts
Normal file
450
packages/desktop-sync/src/services/configManager.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
packages/desktop-sync/src/services/fileWatcher.ts
Normal file
234
packages/desktop-sync/src/services/fileWatcher.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
501
packages/desktop-sync/src/services/syncManager.ts
Normal file
501
packages/desktop-sync/src/services/syncManager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/desktop-sync/tsconfig.json
Normal file
24
packages/desktop-sync/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -27,7 +27,7 @@ import { api } from '../services/api';
|
|||||||
|
|
||||||
interface JobProgress {
|
interface JobProgress {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
type: 'storage-sync' | 'song-matching';
|
type: 's3-sync' | 'song-matching';
|
||||||
status: 'running' | 'completed' | 'failed';
|
status: 'running' | 'completed' | 'failed';
|
||||||
progress: number;
|
progress: number;
|
||||||
current: number;
|
current: number;
|
||||||
@ -56,42 +56,98 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
|||||||
const { isOpen, onClose } = useDisclosure();
|
const { isOpen, onClose } = useDisclosure();
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Simple polling function
|
// Load all jobs
|
||||||
const pollJobs = async () => {
|
const loadJobs = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const jobsData = await api.getAllJobs();
|
const jobsData = await api.getAllJobs();
|
||||||
setJobs(jobsData);
|
setJobs(jobsData);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load jobs');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle job completion for the specific job if provided
|
// Update specific job progress
|
||||||
if (jobId) {
|
const updateJobProgress = async (jobId: string) => {
|
||||||
const specificJob = jobsData.find((j: JobProgress) => j.jobId === jobId);
|
try {
|
||||||
if (specificJob) {
|
const progress = await api.getJobProgress(jobId);
|
||||||
if (specificJob.status === 'completed' && onJobComplete) {
|
|
||||||
onJobComplete(specificJob.result);
|
setJobs(prev => prev.map(job =>
|
||||||
} else if (specificJob.status === 'failed' && onJobError) {
|
job.jobId === jobId ? progress : job
|
||||||
onJobError(specificJob.error || 'Job failed');
|
));
|
||||||
}
|
|
||||||
}
|
// Handle job completion
|
||||||
|
if (progress.status === 'completed' && onJobComplete) {
|
||||||
|
onJobComplete(progress.result);
|
||||||
|
} else if (progress.status === 'failed' && onJobError) {
|
||||||
|
onJobError(progress.error || 'Job failed');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore transient polling errors
|
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);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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
|
// Start polling on mount and stop on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial poll
|
loadJobs();
|
||||||
pollJobs();
|
startPolling();
|
||||||
|
return () => stopPolling();
|
||||||
// Set up interval polling
|
}, []);
|
||||||
const interval = setInterval(() => {
|
|
||||||
pollJobs();
|
|
||||||
}, 10000); // Simple 10-second interval
|
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
stopPolling();
|
||||||
};
|
};
|
||||||
}, [jobId, onJobComplete, onJobError]);
|
}, []);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -152,7 +208,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
|||||||
<Box key={job.jobId} p={3} bg="gray.700" borderRadius="md">
|
<Box key={job.jobId} p={3} bg="gray.700" borderRadius="md">
|
||||||
<Flex justify="space-between" align="center" mb={2}>
|
<Flex justify="space-between" align="center" mb={2}>
|
||||||
<Text fontSize="sm" fontWeight="medium" color="gray.100">
|
<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>
|
</Text>
|
||||||
<Badge colorScheme={getStatusColor(job.status)} size="sm">
|
<Badge colorScheme={getStatusColor(job.status)} size="sm">
|
||||||
{job.status}
|
{job.status}
|
||||||
@ -190,7 +246,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
|||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<Text fontWeight="bold" color="gray.100">All Jobs</Text>
|
<Text fontWeight="bold" color="gray.100">All Jobs</Text>
|
||||||
<Button size="sm" onClick={() => pollJobs()} isLoading={loading}>
|
<Button size="sm" onClick={loadJobs} isLoading={loading}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
@ -216,7 +272,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
|||||||
<Tr key={job.jobId}>
|
<Tr key={job.jobId}>
|
||||||
<Td>
|
<Td>
|
||||||
<Text fontSize="sm" color="gray.100">
|
<Text fontSize="sm" color="gray.100">
|
||||||
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
|
{job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'}
|
||||||
</Text>
|
</Text>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
|
|||||||
@ -162,12 +162,12 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Merge duplicates (keeps target, removes others from playlists only)">
|
<Tooltip label="Delete other duplicates (optionally remove music files)">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Merge duplicates"
|
aria-label="Delete duplicates"
|
||||||
icon={<FiCheck />}
|
icon={<FiTrash2 />}
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme="blue"
|
colorScheme="red"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
isLoading={processingGroupKey === group.key}
|
isLoading={processingGroupKey === group.key}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@ -175,7 +175,7 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const targetId = it.songId;
|
const targetId = it.songId;
|
||||||
const others = group.items.map(x => x.songId).filter(id => id !== targetId);
|
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 allPlaylists = await api.getPlaylists();
|
||||||
const updated = allPlaylists.map(p => {
|
const updated = allPlaylists.map(p => {
|
||||||
if (p.type === 'playlist') {
|
if (p.type === 'playlist') {
|
||||||
@ -196,7 +196,7 @@ export const DuplicatesViewer: React.FC = () => {
|
|||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
await api.savePlaylists(updated as any);
|
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);
|
await loadDuplicates(minGroupSize);
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingGroupKey(null);
|
setProcessingGroupKey(null);
|
||||||
|
|||||||
@ -249,9 +249,6 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
|||||||
<Text fontSize="sm" color="gray.400">
|
<Text fontSize="sm" color="gray.400">
|
||||||
Supports MP3, WAV, FLAC, AAC, OGG, WMA, Opus (max 100MB per file)
|
Supports MP3, WAV, FLAC, AAC, OGG, WMA, Opus (max 100MB per file)
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
Original filenames and metadata will be preserved
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@ -12,13 +12,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputLeftElement,
|
InputLeftElement,
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuList,
|
|
||||||
MenuItem,
|
|
||||||
MenuDivider,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
import { Search2Icon } from '@chakra-ui/icons';
|
||||||
import { FiPlay } from 'react-icons/fi';
|
import { FiPlay } from 'react-icons/fi';
|
||||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
@ -136,7 +131,7 @@ const SongItem = memo<{
|
|||||||
</Box>
|
</Box>
|
||||||
<Box textAlign="right" ml={2}>
|
<Box textAlign="right" ml={2}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text fontSize="xs" color="gray.500">
|
||||||
{formattedDuration}{song.tonality ? ` - ${song.tonality}` : ''}
|
{formattedDuration}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="xs" color="gray.600">
|
<Text fontSize="xs" color="gray.600">
|
||||||
{song.averageBpm} BPM
|
{song.averageBpm} BPM
|
||||||
@ -207,13 +202,6 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
loadingRef_state.current = loading;
|
loadingRef_state.current = loading;
|
||||||
onLoadMoreRef.current = onLoadMore;
|
onLoadMoreRef.current = onLoadMore;
|
||||||
}, [hasMore, loading, onLoadMore]);
|
}, [hasMore, loading, onLoadMore]);
|
||||||
|
|
||||||
// Clear selection when switching playlists
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSwitchingPlaylist) {
|
|
||||||
setSelectedSongs(new Set());
|
|
||||||
}
|
|
||||||
}, [isSwitchingPlaylist]);
|
|
||||||
|
|
||||||
// Debounce search to prevent excessive API calls
|
// Debounce search to prevent excessive API calls
|
||||||
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
||||||
@ -534,32 +522,27 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{selectedSongs.size > 0 && (
|
{selectedSongs.size > 0 && (
|
||||||
<Menu>
|
<HStack spacing={2}>
|
||||||
<MenuButton
|
<Button
|
||||||
as={Button}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
rightIcon={<ChevronDownIcon />}
|
onClick={onPlaylistModalOpen}
|
||||||
>
|
>
|
||||||
Actions
|
Add to Playlist...
|
||||||
</MenuButton>
|
</Button>
|
||||||
<MenuList>
|
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
||||||
<MenuItem onClick={onPlaylistModalOpen}>
|
<Button
|
||||||
Add to Playlist...
|
size="sm"
|
||||||
</MenuItem>
|
variant="outline"
|
||||||
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
|
colorScheme="red"
|
||||||
<>
|
onClick={() => {
|
||||||
<MenuDivider />
|
handleBulkRemoveFromPlaylist();
|
||||||
<MenuItem
|
}}
|
||||||
color="red.300"
|
>
|
||||||
onClick={handleBulkRemoveFromPlaylist}
|
Remove from {currentPlaylist}
|
||||||
>
|
</Button>
|
||||||
Remove from {currentPlaylist}
|
)}
|
||||||
</MenuItem>
|
</HStack>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
|
|||||||
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
|
import { Search2Icon, ChevronDownIcon } from "@chakra-ui/icons";
|
||||||
import { FiPlay, FiMusic } from 'react-icons/fi';
|
import { FiPlay, FiMusic } from 'react-icons/fi';
|
||||||
import type { Song, PlaylistNode } from "../types/interfaces";
|
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';
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||||
|
|
||||||
@ -33,7 +33,6 @@ interface SongListProps {
|
|||||||
currentPlaylist: string | null;
|
currentPlaylist: string | null;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
onPlaySong?: (song: Song) => void;
|
onPlaySong?: (song: Song) => void;
|
||||||
isSwitchingPlaylist?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SongList: React.FC<SongListProps> = ({
|
export const SongList: React.FC<SongListProps> = ({
|
||||||
@ -45,19 +44,11 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
selectedSongId,
|
selectedSongId,
|
||||||
currentPlaylist,
|
currentPlaylist,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
onPlaySong,
|
onPlaySong
|
||||||
isSwitchingPlaylist = false
|
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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
|
// Helper function to get all playlists (excluding folders) from the playlist tree
|
||||||
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
|
const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => {
|
||||||
let result: PlaylistNode[] = [];
|
let result: PlaylistNode[] = [];
|
||||||
@ -279,7 +270,7 @@ export const SongList: React.FC<SongListProps> = ({
|
|||||||
fontSize={depth > 0 ? "xs" : "sm"}
|
fontSize={depth > 0 ? "xs" : "sm"}
|
||||||
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
|
color={selectedSongId === song.id ? "gray.300" : "gray.500"}
|
||||||
>
|
>
|
||||||
{song.artist} • {formatDuration(song.totalTime)}{song.tonality ? ` - ${song.tonality}` : ''}
|
{song.artist} • {formatDuration(song.totalTime)}
|
||||||
</Text>
|
</Text>
|
||||||
{song.location && (
|
{song.location && (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@ -261,7 +261,7 @@ export const SongMatching: React.FC = () => {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
<Text color="gray.300" textAlign="center">
|
<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>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FiZap />}
|
leftIcon={<FiZap />}
|
||||||
|
|||||||
@ -29,12 +29,12 @@ import { FiDatabase, FiSettings, FiUpload, FiLink, FiRefreshCw, FiTrash2 } from
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useXmlParser } from "../hooks/useXmlParser";
|
import { useXmlParser } from "../hooks/useXmlParser";
|
||||||
import { StyledFileInput } from "../components/StyledFileInput";
|
import { StyledFileInput } from "../components/StyledFileInput";
|
||||||
import { StorageConfiguration } from "./StorageConfiguration";
|
import { S3Configuration } from "./S3Configuration";
|
||||||
import { MusicUpload } from "../components/MusicUpload";
|
import { MusicUpload } from "../components/MusicUpload";
|
||||||
import { SongMatching } from "../components/SongMatching";
|
import { SongMatching } from "../components/SongMatching";
|
||||||
import { api } from "../services/api";
|
import { api } from "../services/api";
|
||||||
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
interface MusicFile {
|
interface MusicFile {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -63,27 +63,10 @@ export function Configuration() {
|
|||||||
return stored ? parseInt(stored, 10) : 0;
|
return stored ? parseInt(stored, 10) : 0;
|
||||||
}, []);
|
}, []);
|
||||||
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
|
||||||
const [storageProvider, setStorageProvider] = useState<string>('Storage');
|
|
||||||
|
|
||||||
// No explicit tab index enum needed
|
// No explicit tab index enum needed
|
||||||
|
|
||||||
// Load current storage provider for dynamic button labels
|
// S3 config fetch removed; Sync buttons remain available in the panel
|
||||||
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
|
|
||||||
|
|
||||||
// Removed Music Library sync handlers from Config (moved to Sync & Matching 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" }}>
|
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Icon as={FiSettings} />
|
<Icon as={FiSettings} />
|
||||||
<Text>Storage Configuration</Text>
|
<Text>S3 Configuration</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
@ -253,8 +236,8 @@ export function Configuration() {
|
|||||||
Upload Music Files
|
Upload Music Files
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text color="gray.400" mb={4}>
|
<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
|
Drag and drop your music files here or click to select. Files will be uploaded to S3 storage
|
||||||
(S3 or WebDAV) and metadata will be automatically extracted.
|
and metadata will be automatically extracted.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<MusicUpload onUploadComplete={handleUploadComplete} />
|
<MusicUpload onUploadComplete={handleUploadComplete} />
|
||||||
@ -266,21 +249,21 @@ export function Configuration() {
|
|||||||
{/* Song Matching Tab */}
|
{/* Song Matching Tab */}
|
||||||
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
|
||||||
<VStack spacing={6} align="stretch">
|
<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}>
|
<HStack spacing={3}>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FiRefreshCw />}
|
leftIcon={<FiRefreshCw />}
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onClick={() => api.startStorageSync()}
|
onClick={() => api.startS3Sync()}
|
||||||
>
|
>
|
||||||
Sync (incremental)
|
Sync S3 (incremental)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FiRefreshCw />}
|
leftIcon={<FiRefreshCw />}
|
||||||
colorScheme="orange"
|
colorScheme="orange"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => api.startStorageSync({ force: true })}
|
onClick={() => api.startS3Sync({ force: true })}
|
||||||
>
|
>
|
||||||
Force Sync (rescan all)
|
Force Sync (rescan all)
|
||||||
</Button>
|
</Button>
|
||||||
@ -288,7 +271,7 @@ export function Configuration() {
|
|||||||
leftIcon={<FiTrash2 />}
|
leftIcon={<FiTrash2 />}
|
||||||
colorScheme="red"
|
colorScheme="red"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => api.startStorageSync({ clearLinks: true, force: true })}
|
onClick={() => api.startS3Sync({ clearLinks: true, force: true })}
|
||||||
>
|
>
|
||||||
Clear Links + Force Sync
|
Clear Links + Force Sync
|
||||||
</Button>
|
</Button>
|
||||||
@ -302,10 +285,10 @@ export function Configuration() {
|
|||||||
<DuplicatesViewer />
|
<DuplicatesViewer />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Storage Configuration Tab */}
|
{/* S3 Configuration Tab */}
|
||||||
<TabPanel bg="gray.800" p={0}>
|
<TabPanel bg="gray.800" p={0}>
|
||||||
<Box p={6}>
|
<Box p={6}>
|
||||||
<StorageConfiguration />
|
<S3Configuration />
|
||||||
</Box>
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -142,7 +142,7 @@ class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background job methods
|
// 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`, {
|
const response = await fetch(`${API_BASE_URL}/background-jobs/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -155,13 +155,13 @@ class Api {
|
|||||||
return response.json();
|
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`, {
|
const response = await fetch(`${API_BASE_URL}/music/sync-s3`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(options || {})
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,7 +205,15 @@ class Api {
|
|||||||
return response.json();
|
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();
|
export const api = new Api();
|
||||||
Loading…
x
Reference in New Issue
Block a user