More webdav compatiblity

This commit is contained in:
Geert Rademakes 2025-09-17 11:30:03 +02:00
parent 218046ec4f
commit 7065247277
11 changed files with 226 additions and 122 deletions

View File

@ -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}`);

View File

@ -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

View File

@ -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<void> {
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<void> {
private async runStorageSyncJob(jobId: string, options?: any): Promise<void> {
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++;
}

View File

@ -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<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 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<void> {
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<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

@ -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<UploadResult> {
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<void> {
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<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

View File

@ -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<BackgroundJobProgressProps> = ({
<Box key={job.jobId} p={3} bg="gray.700" borderRadius="md">
<Flex justify="space-between" align="center" mb={2}>
<Text fontSize="sm" fontWeight="medium" color="gray.100">
{job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'}
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
</Text>
<Badge colorScheme={getStatusColor(job.status)} size="sm">
{job.status}
@ -272,7 +272,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
<Tr key={job.jobId}>
<Td>
<Text fontSize="sm" color="gray.100">
{job.type === 's3-sync' ? 'S3 Sync' : 'Song Matching'}
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
</Text>
</Td>
<Td>

View File

@ -162,12 +162,12 @@ export const DuplicatesViewer: React.FC = () => {
}}
/>
</Tooltip>
<Tooltip label="Delete other duplicates (optionally remove music files)">
<Tooltip label="Merge duplicates (keeps target, removes others from playlists only)">
<IconButton
aria-label="Delete duplicates"
icon={<FiTrash2 />}
aria-label="Merge duplicates"
icon={<FiCheck />}
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);

View File

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

View File

@ -261,7 +261,7 @@ export const SongMatching: React.FC = () => {
<CardBody>
<VStack spacing={4}>
<Text color="gray.300" textAlign="center">
Try linking any remaining unmatched files. The main 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>
<Button
leftIcon={<FiZap />}

View File

@ -34,7 +34,7 @@ import { MusicUpload } from "../components/MusicUpload";
import { SongMatching } from "../components/SongMatching";
import { api } from "../services/api";
import { DuplicatesViewer } from "../components/DuplicatesViewer.tsx";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
interface MusicFile {
_id: string;
@ -63,10 +63,27 @@ export function Configuration() {
return stored ? parseInt(stored, 10) : 0;
}, []);
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
const [storageProvider, setStorageProvider] = useState<string>('Storage');
// No explicit tab index enum needed
// 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)
@ -255,25 +272,25 @@ export function Configuration() {
leftIcon={<FiRefreshCw />}
colorScheme="blue"
variant="solid"
onClick={() => api.startS3Sync()}
onClick={() => api.startStorageSync()}
>
Sync S3 (incremental)
Sync {storageProvider} (incremental)
</Button>
<Button
leftIcon={<FiRefreshCw />}
colorScheme="orange"
variant="outline"
onClick={() => api.startS3Sync({ force: true })}
onClick={() => api.startStorageSync({ force: true })}
>
Force Sync (rescan all)
Force {storageProvider} Sync (rescan all)
</Button>
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
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>
</HStack>
<SongMatching />

View File

@ -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();