Compare commits

..

5 Commits

Author SHA1 Message Date
Geert Rademakes
e58d42bea2 Fix WebDAV music seeking functionality
- Add proxy endpoint for WebDAV streaming with authentication
- Implement range request support for audio seeking
- Update streaming endpoint to use proxy for WebDAV, presigned URLs for S3
- Add proper HTTP headers for audio streaming (Accept-Ranges, Content-Range)
- Fix music player seeking issue where clicking progress bar would reset playback

The WebDAV integration now supports proper audio seeking by using a backend
proxy that handles WebDAV authentication and range requests, allowing users
to click anywhere on the progress bar to seek to that position in the track.
2025-09-18 08:58:49 +02:00
Geert Rademakes
9de7564c18 Fix WebDAV file listing issue and add AIFF support
- Fix WebDAV service to find all 4,101 MP3 files instead of 1,023
- Add support for AIFF files (.aif, .aiff) in audio detection
- Update audioMetadataService to recognize AIFF formats
- Simplify BackgroundJobProgress component polling logic
- Add maxDepth parameter to WebDAV directory listing
- Add comprehensive test scripts for debugging WebDAV integration

The WebDAV integration now correctly processes all 4,257 audio files
from the music collection, including 4,101 MP3 files and 156 other
audio formats (FLAC, WAV, AIFF, M4A, OGG).
2025-09-17 22:52:15 +02:00
Geert Rademakes
d747830384 Working webdav backend! 2025-09-17 13:22:19 +02:00
Geert Rademakes
7065247277 More webdav compatiblity 2025-09-17 11:30:03 +02:00
Geert Rademakes
218046ec4f webdav setup 2025-09-17 10:55:35 +02:00
30 changed files with 3023 additions and 261 deletions

View File

@ -9,6 +9,8 @@ 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
@ -130,6 +132,34 @@ 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

258
WEBDAV_INTEGRATION.md Normal file
View File

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

270
package-lock.json generated
View File

@ -1180,6 +1180,14 @@
"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",
@ -4261,9 +4269,13 @@
"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",
@ -4428,6 +4440,11 @@
"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",
@ -4510,6 +4527,14 @@
"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",
@ -4716,6 +4741,14 @@
"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",
@ -4730,6 +4763,14 @@
"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",
@ -4842,6 +4883,17 @@
"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",
@ -5284,6 +5336,28 @@
"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",
@ -5422,6 +5496,17 @@
"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",
@ -5675,6 +5760,11 @@
"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",
@ -5775,6 +5865,11 @@
"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",
@ -5928,6 +6023,11 @@
"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",
@ -6011,6 +6111,16 @@
"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",
@ -6319,6 +6429,47 @@
"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",
@ -6480,6 +6631,11 @@
"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",
@ -6624,6 +6780,11 @@
"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",
@ -6939,6 +7100,11 @@
"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",
@ -7675,6 +7841,23 @@
"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",
@ -7821,6 +8004,88 @@
} }
} }
}, },
"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",
@ -8003,7 +8268,8 @@
"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",

View File

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

View File

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

View File

