Compare commits

...

5 Commits

4 changed files with 128 additions and 508 deletions

View File

@ -187,6 +187,30 @@ class BackgroundJobService {
}
};
// Optional: clear existing links for a force re-sync
if (options?.clearLinks) {
this.updateProgress(jobId, {
message: 'Clearing existing song-file links...',
current: 0,
total: 0
});
await Promise.all([
// Reset song s3File metadata
Song.updateMany({}, {
$set: {
's3File.musicFileId': null,
's3File.s3Key': null,
's3File.s3Url': null,
's3File.streamingUrl': null,
's3File.hasS3File': false
}
}),
// Unlink music files
MusicFile.updateMany({}, { $unset: { songId: '' } })
]);
}
// Phase 1: Quick filename matching
this.updateProgress(jobId, {
message: 'Phase 1: Fetching files from S3...',
@ -209,7 +233,7 @@ class BackgroundJobService {
// Get existing files
const existingFiles = await MusicFile.find({}, { s3Key: 1 });
const existingS3Keys = new Set(existingFiles.map(f => f.s3Key));
const newAudioFiles = audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
const newAudioFiles = options?.force ? audioFiles : audioFiles.filter(s3File => !existingS3Keys.has(s3File.key));
this.updateProgress(jobId, {
message: `Phase 1: Processing ${newAudioFiles.length} new audio files...`,
@ -258,17 +282,19 @@ class BackgroundJobService {
if (matchedSong) {
const basicMetadata = audioMetadataService.extractBasicMetadataFromFilename(filename);
const musicFile = new MusicFile({
originalName: filename,
s3Key: s3File.key,
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
contentType: guessContentType(filename),
size: s3File.size,
...basicMetadata,
songId: matchedSong._id,
});
// Save immediately for real-time availability
// Reuse existing MusicFile if present (force mode), otherwise create new
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
if (!musicFile) {
musicFile = new MusicFile({ s3Key: s3File.key });
}
musicFile.originalName = filename;
musicFile.s3Key = s3File.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
musicFile.contentType = guessContentType(filename);
musicFile.size = s3File.size;
Object.assign(musicFile, basicMetadata);
musicFile.songId = matchedSong._id;
await musicFile.save();
quickMatches.push(musicFile);
@ -335,16 +361,17 @@ class BackgroundJobService {
const fileBuffer = await s3Service.getFileContent(s3File.key);
const metadata = await audioMetadataService.extractMetadata(fileBuffer, filename);
const musicFile = new MusicFile({
originalName: filename,
s3Key: s3File.key,
s3Url: `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`,
contentType: guessContentType(filename),
size: s3File.size,
...metadata,
});
processedFiles.push(musicFile);
// Reuse existing MusicFile document if present to avoid duplicate key errors
let musicFile = await MusicFile.findOne({ s3Key: s3File.key });
if (!musicFile) {
musicFile = new MusicFile({ s3Key: s3File.key });
}
musicFile.originalName = filename;
musicFile.s3Key = s3File.key;
musicFile.s3Url = `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${s3File.key}`;
musicFile.contentType = guessContentType(filename);
musicFile.size = s3File.size;
Object.assign(musicFile, metadata);
// Try complex matching
const matchResult = await songMatchingService.matchMusicFileToSongs(musicFile, {
@ -376,7 +403,7 @@ class BackgroundJobService {
stillUnmatched++;
}
// Save immediately for real-time availability
// Save immediately for real-time availability (create or update)
await musicFile.save();
processedFiles.push(musicFile);
@ -545,12 +572,13 @@ class BackgroundJobService {
songUpdates.push({
updateOne: {
filter: { _id: bestMatch.song._id },
update: {
$addToSet: {
s3File: {
musicFileId: musicFile._id,
hasS3File: true
}
update: {
$set: {
's3File.musicFileId': musicFile._id,
's3File.s3Key': musicFile.s3Key,
's3File.s3Url': musicFile.s3Url,
's3File.streamingUrl': musicFile.s3Key ? `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET_NAME}/${musicFile.s3Key}` : undefined,
's3File.hasS3File': true
}
}
}

View File

@ -29,7 +29,7 @@ import {
Tooltip,
Spinner,
} from '@chakra-ui/react';
import { FiPlay, FiLink, FiSearch, FiZap, FiMusic, FiCheck, FiX } from 'react-icons/fi';
import { FiPlay, FiLink, FiSearch, FiZap } from 'react-icons/fi';
interface MatchResult {
song: any;
@ -52,8 +52,7 @@ interface MatchingStats {
export const SongMatching: React.FC = () => {
const [stats, setStats] = useState<MatchingStats | null>(null);
const [unmatchedMusicFiles, setUnmatchedMusicFiles] = useState<any[]>([]);
const [matchedMusicFiles, setMatchedMusicFiles] = useState<any[]>([]);
const [songsWithMusicFiles, setSongsWithMusicFiles] = useState<any[]>([]);
// Removed matched and songs-with-files lists to reduce load
const [isLoading, setIsLoading] = useState(false);
const [autoLinking, setAutoLinking] = useState(false);
const [selectedMusicFile, setSelectedMusicFile] = useState<any>(null);
@ -70,11 +69,9 @@ export const SongMatching: React.FC = () => {
const loadData = async () => {
setIsLoading(true);
try {
const [statsRes, unmatchedRes, matchedRes, songsWithRes] = await Promise.all([
const [statsRes, unmatchedRes] = await Promise.all([
fetch('/api/matching/stats'),
fetch('/api/matching/unmatched-music-files?limit=200'),
fetch('/api/matching/matched-music-files?limit=200'),
fetch('/api/matching/songs-with-music-files?limit=200')
fetch('/api/matching/unmatched-music-files?limit=200')
]);
if (statsRes.ok) {
@ -87,15 +84,7 @@ export const SongMatching: React.FC = () => {
setUnmatchedMusicFiles(unmatchedData.musicFiles);
}
if (matchedRes.ok) {
const matchedData = await matchedRes.json();
setMatchedMusicFiles(matchedData.musicFiles);
}
if (songsWithRes.ok) {
const songsData = await songsWithRes.json();
setSongsWithMusicFiles(songsData.songs);
}
// Matched and songs-with-files lists are omitted
} catch (error) {
console.error('Error loading data:', error);
toast({
@ -125,24 +114,25 @@ export const SongMatching: React.FC = () => {
}),
});
if (response.ok) {
const result = await response.json();
toast({
title: 'Auto-linking Complete',
description: `Linked ${result.result.linked} files, ${result.result.unmatched} unmatched`,
status: 'success',
duration: 5000,
isClosable: true,
});
loadData(); // Refresh data
} else {
throw new Error('Auto-linking failed');
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Auto-linking failed');
}
const result = await response.json();
toast({
title: 'Auto-linking Started',
description: `Job ${result.jobId} started.`,
status: 'info',
duration: 4000,
isClosable: true,
});
// Do not call loadData immediately; background job UI will refresh stats as it completes
} catch (error) {
console.error('Error during auto-linking:', error);
toast({
title: 'Error',
description: 'Failed to auto-link music files',
description: error instanceof Error ? error.message : 'Failed to auto-link music files',
status: 'error',
duration: 3000,
isClosable: true,
@ -209,35 +199,7 @@ export const SongMatching: React.FC = () => {
}
};
const handleUnlinkMusicFile = async (songId: string) => {
try {
const response = await fetch(`/api/matching/unlink/${songId}`, {
method: 'DELETE',
});
if (response.ok) {
toast({
title: 'Success',
description: 'Music file unlinked from song successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
loadData(); // Refresh data
} else {
throw new Error('Failed to unlink music file');
}
} catch (error) {
console.error('Error unlinking music file:', error);
toast({
title: 'Error',
description: 'Failed to unlink music file from song',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
// Unlink handler removed with matched/songs-with-files lists
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.9) return 'green';
@ -386,162 +348,7 @@ export const SongMatching: React.FC = () => {
</CardBody>
</Card>
{/* Matched Music Files */}
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
<CardHeader>
<Heading size="md" color="white">Matched Music Files ({matchedMusicFiles.length})</Heading>
</CardHeader>
<CardBody>
{matchedMusicFiles.length === 0 ? (
<Text color="gray.500" textAlign="center">
No music files are matched yet.
</Text>
) : (
<VStack spacing={3} align="stretch">
{matchedMusicFiles.slice(0, 10).map((file) => (
<Box
key={file._id}
p={3}
border="1px"
borderColor="green.700"
borderRadius="md"
bg="green.900"
>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="white">
{file.title || file.originalName}
</Text>
<Badge colorScheme="green" size="sm" bg="green.800" color="green.200">
<FiCheck style={{ marginRight: '4px' }} />
Matched
</Badge>
</HStack>
<Text fontSize="xs" color="gray.300">
{file.artist}
</Text>
<Text fontSize="xs" color="gray.400">
{file.album}
</Text>
</VStack>
<HStack spacing={2}>
<Tooltip label="Unlink music file">
<IconButton
aria-label="Unlink"
icon={<FiX />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleUnlinkMusicFile(file.songId)}
_hover={{ bg: "red.900" }}
/>
</Tooltip>
<Tooltip label="Play music file">
<IconButton
aria-label="Play"
icon={<FiPlay />}
size="sm"
variant="ghost"
colorScheme="blue"
_hover={{ bg: "blue.900" }}
/>
</Tooltip>
</HStack>
</HStack>
</Box>
))}
{matchedMusicFiles.length > 10 && (
<Text fontSize="sm" color="gray.500" textAlign="center">
Showing first 10 of {matchedMusicFiles.length} matched files
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
{/* Songs with Music Files */}
<Card bg="gray.800" borderColor="gray.700" borderWidth="1px">
<CardHeader>
<Heading size="md" color="white">Songs with Music Files ({songsWithMusicFiles.length})</Heading>
</CardHeader>
<CardBody>
{songsWithMusicFiles.length === 0 ? (
<Text color="gray.500" textAlign="center">
No songs have music files linked yet.
</Text>
) : (
<VStack spacing={3} align="stretch">
{songsWithMusicFiles.slice(0, 10).map((song) => (
<Box
key={song._id}
p={3}
border="1px"
borderColor="blue.700"
borderRadius="md"
bg="blue.900"
>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="white">
{song.title}
</Text>
<Badge colorScheme="blue" size="sm" bg="blue.800" color="blue.200">
<FiMusic style={{ marginRight: '4px' }} />
Has S3 File
</Badge>
</HStack>
<Text fontSize="xs" color="gray.300">
{song.artist}
</Text>
{song.location && (
<Text fontSize="xs" color="gray.400">
📁 {song.location}
</Text>
)}
{song.s3File?.streamingUrl && (
<Text fontSize="xs" color="green.400">
🎵 S3: {song.s3File.s3Key}
</Text>
)}
</VStack>
<HStack spacing={2}>
<Tooltip label="Unlink music file">
<IconButton
aria-label="Unlink"
icon={<FiX />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleUnlinkMusicFile(song._id)}
_hover={{ bg: "red.900" }}
/>
</Tooltip>
<Tooltip label="Play music file">
<IconButton
aria-label="Play"
icon={<FiPlay />}
size="sm"
variant="ghost"
colorScheme="blue"
_hover={{ bg: "blue.800" }}
/>
</Tooltip>
</HStack>
</HStack>
</Box>
))}
{songsWithMusicFiles.length > 10 && (
<Text fontSize="sm" color="gray.500" textAlign="center">
Showing first 10 of {songsWithMusicFiles.length} songs with music files
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
{/* Removed Matched Music Files and Songs with Music Files lists to streamline UI */}
{/* Suggestions Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">

View File

@ -23,11 +23,9 @@ import {
TabPanel,
Icon,
HStack,
Badge,
Spinner,
} from "@chakra-ui/react";
import { ChevronLeftIcon } from "@chakra-ui/icons";
import { FiDatabase, FiSettings, FiUpload, FiMusic, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
import { FiDatabase, FiSettings, FiUpload, FiLink, FiRefreshCw, FiTrash2 } from 'react-icons/fi';
import { useNavigate } from "react-router-dom";
import { useXmlParser } from "../hooks/useXmlParser";
import { StyledFileInput } from "../components/StyledFileInput";
@ -36,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, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
interface MusicFile {
_id: string;
@ -57,12 +55,7 @@ export function Configuration() {
const toast = useToast();
const navigate = useNavigate();
// Music storage state
const [musicFiles, setMusicFiles] = useState<MusicFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [hasS3Config, setHasS3Config] = useState<boolean>(false);
const [musicLoaded, setMusicLoaded] = useState<boolean>(false);
// Music storage state removed from Config; see Sync & Matching section
// Tabs: remember active tab across refreshes
const initialTabIndex = useMemo(() => {
@ -71,99 +64,13 @@ export function Configuration() {
}, []);
const [tabIndex, setTabIndex] = useState<number>(initialTabIndex);
// Tab indices (keep in sync with Tab order below)
const TAB_INDEX = {
LIBRARY: 0,
UPLOAD: 1,
MUSIC_LIBRARY: 2,
MATCHING: 3,
DUPLICATES: 4,
S3_CONFIG: 5,
} as const;
// No explicit tab index enum needed
// Fetch S3 config (small and safe to do on mount)
useEffect(() => {
const fetchS3Config = async () => {
try {
const res = await fetch('/api/config/s3');
if (res.ok) {
const data = await res.json();
const cfg = data?.config || {};
const required = ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'];
const present = required.every((k) => typeof cfg[k] === 'string' && cfg[k]);
setHasS3Config(present);
} else {
setHasS3Config(false);
}
} catch {
setHasS3Config(false);
}
};
fetchS3Config();
}, []);
// S3 config fetch removed; Sync buttons remain available in the panel
// Lazy-load: only load music files when Music Library tab becomes active
useEffect(() => {
if (tabIndex === TAB_INDEX.MUSIC_LIBRARY && !musicLoaded) {
loadMusicFiles().then(() => setMusicLoaded(true));
}
}, [tabIndex]);
const loadMusicFiles = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/music/files');
if (response.ok) {
const data = await response.json();
setMusicFiles(data.musicFiles || []);
} else {
throw new Error('Failed to load music files');
}
} catch (error) {
console.error('Error loading music files:', error);
toast({
title: 'Error',
description: 'Failed to load music files',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const handleSyncS3 = async () => {
setIsSyncing(true);
try {
const { jobId } = await api.startBackgroundJob('s3-sync');
toast({
title: 'S3 Sync Started',
description: `Job ${jobId} started. Progress will appear shortly.`,
status: 'info',
duration: 4000,
isClosable: true,
});
// Defer reloading; background job widget will show progress and we fetch on demand
if (tabIndex !== TAB_INDEX.MUSIC_LIBRARY) {
setMusicLoaded(false);
}
} catch (error) {
console.error('Error starting S3 sync:', error);
toast({
title: 'Error',
description: 'Failed to start S3 sync',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsSyncing(false);
}
};
// Removed Music Library sync handlers from Config (moved to Sync & Matching panel)
const handleUploadComplete = (files: MusicFile[]) => {
setMusicFiles(prev => [...files, ...prev]);
toast({
title: 'Upload Complete',
description: `${files.length} file${files.length !== 1 ? 's' : ''} uploaded successfully`,
@ -172,53 +79,9 @@ export function Configuration() {
isClosable: true,
});
};
// Deletion handler not needed in Config anymore
const handleDeleteFile = async (fileId: string) => {
try {
const response = await fetch(`/api/music/${fileId}`, {
method: 'DELETE',
});
if (response.ok) {
setMusicFiles(prev => prev.filter(file => file._id !== fileId));
toast({
title: 'File Deleted',
description: 'Music file deleted successfully',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
throw new Error('Failed to delete file');
}
} catch (error) {
console.error('Error deleting file:', error);
toast({
title: 'Error',
description: 'Failed to delete music file',
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
const formatFileSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDuration = (seconds: number): string => {
if (!seconds || isNaN(seconds)) return '00:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// Utilities for file list removed with Music Library tab
const handleResetDatabase = async () => {
@ -300,12 +163,7 @@ export function Configuration() {
<Text>Upload Music</Text>
</HStack>
</Tab>
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiMusic} />
<Text>Music Library</Text>
</HStack>
</Tab>
{/* Hide heavy Music Library tab from config to reduce load; may move to separate page later */}
<Tab color="gray.300" _selected={{ bg: "gray.700", color: "white", borderColor: "gray.600" }}>
<HStack spacing={2}>
<Icon as={FiLink} />
@ -386,123 +244,40 @@ export function Configuration() {
</VStack>
</TabPanel>
{/* Music Library Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<VStack spacing={4} align="stretch">
<HStack justify="space-between">
<Heading size="md" color="white">Music Library</Heading>
<HStack spacing={2}>
<Text color="gray.400">
{musicFiles.length} file{musicFiles.length !== 1 ? 's' : ''}
</Text>
<Button
leftIcon={isSyncing ? <Spinner size="sm" /> : <FiRefreshCw />}
size="sm"
variant="outline"
colorScheme="blue"
onClick={handleSyncS3}
isLoading={isSyncing}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..."
_hover={{ bg: "blue.900", borderColor: "blue.400" }}
>
Sync S3
</Button>
</HStack>
</HStack>
{tabIndex === TAB_INDEX.MUSIC_LIBRARY && isLoading ? (
<Text textAlign="center" color="gray.500">
Loading music files...
</Text>
) : tabIndex === TAB_INDEX.MUSIC_LIBRARY && musicFiles.length === 0 ? (
<VStack spacing={4} textAlign="center" color="gray.500">
<Text>No music files found in the database.</Text>
<Text fontSize="sm">
Try uploading files in the Upload tab, or click "Sync S3" to find files already in your S3 bucket.
</Text>
<Button
leftIcon={<FiRefreshCw />}
onClick={handleSyncS3}
isLoading={isSyncing}
isDisabled={!hasS3Config || isSyncing}
loadingText="Syncing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Sync S3 Bucket
</Button>
{!hasS3Config && (
<Text fontSize="xs" color="orange.300">
Configure your S3 connection in the S3 Configuration tab first.
</Text>
)}
</VStack>
) : (
<VStack spacing={2} align="stretch">
{musicFiles.map((file) => (
<Box
key={file._id}
p={4}
border="1px"
borderColor="gray.700"
borderRadius="md"
bg="gray.800"
_hover={{ bg: "gray.750" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack spacing={2} align="center">
<Text fontWeight="bold" fontSize="md" color="white" noOfLines={1}>
{file.title || file.originalName}
</Text>
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
{file.format?.toUpperCase() || 'AUDIO'}
</Badge>
{file.songId && (
<Badge colorScheme="green" size="sm" bg="green.900" color="green.200">
Linked to Rekordbox
</Badge>
)}
</HStack>
{file.artist && (
<Text fontSize="sm" color="gray.400" noOfLines={1}>
{file.artist}
</Text>
)}
{file.album && (
<Text fontSize="sm" color="gray.500" noOfLines={1}>
{file.album}
</Text>
)}
<HStack spacing={4} fontSize="xs" color="gray.500">
<Text>{formatDuration(file.duration || 0)}</Text>
<Text>{formatFileSize(file.size)}</Text>
<Text>{file.format?.toUpperCase()}</Text>
</HStack>
</VStack>
<HStack spacing={2}>
<IconButton
aria-label="Delete file"
icon={<FiTrash2 />}
size="sm"
variant="ghost"
colorScheme="red"
onClick={() => handleDeleteFile(file._id)}
_hover={{ bg: "red.900" }}
/>
</HStack>
</HStack>
</Box>
))}
</VStack>
)}
</VStack>
</TabPanel>
{/* Music Library tab removed from Config (heavy). Consider separate page if needed. */}
{/* Song Matching Tab */}
<TabPanel bg="gray.800" p={6} borderRadius="lg" borderWidth="1px" borderColor="gray.700">
<SongMatching />
<VStack spacing={6} align="stretch">
<Heading size="md" color="white">Sync and Matching</Heading>
<HStack spacing={3}>
<Button
leftIcon={<FiRefreshCw />}
colorScheme="blue"
variant="solid"
onClick={() => api.startS3Sync()}
>
Sync S3 (incremental)
</Button>
<Button
leftIcon={<FiRefreshCw />}
colorScheme="orange"
variant="outline"
onClick={() => api.startS3Sync({ force: true })}
>
Force Sync (rescan all)
</Button>
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
variant="outline"
onClick={() => api.startS3Sync({ clearLinks: true, force: true })}
>
Clear Links + Force Sync
</Button>
</HStack>
<SongMatching />
</VStack>
</TabPanel>
{/* Duplicates Tab */}

View File

@ -155,6 +155,16 @@ class Api {
return response.json();
}
async startS3Sync(options?: { force?: boolean; clearLinks?: boolean }): Promise<{ jobId: string; type: string }> {
const response = await fetch(`${API_BASE_URL}/music/sync-s3`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options || {})
});
if (!response.ok) throw new Error('Failed to start S3 sync');
return response.json();
}
async getJobProgress(jobId: string): Promise<any> {
const response = await fetch(`${API_BASE_URL}/background-jobs/progress/${jobId}`);
if (!response.ok) throw new Error('Failed to get job progress');