From 7065247277e13ddfdd0e39297ef66be546747bdc Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 17 Sep 2025 11:30:03 +0200 Subject: [PATCH] More webdav compatiblity --- packages/backend/src/routes/backgroundJobs.ts | 4 +- packages/backend/src/routes/music.ts | 31 +------ .../src/services/backgroundJobService.ts | 88 ++++++++++--------- packages/backend/src/services/s3Service.ts | 78 +++++++++++++--- .../backend/src/services/webdavService.ts | 75 ++++++++++++++-- .../src/components/BackgroundJobProgress.tsx | 6 +- .../src/components/DuplicatesViewer.tsx | 12 +-- .../frontend/src/components/MusicUpload.tsx | 3 + .../frontend/src/components/SongMatching.tsx | 2 +- packages/frontend/src/pages/Configuration.tsx | 33 +++++-- packages/frontend/src/services/api.ts | 16 +--- 11 files changed, 226 insertions(+), 122 deletions(-) diff --git a/packages/backend/src/routes/backgroundJobs.ts b/packages/backend/src/routes/backgroundJobs.ts index 570ddf0..1edc707 100644 --- a/packages/backend/src/routes/backgroundJobs.ts +++ b/packages/backend/src/routes/backgroundJobs.ts @@ -10,8 +10,8 @@ router.post('/start', async (req, res) => { try { const { type, options } = req.body; - if (!type || !['s3-sync', 'song-matching'].includes(type)) { - return res.status(400).json({ error: 'Invalid job type. Must be "s3-sync" or "song-matching"' }); + if (!type || !['storage-sync', 'song-matching'].includes(type)) { + return res.status(400).json({ error: 'Invalid job type. Must be "storage-sync" or "song-matching"' }); } console.log(`🚀 Starting background job: ${type}`); diff --git a/packages/backend/src/routes/music.ts b/packages/backend/src/routes/music.ts index ee8e940..3889e26 100644 --- a/packages/backend/src/routes/music.ts +++ b/packages/backend/src/routes/music.ts @@ -342,12 +342,12 @@ router.post('/sync-s3', async (req, res) => { // Start the background job const jobId = await backgroundJobService.startJob({ - type: 's3-sync', + type: 'storage-sync', options: req.body }); res.json({ - message: 'S3 sync started as background job', + message: 'Storage sync started as background job', jobId, status: 'started' }); @@ -479,31 +479,8 @@ router.get('/', async (req, res) => { } }); -/** - * Delete a music file - */ -router.delete('/:id', async (req, res) => { - try { - const musicFile = await MusicFile.findById(req.params.id); - if (!musicFile) { - return res.status(404).json({ error: 'Music file not found' }); - } - - // Delete from storage - await storageService.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' }); - } -}); +// DELETE endpoint removed to keep WebDAV integration read-only +// Music files cannot be deleted to prevent accidental data loss from WebDAV /** * Link music file to existing song diff --git a/packages/backend/src/services/backgroundJobService.ts b/packages/backend/src/services/backgroundJobService.ts index 79cdc2b..0ed0d72 100644 --- a/packages/backend/src/services/backgroundJobService.ts +++ b/packages/backend/src/services/backgroundJobService.ts @@ -13,7 +13,7 @@ export interface JobProgress { } export interface JobOptions { - type: 's3-sync' | 'song-matching'; + type: 'storage-sync' | 'song-matching'; options?: any; } @@ -140,8 +140,8 @@ class BackgroundJobService { private async runJob(jobId: string, jobOptions: JobOptions): Promise { try { switch (jobOptions.type) { - case 's3-sync': - await this.runS3SyncJob(jobId, jobOptions.options); + case 'storage-sync': + await this.runStorageSyncJob(jobId, jobOptions.options); break; case 'song-matching': 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 { + private async runStorageSyncJob(jobId: string, options?: any): Promise { try { // 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 { SongMatchingService } = await import('./songMatchingService.js'); const { MusicFile } = await import('../models/MusicFile.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 songMatchingService = new SongMatchingService(); @@ -213,14 +215,14 @@ class BackgroundJobService { // Phase 1: Quick filename matching this.updateProgress(jobId, { - message: 'Phase 1: Fetching files from S3...', + message: `Phase 1: Fetching files from ${config.provider.toUpperCase()}...`, current: 0, total: 0 }); - const s3Files = await s3Service.listAllFiles(); - const audioFiles = s3Files.filter(s3File => { - const filename = s3File.key.split('/').pop() || s3File.key; + const storageFiles = await storageService.listAllFiles(); + const audioFiles = storageFiles.filter(storageFile => { + const filename = storageFile.key.split('/').pop() || storageFile.key; return audioMetadataService.isAudioFile(filename); }); @@ -232,8 +234,8 @@ class BackgroundJobService { // Get existing files const existingFiles = await MusicFile.find({}, { s3Key: 1 }); - const existingS3Keys = new Set(existingFiles.map(f => f.s3Key)); - const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key)); + const existingStorageKeys = new Set(existingFiles.map(f => f.s3Key)); + const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(storageFile => !existingStorageKeys.has(storageFile.key)); this.updateProgress(jobId, { message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`, @@ -249,11 +251,11 @@ class BackgroundJobService { let processedCount = 0; let phase1Errors = 0; - for (const s3File of newAudioFiles) { + for (const storageFile of newAudioFiles) { processedCount++; try { - const filename = s3File.key.split('/').pop() || s3File.key; + const filename = storageFile.key.split('/').pop() || storageFile.key; this.updateProgress(jobId, { message: `Phase 1: Quick filename matching`, @@ -265,7 +267,7 @@ class BackgroundJobService { // Decode URL-encoded sequences so %20, %27 etc. are compared correctly const safeDecode = (s: string): string => { try { return decodeURIComponent(s); } catch { return s; } }; const stripDiacritics = (s: string) => s.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); - const normalizedS3Filename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase(); + const normalizedStorageFilename = stripDiacritics(safeDecode(filename)).replace(/\.[^/.]+$/, '').toLowerCase(); let matchedSong = null; for (const song of allSongs) { @@ -273,7 +275,7 @@ class BackgroundJobService { const rekordboxFilename = song.location.split(/[/\\]/).pop() || song.location; const normalizedRekordboxFilename = stripDiacritics(safeDecode(rekordboxFilename)).replace(/\.[^/.]+$/, '').toLowerCase(); - if (normalizedS3Filename === normalizedRekordboxFilename) { + if (normalizedStorageFilename === normalizedRekordboxFilename) { matchedSong = song; break; } @@ -283,30 +285,31 @@ class BackgroundJobService { if (matchedSong) { const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename); // Reuse existing MusicFile if present (force mode), otherwise create new - let musicFile = await MusicFile.findOne({ s3Key: s3File.key }); + let musicFile = await MusicFile.findOne({ s3Key: storageFile.key }); if (!musicFile) { - musicFile = new MusicFile({ s3Key: s3File.key }); + musicFile = new MusicFile({ s3Key: storageFile.key }); } musicFile.originalName = filename; - musicFile.s3Key = s3File.key; - musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`; + musicFile.s3Key = storageFile.key; + musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key); musicFile.contentType = guessContentType(filename); - musicFile.size = s3File.size; + musicFile.size = storageFile.size; Object.assign(musicFile, basicMetadata); musicFile.songId = matchedSong._id; await musicFile.save(); 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( { _id: matchedSong._id }, { $set: { 's3File.musicFileId': musicFile._id, - 's3File.s3Key': s3File.key, - 's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, - 's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, + 's3File.s3Key': storageFile.key, + 's3File.s3Url': storageUrl, + 's3File.streamingUrl': storageUrl, 's3File.hasS3File': true } } @@ -314,14 +317,14 @@ class BackgroundJobService { console.log(`✅ Quick match saved immediately: ${filename}`); } else { - unmatchedFiles.push(s3File); + unmatchedFiles.push(storageFile); } } catch (error) { - console.error(`Error in quick matching ${s3File.key}:`, error); - unmatchedFiles.push(s3File); + console.error(`Error in quick matching ${storageFile.key}:`, error); + unmatchedFiles.push(storageFile); phase1Errors++; } } @@ -346,10 +349,10 @@ class BackgroundJobService { const processedFiles: any[] = []; for (let i = 0; i < unmatchedFiles.length; i++) { - const s3File = unmatchedFiles[i]; + const storageFile = unmatchedFiles[i]; try { - const filename = s3File.key.split('/').pop() || s3File.key; + const filename = storageFile.key.split('/').pop() || storageFile.key; this.updateProgress(jobId, { message: `Phase 2: Complex matching`, @@ -358,19 +361,19 @@ class BackgroundJobService { }); // 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); // 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) { - musicFile = new MusicFile({ s3Key: s3File.key }); + musicFile = new MusicFile({ s3Key: storageFile.key }); } musicFile.originalName = filename; - musicFile.s3Key = s3File.key; - musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`; + musicFile.s3Key = storageFile.key; + musicFile.s3Url = await storageService.getPresignedUrl(storageFile.key); musicFile.contentType = guessContentType(filename); - musicFile.size = s3File.size; + musicFile.size = storageFile.size; Object.assign(musicFile, metadata); // Try complex matching @@ -386,15 +389,16 @@ class BackgroundJobService { musicFile.songId = bestMatch.song._id; 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( { _id: bestMatch.song._id }, { $set: { 's3File.musicFileId': musicFile._id, - 's3File.s3Key': s3File.key, - 's3File.s3Url': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, - 's3File.streamingUrl': `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`, + 's3File.s3Key': storageFile.key, + 's3File.s3Url': storageUrl, + 's3File.streamingUrl': storageUrl, 's3File.hasS3File': true } } @@ -412,7 +416,7 @@ class BackgroundJobService { } catch (error) { - console.error(`Error processing ${s3File.key}:`, error); + console.error(`Error processing ${storageFile.key}:`, error); stillUnmatched++; phase2Errors++; } diff --git a/packages/backend/src/services/s3Service.ts b/packages/backend/src/services/s3Service.ts index da9375f..83dc9c9 100644 --- a/packages/backend/src/services/s3Service.ts +++ b/packages/backend/src/services/s3Service.ts @@ -43,6 +43,7 @@ export class S3Service implements StorageProvider { } catch (error) { console.warn('Failed to load s3-config.json, using environment variables as fallback'); return { + provider: 's3' as const, endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin', @@ -70,16 +71,22 @@ export class S3Service implements StorageProvider { contentType: string, targetFolder?: string ): Promise { - 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 safeFolder = cleaned; + + // Use original filename instead of UUID const key = safeFolder - ? `${safeFolder}/${uuidv4()}.${fileExtension}` - : `${uuidv4()}.${fileExtension}`; + ? `${safeFolder}/${sanitizedFilename}` + : sanitizedFilename; + + // Check if file already exists and handle conflicts + const finalKey = await this.handleFilenameConflict(key); const command = new PutObjectCommand({ Bucket: this.bucketName, - Key: key, + Key: finalKey, Body: file, ContentType: contentType, Metadata: { @@ -91,8 +98,8 @@ export class S3Service implements StorageProvider { await this.client.send(command); return { - key, - url: `${this.bucketName}/${key}`, + key: finalKey, + url: `${this.bucketName}/${finalKey}`, size: file.length, contentType, }; @@ -184,12 +191,7 @@ export class S3Service implements StorageProvider { * Delete a file from S3 */ async deleteFile(key: string): Promise { - const command = new DeleteObjectCommand({ - Bucket: this.bucketName, - Key: key, - }); - - await this.client.send(command); + throw new Error('File deletion is disabled to prevent accidental data loss'); } /** @@ -269,4 +271,56 @@ export class S3Service implements StorageProvider { 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 { + 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; + } + } } \ No newline at end of file diff --git a/packages/backend/src/services/webdavService.ts b/packages/backend/src/services/webdavService.ts index 67a688a..3e88a47 100644 --- a/packages/backend/src/services/webdavService.ts +++ b/packages/backend/src/services/webdavService.ts @@ -13,8 +13,10 @@ export interface WebDAVConfig extends StorageConfig { 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, @@ -65,12 +67,15 @@ export class WebDAVService implements StorageProvider { contentType: string, targetFolder?: string ): Promise { - const fileExtension = originalName.split('.').pop(); + // 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}/${uuidv4()}.${fileExtension}` - : `${uuidv4()}.${fileExtension}`; + ? `${safeFolder}/${sanitizedFilename}` + : sanitizedFilename; const remotePath = `${this.basePath}/${key}`; @@ -78,8 +83,11 @@ export class WebDAVService implements StorageProvider { 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(remotePath, file, { + await this.client.putFileContents(`${this.basePath}/${finalKey}`, file, { overwrite: true, headers: { 'Content-Type': contentType, @@ -87,8 +95,8 @@ export class WebDAVService implements StorageProvider { }); return { - key, - url: remotePath, + key: finalKey, + url: `${this.basePath}/${finalKey}`, size: file.length, contentType, }; @@ -170,11 +178,10 @@ export class WebDAVService implements StorageProvider { } /** - * Delete a file from WebDAV + * Delete a file from WebDAV - DISABLED for read-only safety */ async deleteFile(key: string): Promise { - const remotePath = `${this.basePath}/${key}`; - await this.client.deleteFile(remotePath); + throw new Error('File deletion is disabled for WebDAV integration to prevent accidental data loss'); } /** @@ -268,6 +275,56 @@ export class WebDAVService implements StorageProvider { 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 { + 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 diff --git a/packages/frontend/src/components/BackgroundJobProgress.tsx b/packages/frontend/src/components/BackgroundJobProgress.tsx index 1e2fe5b..7eed1bb 100644 --- a/packages/frontend/src/components/BackgroundJobProgress.tsx +++ b/packages/frontend/src/components/BackgroundJobProgress.tsx @@ -27,7 +27,7 @@ import { api } from '../services/api'; interface JobProgress { jobId: string; - type: 's3-sync' | 'song-matching'; + type: 'storage-sync' | 'song-matching'; status: 'running' | 'completed' | 'failed'; progress: number; current: number; @@ -208,7 +208,7 @@ export const BackgroundJobProgress: React.FC = ({ - {job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'} + {job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'} {job.status} @@ -272,7 +272,7 @@ export const BackgroundJobProgress: React.FC = ({ - {job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'} + {job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'} diff --git a/packages/frontend/src/components/DuplicatesViewer.tsx b/packages/frontend/src/components/DuplicatesViewer.tsx index d7cc5c1..584e7f6 100644 --- a/packages/frontend/src/components/DuplicatesViewer.tsx +++ b/packages/frontend/src/components/DuplicatesViewer.tsx @@ -162,12 +162,12 @@ export const DuplicatesViewer: React.FC = () => { }} /> - + } + aria-label="Merge duplicates" + icon={} size="sm" - colorScheme="red" + colorScheme="blue" variant="outline" isLoading={processingGroupKey === group.key} onClick={async () => { @@ -175,7 +175,7 @@ export const DuplicatesViewer: React.FC = () => { try { const targetId = it.songId; const others = group.items.map(x => x.songId).filter(id => id !== targetId); - // 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 updated = allPlaylists.map(p => { if (p.type === 'playlist') { @@ -196,7 +196,7 @@ export const DuplicatesViewer: React.FC = () => { return p; }); await api.savePlaylists(updated as any); - await api.deleteDuplicateSongs(targetId, others, true); + // Note: We don't call deleteDuplicateSongs anymore to keep it read-only await loadDuplicates(minGroupSize); } finally { setProcessingGroupKey(null); diff --git a/packages/frontend/src/components/MusicUpload.tsx b/packages/frontend/src/components/MusicUpload.tsx index 81d6565..ce3dcba 100644 --- a/packages/frontend/src/components/MusicUpload.tsx +++ b/packages/frontend/src/components/MusicUpload.tsx @@ -249,6 +249,9 @@ export const MusicUpload: React.FC = ({ onUploadComplete }) => Supports MP3, WAV, FLAC, AAC, OGG, WMA, Opus (max 100MB per file) + + Original filenames and metadata will be preserved + diff --git a/packages/frontend/src/components/SongMatching.tsx b/packages/frontend/src/components/SongMatching.tsx index d33e28d..8909d3e 100644 --- a/packages/frontend/src/components/SongMatching.tsx +++ b/packages/frontend/src/components/SongMatching.tsx @@ -261,7 +261,7 @@ export const SongMatching: React.FC = () => { - 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. diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 9967e5d..94814b2 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -142,7 +142,7 @@ class Api { } // 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`, { method: 'POST', headers: { @@ -155,13 +155,13 @@ class Api { 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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(); } @@ -205,15 +205,7 @@ class Api { return response.json(); } - async deleteDuplicateSongs(targetSongId: string, redundantSongIds: string[], deleteMusicFiles: boolean): Promise<{ deletedSongs: number; unlinkedOrDeletedMusicFiles: number; }>{ - const response = await fetch(`${API_BASE_URL}/songs/delete-duplicates`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetSongId, redundantSongIds, deleteMusicFiles }) - }); - if (!response.ok) throw new Error('Failed to delete duplicates'); - return response.json(); - } + // deleteDuplicateSongs method removed to keep WebDAV integration read-only } export const api = new Api(); \ No newline at end of file