@ -17,7 +17,8 @@
"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",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import { S3Service } from '../services/s3Service.js'; import { StorageProviderFactory, StorageProvider } from '../services/storageProvider.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,40 +24,43 @@ const upload = multer({
}); });
// Initialize services // Initialize services
let s3Service: S3Service; let storageService: StorageProvider;
// Initialize S3 service with configuration from file // Initialize storage service with configuration from file
async function initializeS3Service() { async function initializeStorageService() {
try { try {
s3Service = await S3Service.createFromConfig(); const config = await StorageProviderFactory.loadConfig();
console.log('✅ S3 service initialized with configuration from s3-config.json'); storageService = await StorageProviderFactory.createProvider(config);
console.log(`✅ Storage service initialized (${config.provider}) with configuration from storage-config.json`);
} catch (error) { } catch (error) {
console.error('❌ Failed to initialize S3 service:', error); console.error('❌ Failed to initialize storage service:', error);
// Fallback to environment variables // Fallback to S3 with environment variables
s3Service = new S3Service({ storageService = await StorageProviderFactory.createProvider({
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('⚠️ S3 service initialized with environment variables as fallback'); console.log('⚠️ Storage service initialized with S3 environment variables as fallback');
} }
} }
// Initialize S3 service on startup // Initialize storage service on startup
initializeS3Service(); initializeStorageService();
/** /**
* Reload S3 service with updated configuration * Reload storage service with updated configuration
*/ */
export async function reloadS3Service() { export async function reloadStorageService() {
try { try {
s3Service = await S3Service.createFromConfig(); const config = await StorageProviderFactory.loadConfig();
console.log('✅ S3 service reloaded with updated configuration'); storageService = await StorageProviderFactory.createProvider(config);
console.log(`✅ Storage service reloaded (${config.provider}) with updated configuration`);
return true; return true;
} catch (error) { } catch (error) {
console.error('❌ Failed to reload S3 service:', error); console.error('❌ Failed to reload storage service:', error);
return false; return false;
} }
} }
@ -77,8 +80,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 S3 // Upload to storage
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder); const uploadResult = await storageService.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);
@ -177,8 +180,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 S3 // Upload to storage
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype); const uploadResult = await storageService.uploadFile(buffer, originalname, mimetype);
// Extract audio metadata // Extract audio metadata
const metadata = await audioMetadataService.extractMetadata(buffer, originalname); const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
@ -298,7 +301,7 @@ router.get('/folders', async (req, res) => {
return res.json({ folders: folderCache.folders }); return res.json({ folders: folderCache.folders });
} }
const folders = await s3Service.listAllFolders(''); const folders = await storageService.listAllFolders('');
const result = ['', ...folders]; const result = ['', ...folders];
// Cache the result // Cache the result
@ -339,12 +342,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: 's3-sync', type: 'storage-sync',
options: req.body options: req.body
}); });
res.json({ res.json({
message: 'S3 sync started as background job', message: 'Storage sync started as background job',
jobId, jobId,
status: 'started' status: 'started'
}); });
@ -368,20 +371,75 @@ 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' });
} }
// Use presigned URL for secure access instead of direct URL // For WebDAV, use a proxy endpoint to handle authentication
const presignedUrl = await s3Service.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry // For S3, use presigned URL for direct access
const config = await StorageProviderFactory.loadConfig();
if (config.provider === 'webdav') {
// Use proxy endpoint for WebDAV to handle authentication
const proxyUrl = `${req.protocol}://${req.get('host')}/api/music/${musicFile._id}/proxy`;
res.json({
streamingUrl: proxyUrl,
musicFile,
contentType: musicFile.contentType || undefined,
});
} else {
// Use presigned URL for S3
const presignedUrl = await storageService.getPresignedUrl(musicFile.s3Key, 3600); // 1 hour expiry
res.json({ res.json({
streamingUrl: presignedUrl, streamingUrl: presignedUrl,
musicFile, musicFile,
contentType: musicFile.contentType || undefined, 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
*/ */
@ -393,7 +451,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 s3Service.getPresignedUrl(musicFile.s3Key, expiresIn); const presignedUrl = await storageService.getPresignedUrl(musicFile.s3Key, expiresIn);
res.json({ res.json({
presignedUrl, presignedUrl,
@ -476,31 +534,8 @@ router.get('/', async (req, res) => {
} }
}); });
/** // DELETE endpoint removed to keep WebDAV integration read-only
* Delete a music file // Music files cannot be deleted to prevent accidental data loss from WebDAV
*/
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

View File

@ -37,6 +37,9 @@ 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
@ -204,7 +207,7 @@ export class AudioMetadataService {
*/ */
isAudioFile(fileName: string): boolean { isAudioFile(fileName: string): boolean {
const supportedFormats = [ const supportedFormats = [
'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus' 'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'aif', 'aiff'
]; ];
const extension = fileName.split('.').pop()?.toLowerCase(); const extension = fileName.split('.').pop()?.toLowerCase();

View File

@ -1,6 +1,6 @@
export interface JobProgress { export interface JobProgress {
jobId: string; jobId: string;
type: 's3-sync' | 'song-matching'; type: 'storage-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: 's3-sync' | 'song-matching'; type: 'storage-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 's3-sync': case 'storage-sync':
await this.runS3SyncJob(jobId, jobOptions.options); await this.runStorageSyncJob(jobId, jobOptions.options);
break; break;
case 'song-matching': case 'song-matching':
await this.runSongMatchingJob(jobId, jobOptions.options); await this.runSongMatchingJob(jobId, jobOptions.options);
@ -156,18 +156,20 @@ class BackgroundJobService {
} }
/** /**
* Run S3 sync job * Run storage sync job (works with any storage provider)
*/ */
private async runS3SyncJob(jobId: string, options?: any): Promise<void> { private async runStorageSyncJob(jobId: string, options?: any): Promise<void> {
try { try {
// Import here to avoid circular dependencies // Import here to avoid circular dependencies
const { S3Service } = await import('./s3Service.js'); const { StorageProviderFactory } = await import('./storageProvider.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');
const s3Service = await S3Service.createFromConfig(); // Get the configured storage provider
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();
@ -183,6 +185,8 @@ 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';
} }
}; };
@ -213,14 +217,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 S3...', message: `Phase 1: Fetching files from ${config.provider.toUpperCase()}...`,
current: 0, current: 0,
total: 0 total: 0
}); });
const s3Files = await s3Service.listAllFiles(); const storageFiles = await storageService.listAllFiles();
const audioFiles = s3Files.filter(s3File => { const audioFiles = storageFiles.filter(storageFile => {
const filename = s3File.key.split('/').pop() || s3File.key; const filename = storageFile.key.split('/').pop() || storageFile.key;
return audioMetadataService.isAudioFile(filename); return audioMetadataService.isAudioFile(filename);
}); });
@ -232,8 +236,8 @@ class BackgroundJobService {
// Get existing files // Get existing files
const existingFiles = await MusicFile.find({}, { s3Key: 1 }); const existingFiles = await MusicFile.find({}, { s3Key: 1 });
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key)); const existingStorageKeys = new Set(existingFiles.map(f => f.s3Key));
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key)); const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(storageFile => !existingStorageKeys.has(storageFile.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...`,
@ -249,11 +253,11 @@ class BackgroundJobService {
let processedCount = 0; let processedCount = 0;
let phase1Errors = 0; let phase1Errors = 0;
for (const s3File of newAudioFiles) { for (const storageFile of newAudioFiles) {
processedCount++; processedCount++;
try { try {
const filename = s3File.key.split('/').pop() || s3File.key; const filename = storageFile.key.split('/').pop() || storageFile.key;
this.updateProgress(jobId, { this.updateProgress(jobId, {
message: `Phase 1: Quick filename matching`, message: `Phase 1: Quick filename matching`,
@ -265,7 +269,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 normalizedS3Filename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase(); const normalizedStorageFilename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase();
let matchedSong = null; let matchedSong = null;
for (const song of allSongs) { for (const song of allSongs) {
@ -273,7 +277,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 (normalizedS3Filename === normalizedRekordboxFilename) { if (normalizedStorageFilename === normalizedRekordboxFilename) {
matchedSong = song; matchedSong = song;
break; break;
} }
@ -283,30 +287,31 @@ 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: s3File.key }); let musicFile = await MusicFile.findOne({ s3Key: storageFile.key });
if (!musicFile) { if (!musicFile) {
musicFile = new MusicFile({ s3Key: s3File.key }); musicFile = new MusicFile({ s3Key: storageFile.key });
} }
musicFile.originalName = filename; musicFile.originalName = filename;
musicFile.s3Key = s3File.key; musicFile.s3Key = storageFile.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`; musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
musicFile.contentType = guessContentType(filename); musicFile.contentType = guessContentType(filename);
musicFile.size = s3File.size; musicFile.size = storageFile.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 an S3 file // Update the Song document to indicate it has a storage 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': s3File.key, 's3File.s3Key': storageFile.key,
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, 's3File.s3Url': storageUrl,
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, 's3File.streamingUrl': storageUrl,
's3File.hasS3File': true 's3File.hasS3File': true
} }
} }
@ -314,14 +319,14 @@ class BackgroundJobService {
console.log(`✅ Quick match saved immediately: ${filename}`); console.log(`✅ Quick match saved immediately: ${filename}`);
} else { } else {
unmatchedFiles.push(s3File); unmatchedFiles.push(storageFile);
} }
} catch (error) { } catch (error) {
console.error(`Error in quick matching ${s3File.key}:`, error); console.error(`Error in quick matching ${storageFile.key}:`, error);
unmatchedFiles.push(s3File); unmatchedFiles.push(storageFile);
phase1Errors++; phase1Errors++;
} }
} }
@ -346,10 +351,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 s3File = unmatchedFiles[i]; const storageFile = unmatchedFiles[i];
try { try {
const filename = s3File.key.split('/').pop() || s3File.key; const filename = storageFile.key.split('/').pop() || storageFile.key;
this.updateProgress(jobId, { this.updateProgress(jobId, {
message: `Phase 2: Complex matching`, message: `Phase 2: Complex matching`,
@ -358,19 +363,19 @@ class BackgroundJobService {
}); });
// Download file and extract metadata // Download file and extract metadata
const fileBuffer = await s3Service.getFileContent(s3File.key); const fileBuffer = await storageService.getFileContent(storageFile.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: s3File.key }); let musicFile = await MusicFile.findOne({ s3Key: storageFile.key });
if (!musicFile) { if (!musicFile) {
musicFile = new MusicFile({ s3Key: s3File.key }); musicFile = new MusicFile({ s3Key: storageFile.key });
} }
musicFile.originalName = filename; musicFile.originalName = filename;
musicFile.s3Key = s3File.key; musicFile.s3Key = storageFile.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`; musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key);
musicFile.contentType = guessContentType(filename); musicFile.contentType = guessContentType(filename);
musicFile.size = s3File.size; musicFile.size = storageFile.size;
Object.assign(musicFile, metadata); Object.assign(musicFile, metadata);
// Try complex matching // Try complex matching
@ -386,15 +391,16 @@ class BackgroundJobService {
musicFile.songId = bestMatch.song._id; musicFile.songId = bestMatch.song._id;
complexMatches++; complexMatches++;
// Update the Song document to indicate it has an S3 file // Update the Song document to indicate it has a storage 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': s3File.key, 's3File.s3Key': storageFile.key,
's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, 's3File.s3Url': storageUrl,
's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, 's3File.streamingUrl': storageUrl,
's3File.hasS3File': true 's3File.hasS3File': true
} }
} }
@ -412,7 +418,7 @@ class BackgroundJobService {
} catch (error) { } catch (error) {
console.error(`Error processing ${s3File.key}:`, error); console.error(`Error processing ${storageFile.key}:`, error);
stillUnmatched++; stillUnmatched++;
phase2Errors++; phase2Errors++;
} }

View File

@ -3,8 +3,10 @@ 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 { export interface S3Config extends StorageConfig {
provider: 's3';
endpoint: string; endpoint: string;
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
@ -13,21 +15,7 @@ export interface S3Config {
useSSL?: boolean; useSSL?: boolean;
} }
export interface UploadResult { export class S3Service implements StorageProvider {
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;
@ -55,6 +43,7 @@ export class S3Service {
} 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',
@ -82,16 +71,22 @@ export class S3Service {
contentType: string, contentType: string,
targetFolder?: string targetFolder?: string
): Promise<UploadResult> { ): Promise<UploadResult> {
const fileExtension = originalName.split('.').pop(); // Sanitize filename to be safe for S3
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}/${uuidv4()}.${fileExtension}` ? `${safeFolder}/${sanitizedFilename}`
: `${uuidv4()}.${fileExtension}`; : sanitizedFilename;
// 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: key, Key: finalKey,
Body: file, Body: file,
ContentType: contentType, ContentType: contentType,
Metadata: { Metadata: {
@ -103,8 +98,8 @@ export class S3Service {
await this.client.send(command); await this.client.send(command);
return { return {
key, key: finalKey,
url: `${this.bucketName}/${key}`, url: `${this.bucketName}/${finalKey}`,
size: file.length, size: file.length,
contentType, contentType,
}; };
@ -113,8 +108,8 @@ export class S3Service {
/** /**
* Recursively list all files in the S3 bucket * Recursively list all files in the S3 bucket
*/ */
async listAllFiles(prefix: string = ''): Promise<S3FileInfo[]> { async listAllFiles(prefix: string = ''): Promise<FileInfo[]> {
const files: S3FileInfo[] = []; const files: FileInfo[] = [];
let continuationToken: string | undefined; let continuationToken: string | undefined;
do { do {
@ -196,12 +191,7 @@ export class S3Service {
* Delete a file from S3 * Delete a file from S3
*/ */
async deleteFile(key: string): Promise<void> { async deleteFile(key: string): Promise<void> {
const command = new DeleteObjectCommand({ throw new Error('File deletion is disabled to prevent accidental data loss');
Bucket: this.bucketName,
Key: key,
});
await this.client.send(command);
} }
/** /**
@ -263,4 +253,74 @@ export class S3Service {
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;
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
test content

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ import { api } from '../services/api';
interface JobProgress { interface JobProgress {
jobId: string; jobId: string;
type: 's3-sync' | 'song-matching'; type: 'storage-sync' | 'song-matching';
status: 'running' | 'completed' | 'failed'; status: 'running' | 'completed' | 'failed';
progress: number; progress: number;
current: number; current: number;
@ -56,98 +56,42 @@ 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);
// Load all jobs // Simple polling function
const loadJobs = async () => { const pollJobs = async () => {
try { try {
setLoading(true);
const jobsData = await api.getAllJobs();
setJobs(jobsData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load jobs');
} finally {
setLoading(false);
}
};
// Update specific job progress
const updateJobProgress = async (jobId: string) => {
try {
const progress = await api.getJobProgress(jobId);
setJobs(prev => prev.map(job =>
job.jobId === jobId ? progress : job
));
// Handle job completion
if (progress.status === 'completed' && onJobComplete) {
onJobComplete(progress.result);
} else if (progress.status === 'failed' && onJobError) {
onJobError(progress.error || 'Job failed');
}
} catch (err) {
console.error('Error updating job progress:', err);
}
};
// Start polling for jobs and update progress
const startPolling = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
const tick = async () => {
try {
// Always reload job list to detect newly started jobs
const jobsData = await api.getAllJobs(); const jobsData = await api.getAllJobs();
setJobs(jobsData); setJobs(jobsData);
// Update progress for active jobs // Handle job completion for the specific job if provided
const activeJobIds = jobsData.filter((j: JobProgress) => j.status === 'running').map((j: JobProgress) => j.jobId);
for (const id of activeJobIds) {
await updateJobProgress(id);
}
if (jobId) { if (jobId) {
await updateJobProgress(jobId); const specificJob = jobsData.find((j: JobProgress) => j.jobId === jobId);
if (specificJob) {
if (specificJob.status === 'completed' && onJobComplete) {
onJobComplete(specificJob.result);
} else if (specificJob.status === 'failed' && onJobError) {
onJobError(specificJob.error || 'Job failed');
}
}
} }
} catch (err) { } catch (err) {
// ignore transient polling errors // 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(() => {
loadJobs(); // Initial poll
startPolling(); pollJobs();
return () => stopPolling();
}, []); // Set up interval polling
const interval = setInterval(() => {
pollJobs();
}, 10000); // Simple 10-second interval
// Cleanup on unmount
useEffect(() => {
return () => { return () => {
stopPolling(); clearInterval(interval);
}; };
}, []); }, [jobId, onJobComplete, onJobError]);
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
@ -208,7 +152,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 === 's3-sync' ? 'S3 Sync' : 'Song Matching'} {job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
</Text> </Text>
<Badge colorScheme={getStatusColor(job.status)} size="sm"> <Badge colorScheme={getStatusColor(job.status)} size="sm">
{job.status} {job.status}
@ -246,7 +190,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={loadJobs} isLoading={loading}> <Button size="sm" onClick={() => pollJobs()} isLoading={loading}>
Refresh Refresh
</Button> </Button>
</HStack> </HStack>
@ -272,7 +216,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 === 's3-sync' ? 'S3 Sync' : 'Song Matching'} {job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
</Text> </Text>
</Td> </Td>
<Td> <Td>

View File

@ -162,12 +162,12 @@ export const DuplicatesViewer: React.FC = () => {
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip label="Delete other duplicates (optionally remove music files)"> <Tooltip label="Merge duplicates (keeps target, removes others from playlists only)">
<IconButton <IconButton
aria-label="Delete duplicates" aria-label="Merge duplicates"
icon={<FiTrash2 />} icon={<FiCheck />}
size="sm" size="sm"
colorScheme="red" colorScheme="blue"
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);
// First merge playlists (safe), then delete redundant songs and optionally their music files // Merge playlists (safe), but don't delete songs or 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);
await api.deleteDuplicateSongs(targetId, others, true); // Note: We don't call deleteDuplicateSongs anymore to keep it read-only
await loadDuplicates(minGroupSize); await loadDuplicates(minGroupSize);
} finally { } finally {
setProcessingGroupKey(null); setProcessingGroupKey(null);

View File

@ -249,6 +249,9 @@ 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>

View File

@ -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 S3 sync already performs matching; use this for leftovers. Try linking any remaining unmatched files. The main storage sync already performs matching; use this for leftovers.
</Text> </Text>
<Button <Button
leftIcon={<FiZap />} leftIcon={<FiZap />}

View File

@ -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 { S3Configuration } from "./S3Configuration"; import { StorageConfiguration } from "./StorageConfiguration";
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 } from "react"; import { useState, useMemo, useEffect } from "react";
interface MusicFile { interface MusicFile {
_id: string; _id: string;
@ -63,10 +63,27 @@ 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
// S3 config fetch removed; Sync buttons remain available in the panel // Load current storage provider for dynamic button labels
useEffect(() => {
const loadStorageProvider = async () => {
try {
const response = await fetch('/api/config/storage');
if (response.ok) {
const data = await response.json();
setStorageProvider(data.config.provider.toUpperCase());
}
} catch (error) {
console.error('Failed to load storage provider:', error);
}
};
loadStorageProvider();
}, []);
// Storage config fetch removed; Sync buttons remain available in the panel
// Removed Music Library sync handlers from Config (moved to Sync & Matching panel) // Removed Music Library sync handlers from Config (moved to Sync & Matching panel)
@ -179,7 +196,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>S3 Configuration</Text> <Text>Storage Configuration</Text>
</HStack> </HStack>
</Tab> </Tab>
</TabList> </TabList>
@ -236,8 +253,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 S3 storage Drag and drop your music files here or click to select. Files will be uploaded to your configured storage
and metadata will be automatically extracted. (S3 or WebDAV) and metadata will be automatically extracted.
</Text> </Text>
</Box> </Box>
<MusicUpload onUploadComplete={handleUploadComplete} /> <MusicUpload onUploadComplete={handleUploadComplete} />
@ -255,25 +272,25 @@ export function Configuration() {
leftIcon={<FiRefreshCw />} leftIcon={<FiRefreshCw />}
colorScheme="blue" colorScheme="blue"
variant="solid" variant="solid"
onClick={() => api.startS3Sync()} onClick={() => api.startStorageSync()}
> >
Sync S3 (incremental) Sync {storageProvider} (incremental)
</Button> </Button>
<Button <Button
leftIcon={<FiRefreshCw />} leftIcon={<FiRefreshCw />}
colorScheme="orange" colorScheme="orange"
variant="outline" variant="outline"
onClick={() => api.startS3Sync({ force: true })} onClick={() => api.startStorageSync({ force: true })}
> >
Force Sync (rescan all) Force {storageProvider} Sync (rescan all)
</Button> </Button>
<Button <Button
leftIcon={<FiTrash2 />} leftIcon={<FiTrash2 />}
colorScheme="red" colorScheme="red"
variant="outline" variant="outline"
onClick={() => api.startS3Sync({ clearLinks: true, force: true })} onClick={() => api.startStorageSync({ clearLinks: true, force: true })}
> >
Clear Links + Force Sync Clear Links + Force {storageProvider} Sync
</Button> </Button>
</HStack> </HStack>
<SongMatching /> <SongMatching />
@ -285,10 +302,10 @@ export function Configuration() {
<DuplicatesViewer /> <DuplicatesViewer />
</TabPanel> </TabPanel>
{/* S3 Configuration Tab */} {/* Storage Configuration Tab */}
<TabPanel bg="gray.800" p={0}> <TabPanel bg="gray.800" p={0}>
<Box p={6}> <Box p={6}>
<S3Configuration /> <StorageConfiguration />
</Box> </Box>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>

View File

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

View File

@ -142,7 +142,7 @@ class Api {
} }
// Background job methods // Background job methods
async startBackgroundJob(type: 's3-sync' | 'song-matching', options?: any): Promise<{ jobId: string; type: string }> { async startBackgroundJob(type: 'storage-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 startS3Sync(options?: { force?: boolean; clearLinks?: boolean }): Promise<{ jobId: string; type: string }> { async startStorageSync(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 S3 sync'); if (!response.ok) throw new Error('Failed to start storage sync');
return response.json(); return response.json();
} }
@ -205,15 +205,7 @@ class Api {
return response.json(); return response.json();
} }
async deleteDuplicateSongs(targetSongId: string, redundantSongIds: string[], deleteMusicFiles: boolean): Promise<{ deletedSongs: number; unlinkedOrDeletedMusicFiles: number; }>{ // deleteDuplicateSongs method removed to keep WebDAV integration read-only
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();